Merge branch 'develop' into bug/sev3/remove-validate-current-step-checkbox
This commit is contained in:
commit
6c1c51a22a
|
@ -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]
|
||||||
|
|
|
@ -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 .
|
||||||
|
|
|
@ -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
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "1.1.18-alpha.0",
|
"version": "1.1.29-alpha.0",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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 {}}{}}")
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 })
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
|
@ -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={() => {
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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" },
|
||||||
{
|
{
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<div style="float: right;">
|
||||||
|
<slot />
|
||||||
|
</div>
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue