Merge branch 'develop' into bug/sev3/remove-validate-current-step-checkbox

This commit is contained in:
Mel O'Hagan 2022-07-28 09:25:55 +01:00
commit 6c1c51a22a
181 changed files with 5754 additions and 3270 deletions

View File

@ -32,6 +32,9 @@ A clear and concise description of what you expected to happen.
**Screenshots** **Screenshots**
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**App Export**
If possible - please attach an export of your budibase application for debugging/reproduction purposes.
**Desktop (please complete the following information):** **Desktop (please complete the following information):**
- OS: [e.g. iOS] - OS: [e.g. iOS]
- Browser [e.g. chrome, safari] - Browser [e.g. chrome, safari]

View File

@ -108,7 +108,7 @@ RUN chmod +x install.sh && ./install.sh
WORKDIR / WORKDIR /
ADD hosting/single/runner.sh . ADD hosting/single/runner.sh .
RUN chmod +x ./runner.sh RUN chmod +x ./runner.sh
ADD hosting/scripts/healthcheck.sh . ADD hosting/single/healthcheck.sh .
RUN chmod +x ./healthcheck.sh RUN chmod +x ./healthcheck.sh
ADD hosting/scripts/build-target-paths.sh . ADD hosting/scripts/build-target-paths.sh .

View File

@ -1,6 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
healthy=true healthy=true
if [ -f "/data/.env" ]; then
export $(cat /data/.env | xargs)
fi
if [[ $(curl -Lfk -s -w "%{http_code}\n" http://localhost/ -o /dev/null) -ne 200 ]]; then if [[ $(curl -Lfk -s -w "%{http_code}\n" http://localhost/ -o /dev/null) -ne 200 ]]; then
echo 'ERROR: Budibase is not running'; echo 'ERROR: Budibase is not running';
healthy=false healthy=false

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import BaseCache from "./base" import BaseCache from "./base"
import { getWritethroughClient } from "../redis/init" import { getWritethroughClient } from "../redis/init"
import { logWarn } from "../logging"
const DEFAULT_WRITE_RATE_MS = 10000 const DEFAULT_WRITE_RATE_MS = 10000
let CACHE: BaseCache | null = null let CACHE: BaseCache | null = null
@ -51,10 +52,8 @@ export async function put(
if (err.status !== 409) { if (err.status !== 409) {
throw err throw err
} else { } else {
// get the rev, update over it - this is risky, may change in future // Swallow 409s but log them
const readDoc = await db.get(doc._id) logWarn(`Ignoring conflict in write-through cache`)
doc._rev = readDoc._rev
await writeDb(doc)
} }
} }
} }

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import { doWithDB, allDbs } from "./index"
import { getCouchInfo } from "./pouch" import { getCouchInfo } from "./pouch"
import { getAppMetadata } from "../cache/appMetadata" import { getAppMetadata } from "../cache/appMetadata"
import { checkSlashesInUrl } from "../helpers" import { checkSlashesInUrl } from "../helpers"
import { isDevApp, isDevAppID } from "./conversions" import { isDevApp, isDevAppID, getProdAppID } from "./conversions"
import { APP_PREFIX } from "./constants" import { APP_PREFIX } from "./constants"
import * as events from "../events" 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. * Generates a template ID.
* @param ownerId The owner/user of the template, this could be global or a workspace level. * @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()}` 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. * 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( export function pagination(
data: any[], data: any[],
pageSize: number, 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) { if (!paginate) {
return { data, hasNextPage: false } return { data, hasNextPage: false }
} }
const hasNextPage = data.length > pageSize const hasNextPage = data.length > pageSize
let nextPage = undefined let nextPage = undefined
if (!getKey) {
getKey = (doc: any) => (property ? doc?.[property] : doc?._id)
}
if (hasNextPage) { if (hasNextPage) {
nextPage = property ? data[pageSize]?.[property] : data[pageSize]?._id nextPage = getKey(data[pageSize])
} }
return { return {
data: data.slice(0, pageSize), data: data.slice(0, pageSize),

View File

@ -56,6 +56,33 @@ exports.createNewUserEmailView = async () => {
await db.put(designDoc) 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 () => { exports.createApiKeyView = async () => {
const db = getGlobalDB() const db = getGlobalDB()
let designDoc let designDoc
@ -106,6 +133,7 @@ exports.queryGlobalView = async (viewName, params, db = null) => {
[ViewNames.USER_BY_EMAIL]: exports.createNewUserEmailView, [ViewNames.USER_BY_EMAIL]: exports.createNewUserEmailView,
[ViewNames.BY_API_KEY]: exports.createApiKeyView, [ViewNames.BY_API_KEY]: exports.createApiKeyView,
[ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView, [ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView,
[ViewNames.USER_BY_APP]: exports.createUserAppView,
} }
// can pass DB in if working with something specific // can pass DB in if working with something specific
if (!db) { if (!db) {

View File

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

View File

@ -4,6 +4,7 @@ const type = "license_error"
const codes = { const codes = {
USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded",
FEATURE_DISABLED: "feature_disabled",
} }
const context = { const context = {
@ -12,6 +13,11 @@ const context = {
limitName: err.limitName, limitName: err.limitName,
} }
}, },
[codes.FEATURE_DISABLED]: err => {
return {
featureName: err.featureName,
}
},
} }
class UsageLimitError extends HTTPError { 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 = { module.exports = {
type, type,
codes, codes,
context, context,
UsageLimitError, 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 view from "./view"
export * as installation from "./installation" export * as installation from "./installation"
export * as backfill from "./backfill" export * as backfill from "./backfill"
export * as group from "./group"

View File

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

View File

@ -15,6 +15,11 @@ export function logAlert(message: string, e?: any) {
console.error(`bb-alert: ${message} ${errorJson}`) console.error(`bb-alert: ${message} ${errorJson}`)
} }
export function logWarn(message: string) {
console.warn(`bb-warn: ${message}`)
}
export default { export default {
logAlert, logAlert,
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

@ -127,7 +127,7 @@ module.exports = (
} }
if (!user && tenantId) { if (!user && tenantId) {
user = { tenantId } user = { tenantId }
} else { } else if (user) {
delete user.password delete user.password
} }
// be explicit // be explicit

View File

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

@ -76,7 +76,7 @@ function isBuiltin(role) {
/** /**
* Works through the inheritance ranks to see how far up the builtin stack this ID is. * 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 builtins = exports.getBuiltinRoles()
const MAX = Object.values(BUILTIN_IDS).length + 1 const MAX = Object.values(BUILTIN_IDS).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) { if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
@ -104,7 +104,8 @@ exports.lowerBuiltinRoleID = (roleId1, roleId2) => {
if (!roleId2) { if (!roleId2) {
return roleId1 return roleId1
} }
return builtinRoleToNumber(roleId1) > builtinRoleToNumber(roleId2) return exports.builtinRoleToNumber(roleId1) >
exports.builtinRoleToNumber(roleId2)
? roleId2 ? roleId2
: roleId1 : 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 { queryGlobalView } = require("./db/views")
const { UNICODE_MAX } = require("./db/constants") const { UNICODE_MAX } = require("./db/constants")
@ -13,12 +18,32 @@ exports.getGlobalUserByEmail = async email => {
throw "Must supply an email address to view" 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(), key: email.toLowerCase(),
include_docs: true, 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, "passwordResetRequested")
jest.spyOn(events.user, "passwordReset") 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, "servedBuilder")
jest.spyOn(events.serve, "servedApp") jest.spyOn(events.serve, "servedApp")
jest.spyOn(events.serve, "servedAppPreview") jest.spyOn(events.serve, "servedAppPreview")

View File

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

View File

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

View File

@ -0,0 +1,218 @@
<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}
>
<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 readonly = false
export let autocomplete = false export let autocomplete = false
export let sort = false export let sort = false
export let autoWidth = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: selectedLookupMap = getSelectedLookupMap(value) $: selectedLookupMap = getSelectedLookupMap(value)
@ -85,4 +86,5 @@
{getOptionValue} {getOptionValue}
onSelectOption={toggleOption} onSelectOption={toggleOption}
{sort} {sort}
{autoWidth}
/> />

View File

@ -0,0 +1,430 @@
<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"
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 getPrimaryOptionLabel = option => option
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)
$: filteredOptions = getFilteredOptions(
primaryOptions,
searchTerm,
getPrimaryOptionLabel
)
let iconData
/*
$: iconData = primaryOptions?.find(x => {
return x.name === primaryFieldText
})
*/
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)
}
}
const getFilteredOptions = (options, term, getLabel) => {
if (autocomplete && term) {
const lowerCaseTerm = term.toLowerCase()
return options.filter(option => {
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
})
}
return options
}
</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}
>
<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 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
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 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
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%;
}
.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 autoWidth = false
export let autocomplete = false export let autocomplete = false
export let sort = false export let sort = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
$: fieldText = getFieldText(value, options, placeholder) $: 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 getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let sort = false export let sort = false
export let autoWidth = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
value = e.detail value = e.detail
@ -33,6 +33,7 @@
{sort} {sort}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{autoWidth}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

View File

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

View File

@ -26,5 +26,9 @@
<style> <style>
.tooltip { .tooltip {
pointer-events: none; pointer-events: none;
background: var(--spectrum-global-color-gray-500);
}
.spectrum-Tooltip-tip {
border-top-color: var(--spectrum-global-color-gray-500);
} }
</style> </style>

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 Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte" export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte" export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as InputDropdown } from "./Form/InputDropdown.svelte"
export { default as PickerDropdown } from "./Form/PickerDropdown.svelte"
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte" export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
export { default as Popover } from "./Popover/Popover.svelte" export { default as Popover } from "./Popover/Popover.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.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 Badge } from "./Badge/Badge.svelte"
export { default as StatusLight } from "./StatusLight/StatusLight.svelte" export { default as StatusLight } from "./StatusLight/StatusLight.svelte"
export { default as ColorPicker } from "./ColorPicker/ColorPicker.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 InlineAlert } from "./InlineAlert/InlineAlert.svelte"
export { default as Banner } from "./Banner/Banner.svelte" export { default as Banner } from "./Banner/Banner.svelte"
export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte" export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte"
export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte" export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte"
export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte" export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte"
export { default as RichTextField } from "./Form/RichTextField.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 IconSideNav } from "./IconSideNav/IconSideNav.svelte"
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte" export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
export { default as Slider } from "./Form/Slider.svelte" export { default as Slider } from "./Form/Slider.svelte"
@ -71,6 +76,7 @@ export { default as Slider } from "./Form/Slider.svelte"
// Renderers // Renderers
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte" export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
export { default as CodeRenderer } from "./Table/CodeRenderer.svelte" export { default as CodeRenderer } from "./Table/CodeRenderer.svelte"
export { default as InternalRenderer } from "./Table/InternalRenderer.svelte"
// Typography // Typography
export { default as Body } from "./Typography/Body.svelte" export { default as Body } from "./Typography/Body.svelte"

File diff suppressed because it is too large Load Diff

View File

@ -17,16 +17,15 @@ filterTests(['all'], () => {
it("should add form with multi select picker, containing 5 options", () => { it("should add form with multi select picker, containing 5 options", () => {
cy.navigateToFrontend() cy.navigateToFrontend()
// Add data provider // Add data provider
cy.get(interact.CATEGORY_DATA, { timeout: 500 }).click() cy.searchAndAddComponent("Data Provider")
cy.get(interact.COMPONENT_DATA_PROVIDER).click()
cy.get(interact.DATASOURCE_PROP_CONTROL).click() cy.get(interact.DATASOURCE_PROP_CONTROL).click()
cy.get(interact.DROPDOWN).contains("Multi Data").click() cy.get(interact.DROPDOWN).contains("Multi Data").click()
// Add Form with schema to match table // Add Form with schema to match table
cy.addComponent("Form", "Form") cy.searchAndAddComponent("Form")
cy.get(interact.DATASOURCE_PROP_CONTROL).click() cy.get(interact.DATASOURCE_PROP_CONTROL).click()
cy.get(interact.DROPDOWN).contains("Multi Data").click() cy.get(interact.DROPDOWN).contains("Multi Data").click()
// Add multi-select picker to form // 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.get(interact.DATASOURCE_FIELD_CONTROL).type("Test Data").type("{enter}")
cy.wait(1000) cy.wait(1000)
cy.getComponent(componentId).contains("Choose some options").click() 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", () => { it("should add Radio Buttons options picker on form, add data, and confirm", () => {
cy.navigateToFrontend() cy.navigateToFrontend()
cy.wait(500) cy.searchAndAddComponent("Form")
cy.addComponent("Form", "Form") cy.searchAndAddComponent("Options Picker").then((componentId) => {
cy.addComponent("Form", "Options Picker").then((componentId) => {
// Provide field setting // Provide field setting
cy.get(interact.DATASOURCE_FIELD_CONTROL).type("1") cy.get(interact.DATASOURCE_FIELD_CONTROL).type("1")
// Open dropdown and select Radio buttons // Open dropdown and select Radio buttons
cy.get(interact.OPTION_TYPE_PROP_CONTROL).click().then(() => { cy.get(interact.OPTION_TYPE_PROP_CONTROL).click().then(() => {
cy.get(interact.SPECTRUM_POPOVER).contains('Radio buttons') cy.get(interact.SPECTRUM_POPOVER).contains('Radio buttons')
.wait(500)
.click() .click()
}) })
const radioButtonsTotal = 3 const radioButtonsTotal = 3
@ -32,8 +30,8 @@ filterTests(['all'], () => {
const addRadioButtonData = (totalRadioButtons) => { const addRadioButtonData = (totalRadioButtons) => {
cy.get(interact.OPTION_SOURCE_PROP_CONROL).click().then(() => { cy.get(interact.OPTION_SOURCE_PROP_CONROL).click().then(() => {
cy.get(interact.SPECTRUM_POPOVER).contains('Custom') cy.get(interact.SPECTRUM_POPOVER).contains('Custom')
.wait(500)
.click() .click()
.wait(1000)
}) })
cy.addCustomSourceOptions(totalRadioButtons) cy.addCustomSourceOptions(totalRadioButtons)
} }

View File

@ -57,6 +57,7 @@ filterTests(["smoke", "all"], () => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000}) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000})
cy.get(interact.SPECTRUM_SIDENAV).contains("Users").click() cy.get(interact.SPECTRUM_SIDENAV).contains("Users").click()
cy.get(interact.SPECTRUM_TABLE, { timeout: 1000 }).contains("bbuser").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++) { for (let i = 0; i < 3; i++) {
cy.get(interact.SPECTRUM_TABLE, { timeout: 3000}) cy.get(interact.SPECTRUM_TABLE, { timeout: 3000})
.eq(1) .eq(1)

View File

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

View File

@ -14,11 +14,8 @@ filterTests(['smoke', 'all'], () => {
cy.closeModal(); cy.closeModal();
cy.contains("Design").click() cy.contains("Design").click()
cy.get(interact.LABEL_ADD_CIRCLE).click() cy.navigateToAutogeneratedModal()
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.get(interact.CONFIRM_WRAP_SPE_BUTTON).should('be.disabled')
})
cy.deleteAllApps() cy.deleteAllApps()
}); });
@ -45,25 +42,25 @@ filterTests(['smoke', 'all'], () => {
// Create Autogenerated screens from the internal table // Create Autogenerated screens from the internal table
cy.createDatasourceScreen(["Cypress Tests"]) cy.createDatasourceScreen(["Cypress Tests"])
// Confirm screens have been auto generated // Confirm screens have been auto generated
cy.get(interact.NAV_ITEMS_CONTAINER).contains("cypress-tests").click({ force: true }) cy.get(interact.BODY).should('contain', "cypress-tests")
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'cypress-tests/:id') .and('contain', 'cypress-tests/:id')
.and('contain', 'cypress-tests/new/row') .and('contain', 'cypress-tests/new/row')
}) })
it("should generate multiple internal table screens at once", () => { it("should generate multiple internal table screens at once", () => {
// Create a second internal table
const initialTable = "Cypress Tests" const initialTable = "Cypress Tests"
const secondTable = "Table Two" const secondTable = "Table Two"
// Create a second internal table
cy.createTable(secondTable) cy.createTable(secondTable)
// Create Autogenerated screens from the internal tables // Create Autogenerated screens from the internal tables
cy.createDatasourceScreen([initialTable, secondTable]) cy.createDatasourceScreen([initialTable, secondTable])
// Confirm screens have been auto generated // 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 // 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') .and('contain', 'cypress-tests-2/new/row')
cy.get(interact.NAV_ITEMS_CONTAINER).contains("table-two").click() .and('contain', 'table-two')
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'table-two/:id') .and('contain', 'table-two/:id')
.and('contain', 'table-two/new/row') .and('contain', 'table-two/new/row')
}) })
@ -73,17 +70,17 @@ filterTests(['smoke', 'all'], () => {
cy.createTable("Table Four") cy.createTable("Table Four")
cy.createDatasourceScreen(["Table Three", "Table Four"], "Admin") cy.createDatasourceScreen(["Table Three", "Table Four"], "Admin")
cy.get(interact.NAV_ITEMS_CONTAINER).contains("table-three").click() // Filter screens to Admin
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'table-three/:id') cy.filterScreensAccessLevel('Admin')
cy.get(interact.BODY).should('contain', 'table-three')
.and('contain', 'table-three/:id')
.and('contain', 'table-three/new/row') .and('contain', 'table-three/new/row')
.and('contain', 'table-four')
cy.get(interact.NAV_ITEMS_CONTAINER).contains("table-four").click() .and('contain', 'table-four/:id')
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'table-four/:id')
.and('contain', 'table-four/new/row') .and('contain', 'table-four/new/row')
.and('not.contain', 'table-two')
//The access level should now be set to admin. Previous screens should be filtered. .and('not.contain', 'cypress-tests')
cy.get(interact.NAV_ITEMS_CONTAINER).contains("table-two").should('not.exist')
cy.get(interact.NAV_ITEMS_CONTAINER).contains("cypress-tests").should('not.exist')
}) })
if (Cypress.env("TEST_ENV")) { if (Cypress.env("TEST_ENV")) {
@ -96,8 +93,8 @@ filterTests(['smoke', 'all'], () => {
// Create Autogenerated screens from a MySQL table - MySQL contains books table // Create Autogenerated screens from a MySQL table - MySQL contains books table
cy.createDatasourceScreen(["books"]) cy.createDatasourceScreen(["books"])
cy.get(interact.NAV_ITEMS_CONTAINER).contains("books").click() cy.get(interact.BODY).should('contain', 'books')
cy.get(interact.NAV_ITEMS_CONTAINER).should('contain', 'books/:id') .and('contain', 'books/:id')
.and('contain', 'books/new/row') .and('contain', 'books/new/row')
}) })
} }

View File

@ -13,7 +13,7 @@ filterTests(['smoke', 'all'], () => {
it("should show the new user UI/UX", () => { it("should show the new user UI/UX", () => {
cy.visit(`${Cypress.config().baseUrl}/builder/portal/apps/create`, { timeout: 5000 }) //added /portal/apps/create cy.visit(`${Cypress.config().baseUrl}/builder/portal/apps/create`, { timeout: 5000 }) //added /portal/apps/create
cy.wait(1000) 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_FILTER).should("exist")
cy.get(interact.TEMPLATE_CATEGORY).should("exist") cy.get(interact.TEMPLATE_CATEGORY).should("exist")

View File

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

View File

@ -31,13 +31,13 @@ filterTests(["all"], () => {
} }
it("should add a container", () => { it("should add a container", () => {
cy.addComponent("Layout", "Container").then(componentId => { cy.searchAndAddComponent("Container").then(componentId => {
cy.getComponent(componentId).should("exist") cy.getComponent(componentId).should("exist")
}) })
}) })
it("should add a headline", () => { it("should add a headline", () => {
cy.addComponent("Elements", "Headline").then(componentId => { cy.searchAndAddComponent("Headline").then(componentId => {
headlineId = componentId headlineId = componentId
cy.getComponent(headlineId).should("exist") cy.getComponent(headlineId).should("exist")
}) })
@ -63,11 +63,11 @@ filterTests(["all"], () => {
}) })
it("should create a form and reset to match schema", () => { 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("[data-cy=setting-dataSource]").contains("Custom").click()
cy.get(interact.DROPDOWN).contains("dog").click() cy.get(interact.DROPDOWN).contains("dog").click()
cy.wait(500) cy.wait(500)
cy.addComponent("Form", "Field Group").then(fieldGroupId => { cy.searchAndAddComponent("Field Group").then(fieldGroupId => {
cy.contains("Update form fields").click() cy.contains("Update form fields").click()
cy.get(".spectrum-Modal") cy.get(".spectrum-Modal")
.get(".confirm-wrap .spectrum-Button") .get(".confirm-wrap .spectrum-Button")
@ -88,7 +88,7 @@ filterTests(["all"], () => {
}) })
it("deletes a component", () => { 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("[data-cy=setting-_instanceName] input").type(componentId).blur()
cy.get( cy.get(
".nav-items-container .nav-item.selected .actions > div > .icon" ".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", () => { 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 //For deletion
cy.get("[data-cy=setting-_instanceName] input") cy.get("[data-cy=setting-_instanceName] input")
.clear() .clear()
@ -123,10 +123,7 @@ filterTests(["all"], () => {
const testFieldFocusOnCreate = componentLabel => { const testFieldFocusOnCreate = componentLabel => {
cy.log("Adding: " + componentLabel) cy.log("Adding: " + componentLabel)
return cy.addComponent("Form", componentLabel).then(componentId => { return cy.searchAndAddComponent(componentLabel).then(componentId => {
cy.getComponent(componentId)
.find(".component-placeholder")
.should("exist")
cy.get("[data-cy=setting-field] button.spectrum-Picker").click() cy.get("[data-cy=setting-field] button.spectrum-Picker").click()
//Click the first appropriate field. They are filtered by type //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", () => { 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 //For deletion
cy.get("[data-cy=setting-_instanceName] input") cy.get("[data-cy=setting-_instanceName] input")
.clear() .clear()
@ -181,7 +178,7 @@ filterTests(["all"], () => {
const testFocusOnCreate = chartLabel => { const testFocusOnCreate = chartLabel => {
cy.log("Adding: " + chartLabel) cy.log("Adding: " + chartLabel)
cy.addComponent("Chart", chartLabel).then(componentId => { cy.searchAndAddComponent(chartLabel).then(componentId => {
cy.get( cy.get(
"[data-cy=dataProvider-prop-control] .spectrum-Picker" "[data-cy=dataProvider-prop-control] .spectrum-Picker"
).should("not.have.class", "is-focused") ).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", () => { 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") cy.get("[data-cy=setting-_instanceName] input")
.clear() .clear()
.type(imageId) .type(imageId)
@ -229,7 +226,7 @@ filterTests(["all"], () => {
}) })
it("should add a markdown component.", () => { 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") cy.get("[data-cy=setting-_instanceName] input")
.clear() .clear()
.type(markdownId) .type(markdownId)
@ -253,8 +250,7 @@ filterTests(["all"], () => {
}) })
it("should direct the user when adding an Icon component.", () => { it("should direct the user when adding an Icon component.", () => {
cy.addComponent("Elements", "Icon").then(iconId => { cy.searchAndAddComponent("Icon").then(iconId => {
cy.getComponent(iconId).find(".component-placeholder").should("exist")
cy.get("[data-cy=setting-_instanceName] input") cy.get("[data-cy=setting-_instanceName] input")
.clear() .clear()
.type(iconId) .type(iconId)

View File

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

@ -108,7 +108,7 @@ filterTests(["all"], () => {
}) })
it("should delete a relationship", () => { 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() cy.reload()
// Delete one relationship // Delete one relationship
cy.get(".spectrum-Table") cy.get(".spectrum-Table")
@ -150,13 +150,15 @@ filterTests(["all"], () => {
cy.get("@query").its("response.statusCode").should("eq", 200) cy.get("@query").its("response.statusCode").should("eq", 200)
cy.get("@query").its("response.body").should("not.be.empty") cy.get("@query").its("response.body").should("not.be.empty")
// Save query // Save query
cy.intercept("**/queries").as("saveQuery")
cy.get(".spectrum-Button").contains("Save Query").click({ force: true }) cy.get(".spectrum-Button").contains("Save Query").click({ force: true })
cy.wait("@saveQuery")
cy.get(".spectrum-Tabs-content", { timeout: 2000 }).should("contain", queryName) cy.get(".spectrum-Tabs-content", { timeout: 2000 }).should("contain", queryName)
}) })
it("should switch to schema with no tables", () => { it("should switch to schema with no tables", () => {
// Switch Schema - To one without any 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") switchSchema("randomText")
// No tables displayed // No tables displayed
@ -219,7 +221,7 @@ filterTests(["all"], () => {
// Access query // Access query
cy.get(".hierarchy-items-container", { timeout: 2000 }) cy.get(".hierarchy-items-container", { timeout: 2000 })
.contains(queryName + " (1)") .contains(queryName + " (1)")
.click() .click({ force: true })
// Rename query // Rename query
cy.wait(1000) cy.wait(1000)

View File

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

View File

@ -4,7 +4,7 @@ Cypress.on("uncaught:exception", () => {
// ACCOUNTS & USERS // ACCOUNTS & USERS
Cypress.Commands.add("login", (email, password) => { Cypress.Commands.add("login", (email, password) => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 })
cy.wait(2000) cy.wait(2000)
cy.url().then(url => { cy.url().then(url => {
if (url.includes("builder/admin")) { if (url.includes("builder/admin")) {
@ -210,7 +210,7 @@ Cypress.Commands.add("deleteApp", name => {
cy.get(".app-overview-actions-icon").within(() => { cy.get(".app-overview-actions-icon").within(() => {
cy.get(".spectrum-Icon").click({ force: true }) 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(".spectrum-Dialog-grid").within(() => {
cy.get("input").type(name) cy.get("input").type(name)
}) })
@ -289,7 +289,7 @@ Cypress.Commands.add("updateAppName", (changedName, noName) => {
}) })
Cypress.Commands.add("publishApp", resolvedAppPath => { 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") cy.get(".toprightnav button.spectrum-Button")
.contains("Publish") .contains("Publish")
.click({ force: true }) .click({ force: true })
@ -301,7 +301,7 @@ Cypress.Commands.add("publishApp", resolvedAppPath => {
cy.wait(1000) 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']") cy.get(".spectrum-Modal [data-cy='deploy-app-success-modal']")
.should("be.visible") .should("be.visible")
.within(() => { .within(() => {
@ -422,7 +422,12 @@ Cypress.Commands.add("createTable", (tableName, initialTable) => {
cy.get("input", { timeout: 2000 }).first().type(tableName).blur() cy.get("input", { timeout: 2000 }).first().type(tableName).blur()
cy.get(".spectrum-ButtonGroup").contains("Create").click() 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", () => { Cypress.Commands.add("createTestTableWithData", () => {
@ -491,13 +496,12 @@ Cypress.Commands.add("selectTable", tableName => {
}) })
Cypress.Commands.add("addCustomSourceOptions", totalOptions => { Cypress.Commands.add("addCustomSourceOptions", totalOptions => {
cy.get(".spectrum-ActionButton") cy.get('[data-cy="customOptions-prop-control"]').within(() => {
.contains("Define Options") cy.get(".spectrum-ActionButton-label").click({ force: true })
.click() })
.then(() => {
for (let i = 0; i < totalOptions; i++) { for (let i = 0; i < totalOptions; i++) {
// Add radio button options // Add radio button options
cy.get(".spectrum-Button") cy.get(".spectrum-Button-label", { timeout: 1000 })
.contains("Add Option") .contains("Add Option")
.click({ force: true }) .click({ force: true })
.then(() => { .then(() => {
@ -507,10 +511,30 @@ Cypress.Commands.add("addCustomSourceOptions", totalOptions => {
} }
// Save options // Save options
cy.get(".spectrum-Button").contains("Save").click({ force: true }) cy.get(".spectrum-Button").contains("Save").click({ force: true })
})
// DESIGN SECTION
Cypress.Commands.add("searchAndAddComponent", component => {
// Open component menu
cy.get(".spectrum-Button").contains("Component").click({ force: true })
// Search and add component
cy.wait(500)
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) => { Cypress.Commands.add("addComponent", (category, component) => {
if (category) { if (category) {
cy.get(`[data-cy="category-${category}"]`, { timeout: 3000 }).click({ cy.get(`[data-cy="category-${category}"]`, { timeout: 3000 }).click({
@ -546,7 +570,7 @@ Cypress.Commands.add("getComponent", componentId => {
Cypress.Commands.add("createScreen", (route, accessLevelLabel) => { Cypress.Commands.add("createScreen", (route, accessLevelLabel) => {
// Blank Screen // Blank Screen
cy.contains("Design").click() cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click() cy.get(".header > .add-button").click()
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
cy.get("[data-cy='blank-screen']").click() cy.get("[data-cy='blank-screen']").click()
cy.get(".spectrum-Button").contains("Continue").click({ force: true }) cy.get(".spectrum-Button").contains("Continue").click({ force: true })
@ -571,7 +595,7 @@ Cypress.Commands.add(
"createDatasourceScreen", "createDatasourceScreen",
(datasourceNames, accessLevelLabel) => { (datasourceNames, accessLevelLabel) => {
cy.contains("Design").click() cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click() cy.get(".header > .add-button").click()
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
cy.get(".item").contains("Autogenerated screens").click() cy.get(".item").contains("Autogenerated screens").click()
cy.get(".spectrum-Button").contains("Continue").click({ force: true }) cy.get(".spectrum-Button").contains("Continue").click({ force: true })
@ -626,13 +650,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 // NAVIGATION
Cypress.Commands.add("navigateToFrontend", () => { Cypress.Commands.add("navigateToFrontend", () => {
// Clicks on Design tab and then the Home nav item // Clicks on Design tab and then the Home nav item
cy.wait(500) cy.wait(500)
cy.contains("Design").click() cy.contains("Design").click()
cy.get(".spectrum-Search", { timeout: 2000 }).type("/") 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", () => { Cypress.Commands.add("navigateToDataSection", () => {
@ -644,9 +715,11 @@ Cypress.Commands.add("navigateToDataSection", () => {
Cypress.Commands.add("navigateToAutogeneratedModal", () => { Cypress.Commands.add("navigateToAutogeneratedModal", () => {
// Screen name must already exist within data source // Screen name must already exist within data source
cy.contains("Design").click() cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click() cy.get(".header > .add-button").click()
cy.get(".spectrum-Modal").within(() => { 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.get(".spectrum-Button").contains("Continue").click({ force: true })
cy.wait(500) 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_BUTTON_GROUP = ".spectrum-ButtonGroup"
export const SPECTRUM_MODAL_INPUT = ".spectrum-Modal input" export const SPECTRUM_MODAL_INPUT = ".spectrum-Modal input"
//AddMultiOptionDatatype test //AddMultiOptionDatatype
export const CATEGORY_DATA = '[data-cy="category-Data"]' export const CATEGORY_DATA = '[data-cy="category-Data"]'
export const COMPONENT_DATA_PROVIDER = '[data-cy="component-Data Provider"]' export const COMPONENT_DATA_PROVIDER = '[data-cy="component-Data Provider"]'
export const DATASOURCE_PROP_CONTROL = '[data-cy="dataSource-prop-control"]' 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 ITEM_DISABLED = ".item.disabled"
export const CONFIRM_WRAP_SPE_BUTTON = ".confirm-wrap .spectrum-Button" export const CONFIRM_WRAP_SPE_BUTTON = ".confirm-wrap .spectrum-Button"
export const DATA_SOURCE_ENTRY = ".data-source-entry" export const DATA_SOURCE_ENTRY = ".data-source-entry"
export const NAV_ITEMS_CONTAINER = ".nav-items-container" export const BODY = ".body"
//publishWorkFlow //publishWorkFlow
export const DEPLOY_APP_MODAL = ".spectrum-Modal [data-cy=deploy-app-modal]" export const DEPLOY_APP_MODAL = ".spectrum-Modal [data-cy=deploy-app-modal]"
@ -108,6 +108,7 @@ export const CONTAINER = ".container"
export const REGENERATE = ".regenerate" export const REGENERATE = ".regenerate"
export const SPECTRUM_DIALOG_CONTENT = ".spectrum-Dialog-content" export const SPECTRUM_DIALOG_CONTENT = ".spectrum-Dialog-content"
export const SPECTRUM_ICON = ".spectrum-Icon" export const SPECTRUM_ICON = ".spectrum-Icon"
export const SPECTRUM_HEADING = ".spectrum-Heading"
//createView //createView
export const SPECTRUM_MENU_ITEM_LABEL = ".spectrum-Menu-itemLabel" export const SPECTRUM_MENU_ITEM_LABEL = ".spectrum-Menu-itemLabel"

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "1.1.18-alpha.0", "version": "1.1.29-alpha.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -69,10 +69,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.1.18-alpha.0", "@budibase/bbui": "^1.1.29-alpha.0",
"@budibase/client": "^1.1.18-alpha.0", "@budibase/client": "^1.1.29-alpha.0",
"@budibase/frontend-core": "^1.1.18-alpha.0", "@budibase/frontend-core": "^1.1.29-alpha.0",
"@budibase/string-templates": "^1.1.18-alpha.0", "@budibase/string-templates": "^1.1.29-alpha.0",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",
@ -113,7 +113,7 @@
"rollup": "^2.44.0", "rollup": "^2.44.0",
"rollup-plugin-copy": "^3.4.0", "rollup-plugin-copy": "^3.4.0",
"start-server-and-test": "^1.12.1", "start-server-and-test": "^1.12.1",
"svelte": "^3.49.0", "svelte": "^3.48.0",
"svelte-jester": "^1.3.2", "svelte-jester": "^1.3.2",
"ts-node": "^10.4.0", "ts-node": "^10.4.0",
"tsconfig-paths": "4.0.0", "tsconfig-paths": "4.0.0",

View File

@ -1,11 +1,10 @@
import { createLocalStorageStore } from "@budibase/frontend-core" import { Constants, createLocalStorageStore } from "@budibase/frontend-core"
export const getThemeStore = () => { export const getThemeStore = () => {
const themeElement = document.documentElement const themeElement = document.documentElement
const initialValue = { const initialValue = {
theme: "darkest", theme: "darkest",
options: ["lightest", "light", "dark", "darkest", "nord"],
} }
const store = createLocalStorageStore("bb-theme", initialValue) const store = createLocalStorageStore("bb-theme", initialValue)
@ -17,11 +16,14 @@ export const getThemeStore = () => {
return return
} }
state.options.forEach(option => { Constants.ThemeOptions.forEach(option => {
themeElement.classList.toggle( themeElement.classList.toggle(
`spectrum--${option}`, `spectrum--${option}`,
option === state.theme option === state.theme
) )
// Ensure darkest is always added as this is the base class for custom
// themes
themeElement.classList.add("spectrum--darkest") themeElement.classList.add("spectrum--darkest")
}) })
}) })

View File

@ -52,8 +52,9 @@
x => x.blockToLoop === block.id x => x.blockToLoop === block.id
) )
$: setPermissions(role) $: isAppAction = block?.stepId === TriggerStepID.APP
$: getPermissions(automationId) $: isAppAction && setPermissions(role)
$: isAppAction && getPermissions(automationId)
async function setPermissions(role) { async function setPermissions(role) {
if (!role || !automationId) { if (!role || !automationId) {
@ -238,7 +239,7 @@
</div> </div>
{/if} {/if}
{#if block.stepId === TriggerStepID.APP} {#if isAppAction}
<Label>Role</Label> <Label>Role</Label>
<RoleSelect bind:value={role} /> <RoleSelect bind:value={role} />
{/if} {/if}

View File

@ -15,16 +15,20 @@
let trigger = {} let trigger = {}
let schemaProperties = {} let schemaProperties = {}
$: {
// clone the trigger so we're not mutating the reference // clone the trigger so we're not mutating the reference
$: trigger = cloneDeep( trigger = cloneDeep(
$automationStore.selectedAutomation.automation.definition.trigger $automationStore.selectedAutomation.automation.definition.trigger
) )
// get the outputs so we can define the fields // get the outputs so we can define the fields
$: schemaProperties = Object.entries(trigger?.schema?.outputs?.properties) let schema = Object.entries(trigger.schema?.outputs?.properties || {})
if (!$automationStore.selectedAutomation.automation.testData) { if (trigger?.event === "app:trigger") {
$automationStore.selectedAutomation.automation.testData = {} schema = [["fields", { customType: "fields" }]]
}
schemaProperties = schema
} }
// check to see if there is existing test data in the store // check to see if there is existing test data in the store

View File

@ -5,9 +5,8 @@
import { ActionStepID } from "constants/backend/automations" import { ActionStepID } from "constants/backend/automations"
export let automation export let automation
export let testResults
let blocks let blocks, testResults
$: { $: {
blocks = [] blocks = []
@ -18,15 +17,11 @@
blocks = blocks blocks = blocks
.concat(automation.definition.steps || []) .concat(automation.definition.steps || [])
.filter(x => x.stepId !== ActionStepID.LOOP) .filter(x => x.stepId !== ActionStepID.LOOP)
} else if (testResults) { } else if ($automationStore.selectedAutomation) {
blocks = testResults.steps || [] automation = $automationStore.selectedAutomation
}
}
$: {
if (!testResults) {
testResults = $automationStore.selectedAutomation?.testResults
} }
} }
$: testResults = $automationStore.selectedAutomation?.testResults
</script> </script>
<div class="title"> <div class="title">

View File

@ -1,6 +1,7 @@
<script> <script>
import TableSelector from "./TableSelector.svelte" import TableSelector from "./TableSelector.svelte"
import RowSelector from "./RowSelector.svelte" import RowSelector from "./RowSelector.svelte"
import FieldSelector from "./FieldSelector.svelte"
import SchemaSetup from "./SchemaSetup.svelte" import SchemaSetup from "./SchemaSetup.svelte"
import { import {
Button, Button,
@ -31,6 +32,7 @@
import { getSchemaForTable } from "builderStore/dataBinding" import { getSchemaForTable } from "builderStore/dataBinding"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { TriggerStepID, ActionStepID } from "constants/backend/automations" import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { cloneDeep } from "lodash/fp"
export let block export let block
export let testData export let testData
@ -41,13 +43,25 @@
let tempFilters = lookForFilters(schemaProperties) || [] let tempFilters = lookForFilters(schemaProperties) || []
let fillWidth = true let fillWidth = true
let codeBindingOpen = false let codeBindingOpen = false
let inputData
$: stepId = block.stepId $: stepId = block.stepId
$: bindings = getAvailableBindings( $: bindings = getAvailableBindings(
block || $automationStore.selectedBlock, block || $automationStore.selectedBlock,
$automationStore.selectedAutomation?.automation?.definition $automationStore.selectedAutomation?.automation?.definition
) )
$: inputData = testData ? testData : block.inputs
$: getInputData(testData, block.inputs)
const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs
if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs)
}
inputData = newInputData
}
$: tableId = inputData ? inputData.tableId : null $: tableId = inputData ? inputData.tableId : null
$: table = tableId $: table = tableId
? $tables.list.find(table => table._id === inputData.tableId) ? $tables.list.find(table => table._id === inputData.tableId)
@ -73,15 +87,13 @@
[key]: e.detail, [key]: e.detail,
}) })
testData[key] = e.detail testData[key] = e.detail
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
} else { } else {
block.inputs[key] = e.detail block.inputs[key] = e.detail
}
await automationStore.actions.save( await automationStore.actions.save(
$automationStore.selectedAutomation?.automation $automationStore.selectedAutomation?.automation
) )
}
} catch (error) { } catch (error) {
notifications.error("Error saving automation") notifications.error("Error saving automation")
} }
@ -185,11 +197,13 @@
<div class="fields"> <div class="fields">
{#each schemaProperties as [key, value]} {#each schemaProperties as [key, value]}
<div class="block-field"> <div class="block-field">
{#if key !== "fields"}
<Label <Label
tooltip={value.title === "Binding / Value" tooltip={value.title === "Binding / Value"
? "If using the String input type, please use a comma or newline separated string" ? "If using the String input type, please use a comma or newline separated string"
: null}>{value.title || (key === "row" ? "Table" : key)}</Label : null}>{value.title || (key === "row" ? "Table" : key)}</Label
> >
{/if}
{#if value.type === "string" && value.enum} {#if value.type === "string" && value.enum}
<Select <Select
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
@ -246,6 +260,7 @@
{bindings} {bindings}
allowJS={false} allowJS={false}
updateOnChange={false} updateOnChange={false}
drawerLeft="260px"
/> />
{/if} {/if}
{:else if value.customType === "query"} {:else if value.customType === "query"}
@ -280,6 +295,14 @@
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
value={inputData[key]} value={inputData[key]}
/> />
{:else if value.customType === "fields"}
<FieldSelector
{block}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
{isTestModal}
/>
{:else if value.customType === "triggerSchema"} {:else if value.customType === "triggerSchema"}
<SchemaSetup on:change={e => onChange(e, key)} value={inputData[key]} /> <SchemaSetup on:change={e => onChange(e, key)} value={inputData[key]} />
{:else if value.customType === "code"} {:else if value.customType === "code"}
@ -335,6 +358,7 @@
{bindings} {bindings}
updateOnChange={false} updateOnChange={false}
placeholder={value.customType === "queryLimit" ? queryLimit : ""} placeholder={value.customType === "queryLimit" ? queryLimit : ""}
drawerLeft="260px"
/> />
</div> </div>
{/if} {/if}

View File

@ -0,0 +1,114 @@
<script>
import { createEventDispatcher } from "svelte"
import RowSelectorTypes from "./RowSelectorTypes.svelte"
const dispatch = createEventDispatcher()
export let value
export let bindings
export let block
export let isTestModal
let schemaFields
$: {
let fields = {}
for (const [key, type] of Object.entries(block?.inputs?.fields)) {
fields = {
...fields,
[key]: {
type: type,
name: key,
fieldName: key,
constraints: { type: type },
},
}
if (value[key] === type) {
value[key] = INITIAL_VALUES[type.toUpperCase()]
}
}
schemaFields = Object.entries(fields)
}
const INITIAL_VALUES = {
BOOLEAN: null,
NUMBER: null,
DATETIME: null,
STRING: "",
OPTIONS: [],
ARRAY: [],
}
const coerce = (value, type) => {
const re = new RegExp(/{{([^{].*?)}}/g)
if (re.test(value)) {
return value
}
if (type === "boolean") {
if (typeof value === "boolean") {
return value
}
return value === "true"
}
if (type === "number") {
if (typeof value === "number") {
return value
}
return Number(value)
}
if (type === "options") {
return [value]
}
if (type === "array") {
if (Array.isArray(value)) {
return value
}
return value.split(",").map(x => x.trim())
}
if (type === "link") {
if (Array.isArray(value)) {
return value
}
return [value]
}
return value
}
const onChange = (e, field, type) => {
value[field] = coerce(e.detail, type)
dispatch("change", value)
}
</script>
{#if schemaFields.length && isTestModal}
<div class="schema-fields">
{#each schemaFields as [field, schema]}
<RowSelectorTypes
{isTestModal}
{field}
{schema}
{bindings}
{value}
{onChange}
/>
{/each}
</div>
{/if}
<style>
.schema-fields {
display: grid;
grid-gap: var(--spacing-s);
margin-top: var(--spacing-s);
}
.schema-fields :global(label) {
text-transform: capitalize;
}
</style>

View File

@ -211,7 +211,6 @@
bindings={getAuthBindings()} bindings={getAuthBindings()}
on:change={e => { on:change={e => {
form.bearer.token = e.detail form.bearer.token = e.detail
console.log(e.detail)
onFieldChange() onFieldChange()
}} }}
on:blur={() => { 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 fillWidth
export let allowJS = true export let allowJS = true
export let updateOnChange = true export let updateOnChange = true
export let drawerLeft
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
@ -53,7 +54,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<Drawer {fillWidth} bind:this={bindingDrawer} {title}> <Drawer {fillWidth} bind:this={bindingDrawer} {title} left={drawerLeft}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Add the objects on the left to enrich your text. Add the objects on the left to enrich your text.
</svelte:fragment> </svelte:fragment>

View File

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

View File

@ -58,7 +58,7 @@
thin thin
disabled={bindable} disabled={bindable}
on:change={evt => onBindingChange(binding.name, evt.detail)} on:change={evt => onBindingChange(binding.name, evt.detail)}
value={runtimeToReadableBinding(bindings, binding.default)} bind:value={binding.default}
/> />
{#if bindable} {#if bindable}
<DrawerBindableInput <DrawerBindableInput

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() await admin.init()
// Create user // Create user
await API.updateOwnMetadata({ roleId: $values.roleId })
await auth.setInitInfo({}) await auth.setInitInfo({})
// Create a default home screen if no template was selected // Create a default home screen if no template was selected

View File

@ -150,12 +150,31 @@ export function flipHeaderState(headersActivity) {
return enabled 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 { export default {
breakQueryString, breakQueryString,
buildQueryString, buildQueryString,
fieldsToSchema, fieldsToSchema,
flipHeaderState, flipHeaderState,
keyValueToQueryParameters, keyValueToQueryParameters,
parseToCsv,
queryParametersToKeyValue, queryParametersToKeyValue,
schemaToFields, schemaToFields,
} }

View File

@ -5,6 +5,8 @@ import "@spectrum-css/vars/dist/spectrum-darkest.css"
import "@spectrum-css/vars/dist/spectrum-dark.css" import "@spectrum-css/vars/dist/spectrum-dark.css"
import "@spectrum-css/vars/dist/spectrum-light.css" import "@spectrum-css/vars/dist/spectrum-light.css"
import "@spectrum-css/vars/dist/spectrum-lightest.css" import "@spectrum-css/vars/dist/spectrum-lightest.css"
import "@budibase/frontend-core/src/themes/nord.css"
import "@budibase/frontend-core/src/themes/midnight.css"
import "@spectrum-css/page/dist/index-vars.css" import "@spectrum-css/page/dist/index-vars.css"
import "./global.css" import "./global.css"
import { suppressWarnings } from "./helpers/warnings" import { suppressWarnings } from "./helpers/warnings"

View File

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

View File

@ -9,6 +9,7 @@
import { setContext } from "svelte" import { setContext } from "svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte" import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { DropPosition } from "./dndStore" import { DropPosition } from "./dndStore"
import { notifications } from "@budibase/bbui"
let scrollRef let scrollRef
@ -55,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 // Set scroll context so components can invoke scrolling when selected
setContext("scroll", { setContext("scroll", {
scrollTo, scrollTo,
@ -83,6 +93,7 @@
opened opened
scrollable scrollable
icon="WebPage" icon="WebPage"
on:drop={onDrop}
> >
<ScreenslotDropdownMenu component={$selectedScreen?.props} /> <ScreenslotDropdownMenu component={$selectedScreen?.props} />
</NavItem> </NavItem>

View File

@ -16,7 +16,7 @@
// Get root li element // Get root li element
const el = document.getElementById(`component-${component?._id}`) const el = document.getElementById(`component-${component?._id}`)
// Get inner nav item content element // Get inner nav item content element
const child = el?.childNodes[0]?.childNodes[0] const child = el?.children[0]?.children[0]
if (!el) { if (!el) {
return return
} }

View File

@ -27,6 +27,7 @@
</script> </script>
{#if $selectedComponent} {#if $selectedComponent}
{#key $selectedComponent._id}
<Panel {title} icon={componentDefinition.icon} borderLeft> <Panel {title} icon={componentDefinition.icon} borderLeft>
<ComponentSettingsSection <ComponentSettingsSection
{componentInstance} {componentInstance}
@ -36,11 +37,16 @@
{isScreen} {isScreen}
/> />
<DesignSection {componentInstance} {componentDefinition} {bindings} /> <DesignSection {componentInstance} {componentDefinition} {bindings} />
<CustomStylesSection {componentInstance} {componentDefinition} {bindings} /> <CustomStylesSection
{componentInstance}
{componentDefinition}
{bindings}
/>
<ConditionalUISection <ConditionalUISection
{componentInstance} {componentInstance}
{componentDefinition} {componentDefinition}
{bindings} {bindings}
/> />
</Panel> </Panel>
{/key}
{/if} {/if}

View File

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

View File

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

View File

@ -52,6 +52,11 @@
href: "/builder/portal/manage/users", href: "/builder/portal/manage/users",
heading: "Manage", heading: "Manage",
}, },
{
title: "User Groups",
href: "/builder/portal/manage/groups",
},
{ title: "Auth", href: "/builder/portal/manage/auth" }, { title: "Auth", href: "/builder/portal/manage/auth" },
{ title: "Email", href: "/builder/portal/manage/email" }, { title: "Email", href: "/builder/portal/manage/email" },
{ {

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

View File

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

View File

@ -0,0 +1,226 @@
<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"
export let groupId
let popoverAnchor
let popover
let searchTerm = ""
let selectedUsers = []
let prevSearch = undefined,
search = undefined
let pageInfo = createPaginationStore()
$: page = $pageInfo.page
$: fetchUsers(page, search)
$: group = $groups.find(x => x._id === groupId)
async function addAll() {
group.users = selectedUsers
await groups.actions.save(group)
}
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, search })
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")
}
}
onMount(async () => {
try {
await groups.actions.init()
await apps.load()
} catch (error) {
notifications.error("Error fetching User Group data")
}
})
</script>
<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
color={RoleUtils.getRoleColour(group.roles[app.appId])}
/>
<div style="margin-left: var(--spacing-s);">
<Body size="XS">{group.roles[app.appId]}</Body>
</div>
</div>
</ListItem>
{/each}
{:else}
<ListItem icon="UserGroup" title="No apps" />
{/if}
</List>
</Layout>
<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,145 @@
<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"
$: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS
)
let modal
let group = {
name: "",
icon: "UserGroup",
color: "var(--spectrum-global-color-blue-600)",
users: [],
apps: [],
roles: {},
}
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`)
}
}
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
? () => modal.show()
: 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: 70px;
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>

View File

@ -2,79 +2,102 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { import {
ActionButton, ActionButton,
ActionMenu,
Avatar,
Button, Button,
Layout, Layout,
Heading, Heading,
Body, Body,
Divider,
Label, Label,
List,
ListItem,
Icon,
Input, Input,
MenuItem,
Popover,
Select, Select,
Toggle,
Modal, Modal,
Table,
ModalContent,
notifications, notifications,
StatusLight,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte"
import { fetchData } from "helpers" import { fetchData } from "helpers"
import { users, auth } from "stores/portal" import { users, auth, groups, apps } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
import TagsRenderer from "./_components/RolesTagsTableRenderer.svelte"
import UpdateRolesModal from "./_components/UpdateRolesModal.svelte"
import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte" import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
import { RoleUtils } from "@budibase/frontend-core"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import DeleteUserModal from "./_components/DeleteUserModal.svelte"
export let userId export let userId
let deleteUserModal
let editRolesModal let deleteModal
let resetPasswordModal let resetPasswordModal
let popoverAnchor
let searchTerm = ""
let popover
let selectedGroups = []
let allAppList = []
let user
$: fetchUser(userId)
$: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS
)
const roleSchema = { $: allAppList = $apps
name: { displayName: "App" }, .filter(x => {
role: {}, if ($userFetch.data?.roles) {
} return Object.keys($userFetch.data.roles).find(y => {
return x.appId === apps.extractAppId(y)
const noRoleSchema = { })
name: { displayName: "App" },
}
$: defaultRoleId = $userFetch?.data?.builder?.global ? "ADMIN" : ""
// Merge the Apps list and the roles response to get something that makes sense for the table
$: allAppList = Object.keys($apps?.data).map(id => {
const roleId = $userFetch?.data?.roles?.[id] || defaultRoleId
const role = $apps?.data?.[id].roles.find(role => role._id === roleId)
return {
...$apps?.data?.[id],
_id: id,
role: [role],
} }
}) })
$: appList = allAppList.filter(app => !!app.role[0])
$: noRoleAppList = allAppList
.filter(app => !app.role[0])
.map(app => { .map(app => {
delete app.role let roles = Object.fromEntries(
return app Object.entries($userFetch.data.roles).filter(([key]) => {
return apps.extractAppId(key) === app.appId
})
)
return {
name: app.name,
devId: app.devId,
icon: app.icon,
roles,
}
})
// Used for searching through groups in the add group popover
$: filteredGroups = $groups.filter(
group =>
selectedGroups &&
group?.name?.toLowerCase().includes(searchTerm.toLowerCase())
)
$: userGroups = $groups.filter(x => {
return x.users?.find(y => {
return y._id === userId
})
}) })
let selectedApp $: globalRole = $userFetch?.data?.admin?.global
? "admin"
: $userFetch?.data?.builder?.global
? "developer"
: "appUser"
const userFetch = fetchData(`/api/global/users/${userId}`) const userFetch = fetchData(`/api/global/users/${userId}`)
const apps = fetchData(`/api/global/roles`)
async function deleteUser() { function getHighestRole(roles) {
try { let highestRole
await users.delete(userId) let highestRoleNumber = 0
notifications.success(`User ${$userFetch?.data?.email} deleted.`) Object.keys(roles).forEach(role => {
$goto("./") let roleNumber = RoleUtils.getRolePriority(roles[role])
} catch (error) { if (roleNumber > highestRoleNumber) {
notifications.error("Error deleting user") highestRoleNumber = roleNumber
highestRole = roles[role]
} }
})
return highestRole
} }
let toggleDisabled = false
async function updateUserFirstName(evt) { async function updateUserFirstName(evt) {
try { try {
await users.save({ ...$userFetch?.data, firstName: evt.target.value }) await users.save({ ...$userFetch?.data, firstName: evt.target.value })
@ -84,6 +107,13 @@
} }
} }
async function removeGroup(id) {
let updatedGroup = $groups.find(x => x._id === id)
let newUsers = updatedGroup.users.filter(user => user._id !== userId)
updatedGroup.users = newUsers
groups.actions.save(updatedGroup)
}
async function updateUserLastName(evt) { async function updateUserLastName(evt) {
try { try {
await users.save({ ...$userFetch?.data, lastName: evt.target.value }) await users.save({ ...$userFetch?.data, lastName: evt.target.value })
@ -93,61 +123,95 @@
} }
} }
async function toggleFlag(flagName, detail) { async function updateUserRole({ detail }) {
toggleDisabled = true if (detail === "developer") {
toggleFlags({ admin: { global: false }, builder: { global: true } })
} else if (detail === "admin") {
toggleFlags({ admin: { global: true }, builder: { global: false } })
} else if (detail === "appUser") {
toggleFlags({ admin: { global: false }, builder: { global: false } })
}
}
async function addGroup(groupId) {
let selectedGroup = selectedGroups.includes(groupId)
let group = $groups.find(group => group._id === groupId)
if (selectedGroup) {
selectedGroups = selectedGroups.filter(id => id === selectedGroup)
let newUsers = group.users.filter(groupUser => user._id !== groupUser._id)
group.users = newUsers
} else {
selectedGroups = [...selectedGroups, groupId]
group.users.push(user)
}
await groups.actions.save(group)
}
async function fetchUser(userId) {
let userPromise = users.get(userId)
user = await userPromise
}
async function toggleFlags(detail) {
try { try {
await users.save({ ...$userFetch?.data, [flagName]: { global: detail } }) await users.save({ ...$userFetch?.data, ...detail })
await userFetch.refresh() await userFetch.refresh()
} catch (error) { } catch (error) {
notifications.error("Error updating user") notifications.error("Error updating user")
} }
toggleDisabled = false
} }
async function toggleBuilderAccess({ detail }) { function addAll() {}
return toggleFlag("builder", detail) onMount(async () => {
} try {
await groups.actions.init()
async function toggleAdminAccess({ detail }) { await apps.load()
return toggleFlag("admin", detail) } catch (error) {
} notifications.error("Error getting User groups")
async function openUpdateRolesModal({ detail }) {
selectedApp = detail
editRolesModal.show()
} }
})
</script> </script>
<Layout noPadding> <Layout gap="L" noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<div> <div>
<ActionButton <ActionButton on:click={() => $goto("./")} size="S" icon="ArrowLeft">
on:click={() => $goto("./")} Back
quiet
size="S"
icon="BackAndroid"
>
Back to users
</ActionButton> </ActionButton>
</div> </div>
<Heading>User: {$userFetch?.data?.email}</Heading>
<Body>
Change user settings and update their app roles. Also contains the ability
to delete the user as well as force reset their password.
</Body>
</Layout> </Layout>
<Divider size="S" /> <Layout gap="XS" noPadding>
<div class="title">
<div>
<div style="display: flex;">
<Avatar size="XXL" initials="PC" />
<div class="subtitle">
<Heading size="S"
>{$userFetch?.data?.firstName +
" " +
$userFetch?.data?.lastName}</Heading
>
<Body size="XS">{$userFetch?.data?.email}</Body>
</div>
</div>
</div>
<div>
<ActionMenu align="right">
<span slot="control">
<Icon hoverable name="More" />
</span>
<MenuItem on:click={resetPasswordModal.show} icon="Refresh"
>Force Password Reset</MenuItem
>
<MenuItem on:click={deleteModal.show} icon="Delete">Delete</MenuItem>
</ActionMenu>
</div>
</div>
</Layout>
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
<Heading size="S">General</Heading>
<div class="fields"> <div class="fields">
<div class="field">
<Label size="L">Email</Label>
<Input disabled thin value={$userFetch?.data?.email} />
</div>
<div class="field">
<Label size="L">Group(s)</Label>
<Select disabled options={["All users"]} value="All users" />
</div>
<div class="field"> <div class="field">
<Label size="L">First name</Label> <Label size="L">First name</Label>
<Input <Input
@ -167,93 +231,104 @@
<!-- don't let a user remove the privileges that let them be here --> <!-- don't let a user remove the privileges that let them be here -->
{#if userId !== $auth.user._id} {#if userId !== $auth.user._id}
<div class="field"> <div class="field">
<Label size="L">Development access</Label> <Label size="L">Role</Label>
<Toggle <Select
text="" value={globalRole}
value={$userFetch?.data?.builder?.global} options={Constants.BbRoles}
on:change={toggleBuilderAccess} on:change={updateUserRole}
disabled={toggleDisabled}
/>
</div>
<div class="field">
<Label size="L">Administration access</Label>
<Toggle
text=""
value={$userFetch?.data?.admin?.global}
on:change={toggleAdminAccess}
disabled={toggleDisabled}
/> />
</div> </div>
{/if} {/if}
</div> </div>
<div class="regenerate">
<ActionButton
size="S"
icon="Refresh"
quiet
on:click={resetPasswordModal.show}>Force password reset</ActionButton
>
</div>
</Layout> </Layout>
<Divider size="S" />
<Layout gap="S" noPadding> {#if hasGroupsLicense}
<Heading size="S">Configure roles</Heading> <!-- User groups -->
<Body>Specify a role to grant access to an app.</Body>
<Table
on:click={openUpdateRolesModal}
schema={roleSchema}
data={appList}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "role", component: TagsRenderer }]}
/>
</Layout>
<Layout gap="S" noPadding>
<Heading size="XS">No Access</Heading>
<Body
>Apps do not appear in the users portal. Public pages may still be viewed
if visited directly.</Body
>
<Table
on:click={openUpdateRolesModal}
schema={noRoleSchema}
data={noRoleAppList}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
</Layout>
<Divider size="S" />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S">Delete user</Heading> <div class="tableTitle">
<Body>Deleting a user completely removes them from your account.</Body> <div>
</Layout> <Heading size="XS">User groups</Heading>
<div class="delete-button"> <Body size="S">Add or remove this user from user groups</Body>
<Button warning on:click={deleteUserModal.show}>Delete user</Button>
</div> </div>
<div bind:this={popoverAnchor}>
<Button on:click={popover.show()} icon="UserGroup" cta
>Add User Group</Button
>
</div>
<Popover align="right" bind:this={popover} anchor={popoverAnchor}>
<UserGroupPicker
key={"name"}
title={"Group"}
bind:searchTerm
bind:selected={selectedGroups}
bind:filtered={filteredGroups}
{addAll}
select={addGroup}
/>
</Popover>
</div>
<List>
{#if userGroups.length}
{#each userGroups as group}
<ListItem
title={group.name}
icon={group.icon}
iconBackground={group.color}
><Icon
on:click={removeGroup(group._id)}
hoverable
size="L"
name="Close"
/></ListItem
>
{/each}
{:else}
<ListItem icon="UserGroup" title="No groups" />
{/if}
</List>
</Layout>
{/if}
<!-- User Apps -->
<Layout gap="S" noPadding>
<div class="appsTitle">
<Heading weight="light" size="XS">Apps</Heading>
<div style="margin-top: var(--spacing-xs)">
<Body size="S">Manage apps that this user has been assigned to</Body>
</div>
</div>
<List>
{#if allAppList.length}
{#each allAppList as app}
<div class="pointer" on:click={$goto(`../../overview/${app.devId}`)}>
<ListItem
title={app.name}
iconBackground={app?.icon?.color || ""}
icon={app?.icon?.name || "Apps"}
>
<div class="title ">
<StatusLight
color={RoleUtils.getRoleColour(getHighestRole(app.roles))}
/>
<div style="margin-left: var(--spacing-s);">
<Body size="XS"
>{Constants.Roles[getHighestRole(app.roles)]}</Body
>
</div>
</div>
</ListItem>
</div>
{/each}
{:else}
<ListItem icon="Apps" title="No apps" />
{/if}
</List>
</Layout>
</Layout> </Layout>
<Modal bind:this={deleteUserModal}> <Modal bind:this={deleteModal}>
<ModalContent <DeleteUserModal user={$userFetch.data} />
warning
onConfirm={deleteUser}
title="Delete User"
confirmText="Delete user"
cancelText="Cancel"
showCloseIcon={false}
>
<Body>
Are you sure you want to delete <strong>{$userFetch?.data?.email}</strong>
</Body>
</ModalContent>
</Modal>
<Modal bind:this={editRolesModal}>
<UpdateRolesModal
app={selectedApp}
user={$userFetch.data}
on:update={userFetch.refresh}
/>
</Modal> </Modal>
<Modal bind:this={resetPasswordModal}> <Modal bind:this={resetPasswordModal}>
<ForceResetPasswordModal <ForceResetPasswordModal
@ -263,6 +338,9 @@
</Modal> </Modal>
<style> <style>
.pointer {
cursor: pointer;
}
.fields { .fields {
display: grid; display: grid;
grid-gap: var(--spacing-m); grid-gap: var(--spacing-m);
@ -272,9 +350,26 @@
grid-template-columns: 32% 1fr; grid-template-columns: 32% 1fr;
align-items: center; align-items: center;
} }
.regenerate {
position: absolute; .title {
top: 0; display: flex;
right: 0; align-items: center;
justify-content: space-between;
}
.tableTitle {
display: flex;
justify-content: space-between;
margin-bottom: var(--spacing-m);
}
.subtitle {
padding: 0 0 0 var(--spacing-m);
display: inline-block;
}
.appsTitle {
display: flex;
flex-direction: column;
} }
</style> </style>

View File

@ -1,113 +1,86 @@
<script> <script>
import { import {
Body,
Input,
Label, Label,
ActionButton,
ModalContent, ModalContent,
notifications, Multiselect,
Select, InputDropdown,
Toggle, Layout,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createValidationStore, emailValidator } from "helpers/validation" import { groups, auth } from "stores/portal"
import { users } from "stores/portal" import { Constants } from "@budibase/frontend-core"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher() export let showOnboardingTypeModal
const password = Math.random().toString(36).substring(2, 22) const password = Math.random().toString(36).substring(2, 22)
const options = ["Email onboarding", "Basic onboarding"]
const [email, error, touched] = createValidationStore("", emailValidator)
let disabled let disabled
let builder let userGroups = []
let admin
let selected = "Email onboarding"
$: basic = selected === "Basic onboarding" $: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS
)
function addUser() { $: userData = [
if (basic) { {
createUser() email: "",
} else { role: "appUser",
createUserFlow()
}
}
async function createUser() {
try {
await users.create({
email: $email,
password, password,
builder,
admin,
forceResetPassword: true, forceResetPassword: true,
}) },
notifications.success("Successfully created user") ]
dispatch("created") function addNewInput() {
} catch (error) { userData = [
notifications.error("Error creating user") ...userData,
} {
} email: "",
role: "appUser",
async function createUserFlow() { password: Math.random().toString(36).substring(2, 22),
try { forceResetPassword: true,
const res = await users.invite({ email: $email, builder, admin }) },
notifications.success(res.message) ]
} catch (error) {
notifications.error("Error inviting user")
}
} }
</script> </script>
<ModalContent <ModalContent
onConfirm={addUser} onConfirm={async () =>
showOnboardingTypeModal({ users: userData, groups: userGroups })}
size="M" size="M"
title="Add new user" title="Add new user"
confirmText="Add user" confirmText="Add user"
confirmDisabled={disabled} confirmDisabled={disabled}
cancelText="Cancel" cancelText="Cancel"
disabled={$error}
showCloseIcon={false} showCloseIcon={false}
> >
<Body size="S"> <Layout noPadding gap="XS">
If you have SMTP configured and an email for the new user, you can use the <Label>Email Address</Label>
automated email onboarding flow. Otherwise, use our basic onboarding process
with autogenerated passwords. {#each userData as input, index}
</Body> <InputDropdown
<Select inputType="email"
placeholder={null} bind:inputValue={input.email}
bind:value={selected} bind:dropdownValue={input.role}
{options} options={Constants.BbRoles}
label="Add new user via:" error={input.error}
/> />
{/each}
<Input
type="email"
label="Email"
bind:value={$email}
error={$touched && $error}
placeholder="john@doe.com"
/>
{#if basic}
<Input disabled label="Password" value={password} />
{/if}
<div> <div>
<div class="toggle"> <ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton>
<Label size="L">Development access</Label>
<Toggle text="" bind:value={builder} />
</div>
<div class="toggle">
<Label size="L">Administration access</Label>
<Toggle text="" bind:value={admin} />
</div>
</div> </div>
</Layout>
{#if hasGroupsLicense}
<Multiselect
bind:value={userGroups}
placeholder="Select User Groups"
label="User Groups"
options={$groups}
getOptionLabel={option => option.name}
getOptionValue={option => option._id}
/>
{/if}
</ModalContent> </ModalContent>
<style> <style>
.toggle { :global(.spectrum-Picker) {
display: grid; border-top-left-radius: 0px;
grid-template-columns: 78% 1fr;
align-items: center;
width: 50%;
} }
</style> </style>

View File

@ -0,0 +1,22 @@
<script>
import { Icon } from "@budibase/bbui"
export let value
</script>
<div class="align">
<div class="spacing">
<Icon name="WebPage" />
</div>
{parseInt(value?.length) || 0}
</div>
<style>
.align {
display: flex;
overflow: hidden;
}
.spacing {
margin-right: var(--spacing-m);
}
</style>

View File

@ -0,0 +1,31 @@
<script>
import { goto } from "@roxi/routify"
import { Body, ModalContent, notifications } from "@budibase/bbui"
import { users } from "stores/portal"
export let user
async function deleteUser() {
try {
await users.delete(user._id)
notifications.success(`User ${user?.email} deleted.`)
$goto("./")
} catch (error) {
notifications.error("Error deleting user")
}
}
</script>
<ModalContent
warning
onConfirm={deleteUser}
title="Delete User"
confirmText="Delete user"
cancelText="Cancel"
showCloseIcon={false}
>
<Body>
Are you sure you want to delete <strong>{user?.email}</strong>
</Body>
</ModalContent>

View File

@ -0,0 +1,36 @@
<script>
import { Icon, Body } from "@budibase/bbui"
export let value
</script>
<div class="align">
<div class="spacing">
<Icon name="UserGroup" />
</div>
{#if value?.length === 0}
<div class="opacity">0</div>
{:else if value?.length === 1}
<div class="opacity">
<Body size="S">{value[0]?.name}</Body>
</div>
{:else}
<div class="opacity">
{parseInt(value?.length) || 0} groups
</div>
{/if}
</div>
<style>
.align {
display: flex;
overflow: hidden;
}
.opacity {
opacity: 0.8;
}
.spacing {
margin-right: var(--spacing-m);
}
</style>

View File

@ -0,0 +1,157 @@
<script>
import {
Body,
ModalContent,
RadioGroup,
Multiselect,
notifications,
} from "@budibase/bbui"
import { groups, auth, admin } from "stores/portal"
import { emailValidator } from "../../../../../../helpers/validation"
import { Constants } from "@budibase/frontend-core"
const BYTES_IN_MB = 1000000
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
const MAX_USERS_UPLOAD_LIMIT = 1000
export let createUsersFromCsv
let files = []
let csvString = undefined
let userEmails = []
let userGroups = []
let usersRole = null
$: invalidEmails = []
$: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS
)
const validEmails = userEmails => {
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
notifications.error(
`Max limit for upload is 1000 users. Please reduce file size and try again.`
)
return false
}
for (const email of userEmails) {
if (emailValidator(email) !== true) invalidEmails.push(email)
}
if (!invalidEmails.length) return true
notifications.error(
`Error, please check the following email${
invalidEmails.length > 1 ? "s" : ""
}: ${invalidEmails.join(", ")}`
)
return false
}
async function handleFile(evt) {
const fileArray = Array.from(evt.target.files)
if (fileArray.some(file => file.size >= FILE_SIZE_LIMIT)) {
notifications.error(
`Files cannot exceed ${
FILE_SIZE_LIMIT / BYTES_IN_MB
}MB. Please try again with smaller files.`
)
return
}
// Read CSV as plain text to upload alongside schema
let reader = new FileReader()
reader.addEventListener("load", function (e) {
csvString = e.target.result
files = fileArray
userEmails = csvString.split("\n")
})
reader.readAsText(fileArray[0])
}
</script>
<ModalContent
size="M"
title="Import users"
confirmText="Done"
showCancelButton={false}
cancelText="Cancel"
showCloseIcon={false}
onConfirm={() => createUsersFromCsv({ userEmails, usersRole, userGroups })}
disabled={!userEmails.length || !validEmails(userEmails) || !usersRole}
>
<Body size="S">Import your users email addrresses from a CSV</Body>
<div class="dropzone">
<input id="file-upload" accept=".csv" type="file" on:change={handleFile} />
<label for="file-upload" class:uploaded={files[0]}>
{#if files[0]}{files[0].name}{:else}Upload{/if}
</label>
</div>
<RadioGroup
bind:value={usersRole}
options={Constants.BuilderRoleDescriptions}
/>
{#if hasGroupsLicense}
<Multiselect
bind:value={userGroups}
placeholder="Select User Groups"
label="User Groups"
options={$groups}
getOptionLabel={option => option.name}
getOptionValue={option => option._id}
/>
{/if}
</ModalContent>
<style>
:global(.spectrum-Picker) {
border-top-left-radius: 0px;
}
.dropzone {
text-align: center;
display: flex;
align-items: center;
flex-direction: column;
border-radius: 10px;
transition: all 0.3s;
}
.uploaded {
color: var(--blue);
}
label {
font-family: var(--font-sans);
cursor: pointer;
font-weight: 600;
box-sizing: border-box;
overflow: hidden;
border-radius: var(--border-radius-s);
color: var(--ink);
padding: var(--spacing-m) var(--spacing-l);
transition: all 0.2s ease 0s;
display: inline-flex;
text-rendering: optimizeLegibility;
min-width: auto;
outline: none;
font-feature-settings: "case" 1, "rlig" 1, "calt" 0;
-webkit-box-align: center;
user-select: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 100%;
background-color: var(--grey-2);
font-size: var(--font-size-xs);
line-height: normal;
border: var(--border-transparent);
}
input[type="file"] {
display: none;
}
</style>

View File

@ -0,0 +1,38 @@
<script>
import { Avatar } from "@budibase/bbui"
export let value
</script>
<div class="align">
{#if value}
<div class="spacing">
<Avatar
size="L"
initials={value
.split(" ")
.map(x => x[0])
.join("")}
/>
</div>
{value}
{:else}
<div class="text">Not Available</div>
{/if}
</div>
<style>
.align {
display: flex;
align-items: center;
overflow: hidden;
}
.spacing {
margin-right: var(--spacing-m);
}
.text {
opacity: 0.8;
}
</style>

View File

@ -0,0 +1,108 @@
<script>
import { ModalContent, Body, Layout, Icon } from "@budibase/bbui"
export let chooseCreationType
let emailOnboardingKey = "emailOnboarding"
let basicOnboaridngKey = "basicOnboarding"
let selectedOnboardingType
</script>
<ModalContent
size="M"
title="Choose your onboarding"
confirmText="Done"
cancelText="Cancel"
showCloseIcon={false}
onConfirm={() => chooseCreationType(selectedOnboardingType)}
disabled={!selectedOnboardingType}
>
<Layout noPadding gap="S">
<div
class="onboarding-type item"
class:selected={selectedOnboardingType == emailOnboardingKey}
on:click={() => {
selectedOnboardingType = emailOnboardingKey
}}
>
<div class="content onboarding-type-wrap">
<Icon name="WebPage" />
<div class="onboarding-type-text">
<Body size="S">Send email invites</Body>
</div>
</div>
<div style="color: var(--spectrum-global-color-green-600); float: right">
{#if selectedOnboardingType == emailOnboardingKey}
<div class="checkmark-spacing">
<Icon size="S" name="CheckmarkCircle" />
</div>
{/if}
</div>
</div>
<div
class="onboarding-type item"
class:selected={selectedOnboardingType == basicOnboaridngKey}
on:click={() => {
selectedOnboardingType = basicOnboaridngKey
}}
>
<div class="content onboarding-type-wrap">
<Icon name="Key" />
<div class="onboarding-type-text">
<Body size="S">Generate passwords for each user</Body>
</div>
</div>
<div style="color: var(--spectrum-global-color-green-600); float: right">
{#if selectedOnboardingType == basicOnboaridngKey}
<div class="checkmark-spacing">
<Icon size="S" name="CheckmarkCircle" />
</div>
{/if}
</div>
</div>
</Layout>
</ModalContent>
<style>
.onboarding-type.item {
padding: var(--spectrum-alias-item-padding-xl);
}
.onboarding-type-wrap {
display: flex;
flex-direction: row;
align-items: center;
}
.checkmark-spacing {
margin-right: var(--spacing-m);
}
.content {
letter-spacing: 0px;
}
.item {
cursor: pointer;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s);
background: var(--spectrum-alias-background-color-primary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
border-width: 1px;
display: flex;
justify-content: space-between;
align-items: center;
}
.item:hover,
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.onboarding-type-wrap .onboarding-type-text {
padding-left: var(--spectrum-alias-item-padding-xl);
}
.onboarding-type-wrap :global(.spectrum-Icon) {
min-width: var(--spectrum-icon-size-m);
}
.onboarding-type-wrap :global(.spectrum-Heading) {
padding-bottom: var(--spectrum-alias-item-padding-s);
}
</style>

View File

@ -0,0 +1,15 @@
<script>
import { InternalRenderer } from "@budibase/bbui"
export let value
</script>
<div style="display: flex; ">
{value}
<div style="margin-left: 1.5rem;">
<InternalRenderer {value} />
</div>
</div>
<style>
</style>

View File

@ -0,0 +1,94 @@
<script>
import { Body, ModalContent, Table, Icon } from "@budibase/bbui"
import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte"
import { parseToCsv } from "helpers/data/utils"
export let userData
$: mappedData = userData.map(user => {
return {
email: user.email,
password: user.password,
}
})
const schema = {
email: {},
password: {},
}
const downloadCsvFile = () => {
const fileName = "passwords.csv"
const content = parseToCsv(["email", "password"], mappedData)
download(fileName, content)
}
const download = (filename, text) => {
const element = document.createElement("a")
element.setAttribute(
"href",
"data:text/csv;charset=utf-8," + encodeURIComponent(text)
)
element.setAttribute("download", filename)
element.style.display = "none"
document.body.appendChild(element)
element.click()
document.body.removeChild(element)
}
</script>
<ModalContent
size="S"
title="Accounts created!"
confirmText="Done"
showCancelButton={false}
cancelText="Cancel"
showCloseIcon={false}
>
<Body size="XS"
>All your new users can be accessed through the autogenerated passwords.
Make not of these passwords or download the csv</Body
>
<div class="container" on:click={downloadCsvFile}>
<div class="inner">
<Icon name="Download" />
<div style="margin-left: var(--spacing-m)">
<Body size="XS">Passwords CSV</Body>
</div>
</div>
</div>
<Table
{schema}
data={mappedData}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "password", component: PasswordCopyRenderer }]}
/>
</ModalContent>
<style>
.inner {
display: flex;
}
:global(.spectrum-Picker) {
border-top-left-radius: 0px;
}
.container {
width: 100%;
height: var(--spectrum-alias-item-height-l);
background: #009562;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@ -0,0 +1,16 @@
<script>
import { users } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
export let row
$: value =
Constants.BbRoles.find(x => x.value === users.getUserRole(row))?.label ||
"Not Available"
</script>
<div on:click|stopPropagation>
{value}
</div>
<style>
</style>

View File

@ -1,52 +1,232 @@
<script> <script>
import { goto } from "@roxi/routify"
import { import {
Heading, Heading,
Body, Body,
Divider,
Button, Button,
ButtonGroup, ButtonGroup,
Search,
Table, Table,
Label,
Layout, Layout,
Modal, Modal,
ModalContent,
Icon,
notifications, notifications,
Pagination, Pagination,
Search,
Label,
} from "@budibase/bbui" } from "@budibase/bbui"
import TagsRenderer from "./_components/TagsTableRenderer.svelte"
import AddUserModal from "./_components/AddUserModal.svelte" import AddUserModal from "./_components/AddUserModal.svelte"
import { users } from "stores/portal" import { users, groups, auth } from "stores/portal"
import { onMount } from "svelte"
import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
import AppsTableRenderer from "./_components/AppsTableRenderer.svelte"
import NameTableRenderer from "./_components/NameTableRenderer.svelte"
import RoleTableRenderer from "./_components/RoleTableRenderer.svelte"
import { goto } from "@roxi/routify"
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
import PasswordModal from "./_components/PasswordModal.svelte"
import ImportUsersModal from "./_components/ImportUsersModal.svelte"
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { Constants } from "@budibase/frontend-core"
const schema = { const accessTypes = [
email: {}, {
developmentAccess: { displayName: "Development Access", type: "boolean" }, icon: "User",
adminAccess: { displayName: "Admin Access", type: "boolean" }, description: "App user - Only has access to published apps",
group: {}, },
} {
icon: "Hammer",
description: "Developer - Access to the app builder",
},
{
icon: "Draw",
description: "Admin - Full access",
},
]
//let email
let enrichedUsers = []
let createUserModal,
inviteConfirmationModal,
onboardingTypeModal,
passwordModal,
importUsersModal
let pageInfo = createPaginationStore() let pageInfo = createPaginationStore()
let prevSearch = undefined, let prevEmail = undefined,
search = undefined searchEmail = undefined
let selectedRows = []
let customRenderers = [
{ column: "userGroups", component: GroupsTableRenderer },
{ column: "apps", component: AppsTableRenderer },
{ column: "name", component: NameTableRenderer },
{ column: "role", component: RoleTableRenderer },
]
$: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS
)
$: schema = {
name: {},
email: {},
role: {
noPropagation: true,
sortable: false,
},
...(hasGroupsLicense && {
userGroups: { sortable: false, displayName: "User groups" },
}),
apps: { width: "120px" },
settings: {
sortable: false,
width: "60px",
displayName: "",
align: "Right",
},
}
$: userData = []
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchUsers(page, search) $: fetchUsers(page, searchEmail)
$: {
enrichedUsers = $users.data?.map(user => {
let userGroups = []
$groups.forEach(group => {
if (group.users) {
group.users?.forEach(y => {
if (y._id === user._id) {
userGroups.push(group)
}
})
}
})
return {
...user,
name: user.firstName ? user.firstName + " " + user.lastName : "",
userGroups,
apps: [...new Set(Object.keys(user.roles))],
}
})
}
const showOnboardingTypeModal = async addUsersData => {
userData = await removingDuplicities(addUsersData)
if (!userData?.users?.length) return
let createUserModal onboardingTypeModal.show()
}
async function fetchUsers(page, search) { async function createUserFlow() {
let emails = userData?.users?.map(x => x.email) || []
try {
const res = await users.invite({
emails: emails,
builder: false,
admin: false,
})
notifications.success(res.message)
inviteConfirmationModal.show()
} catch (error) {
notifications.error("Error inviting user")
}
}
const removingDuplicities = async userData => {
const currentUserEmails = (await users.fetch())?.map(x => x.email) || []
const newUsers = []
for (const user of userData?.users) {
const { email } = user
if (
newUsers.find(x => x.email === email) ||
currentUserEmails.includes(email)
)
continue
newUsers.push(user)
}
if (!newUsers.length)
notifications.info("Duplicated! There is no new users to add.")
return { ...userData, users: newUsers }
}
const createUsersFromCsv = async userCsvData => {
const { userEmails, usersRole, userGroups: groups } = userCsvData
const users = []
for (const email of userEmails) {
const newUser = {
email: email,
role: usersRole,
password: Math.random().toString(36).substring(2, 22),
forceResetPassword: true,
}
users.push(newUser)
}
userData = await removingDuplicities({ groups, users })
if (!userData.users.length) return
return createUser()
}
async function createUser() {
try {
await users.create(await removingDuplicities(userData))
notifications.success("Successfully created user")
await groups.actions.init()
passwordModal.show()
} catch (error) {
notifications.error("Error creating user")
}
}
async function chooseCreationType(onboardingType) {
if (onboardingType === "emailOnboarding") {
createUserFlow()
} else {
await createUser()
}
}
onMount(async () => {
try {
await groups.actions.init()
} catch (error) {
notifications.error("Error fetching User Group data")
}
})
const deleteRows = async () => {
try {
let ids = selectedRows.map(user => user._id)
await users.bulkDelete(ids)
notifications.success(`Successfully deleted ${selectedRows.length} rows`)
selectedRows = []
await fetchUsers(page, searchEmail)
} catch (error) {
notifications.error("Error deleting rows")
}
}
async function fetchUsers(page, email) {
if ($pageInfo.loading) { if ($pageInfo.loading) {
return return
} }
// need to remove the page if they've started searching // need to remove the page if they've started searching
if (search && !prevSearch) { if (email && !prevEmail) {
pageInfo.reset() pageInfo.reset()
page = undefined page = undefined
} }
prevSearch = search prevEmail = email
try { try {
pageInfo.loading() pageInfo.loading()
await users.search({ page, search }) await users.search({ page, email })
pageInfo.fetched($users.hasNextPage, $users.nextPage) pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) { } catch (error) {
notifications.error("Error getting user list") notifications.error("Error getting user list")
@ -57,34 +237,49 @@
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading>Users</Heading> <Heading>Users</Heading>
<Body> <Body>Add users and control who gets access to your published apps</Body>
Each user is assigned to a group that contains apps and permissions. In
this section, you can add users, or edit and delete an existing user. <div>
</Body> {#each accessTypes as type}
<div class="access-description">
<Icon name={type.icon} />
<div class="access-text">
<Body size="S">{type.description}</Body>
</div>
</div>
{/each}
</div>
</Layout> </Layout>
<Divider size="S" />
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
<div class="users-heading">
<Heading size="S">Users</Heading>
<ButtonGroup> <ButtonGroup>
<Button disabled secondary>Import users</Button> <Button
<Button primary dataCy="add-user" on:click={createUserModal.show} dataCy="add-user"
>Add user</Button on:click={createUserModal.show}
icon="UserAdd"
cta>Add Users</Button
> >
</ButtonGroup> <Button on:click={importUsersModal.show} icon="Import" primary
</div> >Import Users</Button
>
<div class="field"> <div class="field">
<Label size="L">Search / filter</Label> <Label size="L">Search email</Label>
<Search bind:value={search} placeholder="" /> <Search bind:value={searchEmail} placeholder="" />
</div> </div>
{#if selectedRows.length > 0}
<DeleteRowsButton on:updaterows {selectedRows} {deleteRows} />
{/if}
</ButtonGroup>
<Table <Table
on:click={({ detail }) => $goto(`./${detail._id}`)} on:click={({ detail }) => $goto(`./${detail._id}`)}
{schema} {schema}
data={$users.data} bind:selectedRows
data={enrichedUsers}
allowEditColumns={false} allowEditColumns={false}
allowEditRows={false} allowEditRows={false}
allowSelectRows={false} allowSelectRows={true}
customRenderers={[{ column: "group", component: TagsRenderer }]} showHeaderBorder={false}
{customRenderers}
/> />
<div class="pagination"> <div class="pagination">
<Pagination <Pagination
@ -99,12 +294,32 @@
</Layout> </Layout>
<Modal bind:this={createUserModal}> <Modal bind:this={createUserModal}>
<AddUserModal <AddUserModal {showOnboardingTypeModal} />
on:created={async () => { </Modal>
pageInfo.reset()
await fetchUsers() <Modal bind:this={inviteConfirmationModal}>
}} <ModalContent
/> showCancelButton={false}
title="Invites sent!"
confirmText="Done"
>
<Body size="S"
>Your users should now recieve an email invite to get access to their
Budibase account</Body
></ModalContent
>
</Modal>
<Modal bind:this={onboardingTypeModal}>
<OnboardingTypeModal {chooseCreationType} />
</Modal>
<Modal bind:this={passwordModal}>
<PasswordModal userData={userData.users} />
</Modal>
<Modal bind:this={importUsersModal}>
<ImportUsersModal {createUsersFromCsv} />
</Modal> </Modal>
<style> <style>
@ -113,14 +328,20 @@
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
grid-gap: var(--spacing-m); grid-gap: var(--spacing-m);
margin-left: auto;
} }
.field > :global(*) + :global(*) { .field > :global(*) + :global(*) {
margin-left: var(--spacing-m); margin-left: var(--spacing-m);
} }
.users-heading {
.access-description {
display: flex; display: flex;
flex-direction: row; margin-top: var(--spacing-xl);
justify-content: space-between; opacity: 0.8;
align-items: center; }
.access-text {
margin-left: var(--spacing-m);
} }
</style> </style>

View File

@ -19,6 +19,7 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import OverviewTab from "../_components/OverviewTab.svelte" import OverviewTab from "../_components/OverviewTab.svelte"
import SettingsTab from "../_components/SettingsTab.svelte" import SettingsTab from "../_components/SettingsTab.svelte"
import AccessTab from "../_components/AccessTab.svelte"
import { API } from "api" import { API } from "api"
import { store } from "builderStore" import { store } from "builderStore"
import { apps, auth } from "stores/portal" import { apps, auth } from "stores/portal"
@ -139,9 +140,10 @@
notifications.success("App ID copied to clipboard.") notifications.success("App ID copied to clipboard.")
} }
const exportApp = app => { const exportApp = (app, opts = { published: false }) => {
const id = isPublished ? app.prodId : app.devId
const appName = encodeURIComponent(app.name) const appName = encodeURIComponent(app.name)
const id = opts?.published ? app.prodId : app.devId
// always export the development version
window.location = `/api/backups/export?appId=${id}&appname=${appName}` window.location = `/api/backups/export?appId=${id}&appname=${appName}`
} }
@ -266,12 +268,21 @@
<span slot="control" class="app-overview-actions-icon"> <span slot="control" class="app-overview-actions-icon">
<Icon hoverable name="More" /> <Icon hoverable name="More" />
</span> </span>
<MenuItem on:click={() => exportApp(selectedApp)} icon="Download"> <MenuItem
Export on:click={() => exportApp(selectedApp, { published: false })}
icon="DownloadFromCloud"
>
Export latest
</MenuItem> </MenuItem>
{#if isPublished} {#if isPublished}
<MenuItem
on:click={() => exportApp(selectedApp, { published: true })}
icon="DownloadFromCloudOutline"
>
Export published
</MenuItem>
<MenuItem on:click={() => copyAppId(selectedApp)} icon="Copy"> <MenuItem on:click={() => copyAppId(selectedApp)} icon="Copy">
Copy App ID Copy app ID
</MenuItem> </MenuItem>
{/if} {/if}
{#if !isPublished} {#if !isPublished}
@ -299,6 +310,9 @@
on:unpublish={e => unpublishApp(e.detail)} on:unpublish={e => unpublishApp(e.detail)}
/> />
</Tab> </Tab>
<Tab title="Access">
<AccessTab app={selectedApp} />
</Tab>
{#if isPublished} {#if isPublished}
<Tab title="Automation History"> <Tab title="Automation History">
<HistoryTab app={selectedApp} /> <HistoryTab app={selectedApp} />

View File

@ -0,0 +1,267 @@
<script>
import {
Layout,
Heading,
Body,
Button,
List,
ListItem,
Modal,
notifications,
Pagination,
Icon,
} from "@budibase/bbui"
import { onMount } from "svelte"
import RoleSelect from "components/common/RoleSelect.svelte"
import { users, groups, apps, auth } from "stores/portal"
import AssignmentModal from "./AssignmentModal.svelte"
import { createPaginationStore } from "helpers/pagination"
import { Constants } from "@budibase/frontend-core"
import { roles } from "stores/backend"
export let app
let assignmentModal
let appGroups = []
let appUsers = []
let prevSearch = undefined,
search = undefined
let pageInfo = createPaginationStore()
let fixedAppId
$: page = $pageInfo.page
$: fetchUsers(page, search)
$: hasGroupsLicense = $auth.user?.license.features.includes(
Constants.Features.USER_GROUPS
)
$: fixedAppId = apps.getProdAppID(app.devId)
$: appUsers =
$users.data?.filter(x => {
return Object.keys(x.roles).find(y => {
return y === fixedAppId
})
}) || []
$: appGroups = $groups.filter(x => {
return x.apps.includes(app.appId)
})
async function addData(appData) {
let gr_prefix = "gr"
let us_prefix = "us"
appData.forEach(async data => {
if (data.id.startsWith(gr_prefix)) {
let matchedGroup = $groups.find(group => {
return group._id === data.id
})
matchedGroup.apps.push(app.appId)
matchedGroup.roles[fixedAppId] = data.role
groups.actions.save(matchedGroup)
} else if (data.id.startsWith(us_prefix)) {
let matchedUser = $users.data.find(user => {
return user._id === data.id
})
let newUser = {
...matchedUser,
roles: { [fixedAppId]: data.role, ...matchedUser.roles },
}
await users.save(newUser, { opts: { appId: fixedAppId } })
await fetchUsers(page, search)
}
})
await groups.actions.init()
}
async function removeUser(user) {
// Remove the user role
const filteredRoles = { ...user.roles }
delete filteredRoles[fixedAppId]
await users.save({
...user,
roles: {
...filteredRoles,
},
})
await fetchUsers(page, search)
}
async function removeGroup(group) {
// Remove the user role
let filteredApps = group.apps.filter(
x => apps.extractAppId(x) !== app.appId
)
const filteredRoles = { ...group.roles }
delete filteredRoles[fixedAppId]
await groups.actions.save({
...group,
apps: filteredApps,
roles: { ...filteredRoles },
})
await fetchUsers(page, search)
}
async function updateUserRole(role, user) {
user.roles[fixedAppId] = role
users.save(user)
}
async function updateGroupRole(role, group) {
group.roles[fixedAppId] = role
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, appId: fixedAppId })
pageInfo.fetched($users.hasNextPage, $users.nextPage)
} catch (error) {
notifications.error("Error getting user list")
}
}
onMount(async () => {
try {
await groups.actions.init()
await apps.load()
await roles.fetch()
} catch (error) {
notifications.error(error)
}
})
</script>
<div class="access-tab">
<Layout>
{#if appGroups.length || appUsers.length}
<div>
<Heading>Access</Heading>
<div class="subtitle">
<Body size="S">
Assign users to your app and define their access here</Body
>
<Button on:click={assignmentModal.show} icon="User" cta
>Assign users</Button
>
</div>
</div>
{#if hasGroupsLicense && appGroups.length}
<List title="User Groups">
{#each appGroups as group}
<ListItem
title={group.name}
icon={group.icon}
iconBackground={group.color}
>
<RoleSelect
on:change={e => updateGroupRole(e.detail, group)}
autoWidth
quiet
value={group.roles[
Object.keys(group.roles).find(x => x === fixedAppId)
]}
/>
<Icon
on:click={() => removeGroup(group)}
hoverable
size="S"
name="Close"
/>
</ListItem>
{/each}
</List>
{/if}
{#if appUsers.length}
<List title="Users">
{#each appUsers as user}
<ListItem title={user.email} avatar>
<RoleSelect
on:change={e => updateUserRole(e.detail, user)}
autoWidth
quiet
value={user.roles[
Object.keys(user.roles).find(x => x === fixedAppId)
]}
/>
<Icon
on:click={() => removeUser(user)}
hoverable
size="S"
name="Close"
/>
</ListItem>
{/each}
</List>
<div class="pagination">
<Pagination
page={$pageInfo.pageNumber}
hasPrevPage={$pageInfo.loading ? false : $pageInfo.hasPrevPage}
hasNextPage={$pageInfo.loading ? false : $pageInfo.hasNextPage}
goToPrevPage={pageInfo.prevPage}
goToNextPage={pageInfo.nextPage}
/>
</div>
{/if}
{:else}
<div class="align">
<Layout gap="S">
<Heading>No users assigned</Heading>
<div class="opacity">
<Body size="S"
>Assign users to your app and set their access here</Body
>
</div>
<div class="padding">
<Button on:click={() => assignmentModal.show()} cta icon="UserArrow"
>Assign Users</Button
>
</div>
</Layout>
</div>
{/if}
</Layout>
</div>
<Modal bind:this={assignmentModal}>
<AssignmentModal {app} {appUsers} {addData} />
</Modal>
<style>
.access-tab {
max-width: 600px;
margin: 0 auto;
padding: 40px;
}
.padding {
margin-top: var(--spacing-m);
}
.opacity {
opacity: 0.8;
}
.align {
text-align: center;
}
.subtitle {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
</style>

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