mirror of https://github.com/Budibase/budibase.git
212 changed files with 8933 additions and 1729 deletions
@ -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) |
|||
} |
|||
@ -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() |
|||
} |
|||
@ -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") |
|||
} |
|||
@ -0,0 +1,228 @@ |
|||
<script> |
|||
import "@spectrum-css/inputgroup/dist/index-vars.css" |
|||
import "@spectrum-css/popover/dist/index-vars.css" |
|||
import "@spectrum-css/menu/dist/index-vars.css" |
|||
import { fly } from "svelte/transition" |
|||
import { createEventDispatcher } from "svelte" |
|||
import clickOutside from "../../Actions/click_outside" |
|||
|
|||
export let inputValue |
|||
export let dropdownValue |
|||
export let id = null |
|||
export let inputType = "text" |
|||
export let placeholder = "Choose an option or type" |
|||
export let disabled = false |
|||
export let readonly = false |
|||
export let updateOnChange = true |
|||
export let error = null |
|||
export let options = [] |
|||
export let getOptionLabel = option => extractProperty(option, "label") |
|||
export let getOptionValue = option => extractProperty(option, "value") |
|||
|
|||
export let isOptionSelected = () => false |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
let open = false |
|||
let focus = false |
|||
|
|||
$: fieldText = getFieldText(dropdownValue, options, placeholder) |
|||
|
|||
const getFieldText = (dropdownValue, options, placeholder) => { |
|||
// Always use placeholder if no value |
|||
if (dropdownValue == null || dropdownValue === "") { |
|||
return placeholder || "Choose an option or type" |
|||
} |
|||
|
|||
// Wait for options to load if there is a value but no options |
|||
if (!options?.length) { |
|||
return "" |
|||
} |
|||
|
|||
// Render the label if the selected option is found, otherwise raw value |
|||
const selected = options.find( |
|||
option => getOptionValue(option) === dropdownValue |
|||
) |
|||
return selected ? getOptionLabel(selected) : dropdownValue |
|||
} |
|||
|
|||
const updateValue = newValue => { |
|||
if (readonly) { |
|||
return |
|||
} |
|||
dispatch("change", newValue) |
|||
} |
|||
|
|||
const onFocus = () => { |
|||
if (readonly) { |
|||
return |
|||
} |
|||
focus = true |
|||
} |
|||
|
|||
const onBlur = event => { |
|||
if (readonly) { |
|||
return |
|||
} |
|||
focus = false |
|||
updateValue(event.target.value) |
|||
} |
|||
|
|||
const onInput = event => { |
|||
if (readonly || !updateOnChange) { |
|||
return |
|||
} |
|||
updateValue(event.target.value) |
|||
} |
|||
|
|||
const updateValueOnEnter = event => { |
|||
if (readonly) { |
|||
return |
|||
} |
|||
if (event.key === "Enter") { |
|||
updateValue(event.target.value) |
|||
} |
|||
} |
|||
|
|||
const onClick = () => { |
|||
dispatch("click") |
|||
if (readonly) { |
|||
return |
|||
} |
|||
open = true |
|||
} |
|||
|
|||
const onPick = newValue => { |
|||
dispatch("pick", newValue) |
|||
open = false |
|||
} |
|||
|
|||
const extractProperty = (value, property) => { |
|||
if (value && typeof value === "object") { |
|||
return value[property] |
|||
} |
|||
return value |
|||
} |
|||
</script> |
|||
|
|||
<div |
|||
class="spectrum-InputGroup" |
|||
class:is-invalid={!!error} |
|||
class:is-disabled={disabled} |
|||
> |
|||
<div |
|||
class="spectrum-Textfield spectrum-InputGroup-textfield" |
|||
class:is-invalid={!!error} |
|||
class:is-disabled={disabled} |
|||
class:is-focused={focus} |
|||
> |
|||
{#if error} |
|||
<svg |
|||
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon" |
|||
focusable="false" |
|||
aria-hidden="true" |
|||
> |
|||
<use xlink:href="#spectrum-icon-18-Alert" /> |
|||
</svg> |
|||
{/if} |
|||
|
|||
<input |
|||
{id} |
|||
on:click |
|||
on:blur |
|||
on:focus |
|||
on:input |
|||
on:keyup |
|||
on:blur={onBlur} |
|||
on:focus={onFocus} |
|||
on:input={onInput} |
|||
on:keyup={updateValueOnEnter} |
|||
value={inputValue || ""} |
|||
placeholder={placeholder || ""} |
|||
{disabled} |
|||
{readonly} |
|||
{inputType} |
|||
class="spectrum-Textfield-input spectrum-InputGroup-input" |
|||
/> |
|||
</div> |
|||
<div style="width: 30%"> |
|||
<button |
|||
{id} |
|||
class="spectrum-Picker spectrum-Picker--sizeM override-borders" |
|||
{disabled} |
|||
class:is-open={open} |
|||
aria-haspopup="listbox" |
|||
on:mousedown={onClick} |
|||
> |
|||
<span class="spectrum-Picker-label"> |
|||
<div> |
|||
{fieldText} |
|||
</div></span |
|||
> |
|||
<svg |
|||
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon" |
|||
focusable="false" |
|||
aria-hidden="true" |
|||
> |
|||
<use xlink:href="#spectrum-css-icon-Chevron100" /> |
|||
</svg> |
|||
</button> |
|||
{#if open} |
|||
<div |
|||
use:clickOutside={() => (open = false)} |
|||
transition:fly|local={{ y: -20, duration: 200 }} |
|||
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
|||
> |
|||
<ul class="spectrum-Menu" role="listbox"> |
|||
{#each options as option, idx} |
|||
<li |
|||
class="spectrum-Menu-item" |
|||
class:is-selected={isOptionSelected(getOptionValue(option, idx))} |
|||
role="option" |
|||
aria-selected="true" |
|||
tabindex="0" |
|||
on:click={() => onPick(getOptionValue(option, idx))} |
|||
> |
|||
<span class="spectrum-Menu-itemLabel"> |
|||
{getOptionLabel(option, idx)} |
|||
</span> |
|||
<svg |
|||
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
|||
focusable="false" |
|||
aria-hidden="true" |
|||
> |
|||
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
|||
</svg> |
|||
</li> |
|||
{/each} |
|||
</ul> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.spectrum-InputGroup { |
|||
min-width: 0; |
|||
width: 100%; |
|||
} |
|||
|
|||
.spectrum-InputGroup-input { |
|||
border-right-width: 1px; |
|||
} |
|||
.spectrum-Textfield { |
|||
width: 100%; |
|||
} |
|||
.spectrum-Textfield-input { |
|||
width: 0; |
|||
} |
|||
|
|||
.override-borders { |
|||
border-top-left-radius: 0px; |
|||
border-bottom-left-radius: 0px; |
|||
} |
|||
.spectrum-Popover { |
|||
max-height: 240px; |
|||
z-index: 999; |
|||
top: 100%; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,436 @@ |
|||
<script> |
|||
import "@spectrum-css/inputgroup/dist/index-vars.css" |
|||
import "@spectrum-css/popover/dist/index-vars.css" |
|||
import "@spectrum-css/menu/dist/index-vars.css" |
|||
import { fly } from "svelte/transition" |
|||
import { createEventDispatcher } from "svelte" |
|||
import clickOutside from "../../Actions/click_outside" |
|||
import Icon from "../../Icon/Icon.svelte" |
|||
import StatusLight from "../../StatusLight/StatusLight.svelte" |
|||
import Detail from "../../Typography/Detail.svelte" |
|||
import Search from "./Search.svelte" |
|||
|
|||
export let primaryLabel = "" |
|||
export let primaryValue = null |
|||
export let id = null |
|||
export let placeholder = "Choose an option or type" |
|||
export let disabled = false |
|||
export let readonly = false |
|||
export let updateOnChange = true |
|||
export let error = null |
|||
export let secondaryOptions = [] |
|||
export let primaryOptions = [] |
|||
export let secondaryFieldText = "" |
|||
export let secondaryFieldIcon = "" |
|||
export let secondaryFieldColour = "" |
|||
export let getPrimaryOptionValue = option => option |
|||
export let getPrimaryOptionColour = () => null |
|||
export let getPrimaryOptionIcon = () => null |
|||
export let getSecondaryOptionLabel = option => option |
|||
export let getSecondaryOptionValue = option => option |
|||
export let getSecondaryOptionColour = () => null |
|||
export let onSelectOption = () => {} |
|||
export let autoWidth = false |
|||
export let autocomplete = false |
|||
export let isOptionSelected = () => false |
|||
export let isPlaceholder = false |
|||
export let placeholderOption = null |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
let primaryOpen = false |
|||
let secondaryOpen = false |
|||
let focus = false |
|||
let searchTerm = null |
|||
|
|||
$: groupTitles = Object.keys(primaryOptions) |
|||
let iconData |
|||
|
|||
const updateSearch = e => { |
|||
dispatch("search", e.detail) |
|||
} |
|||
|
|||
const updateValue = newValue => { |
|||
if (readonly) { |
|||
return |
|||
} |
|||
dispatch("change", newValue) |
|||
} |
|||
|
|||
const onClickSecondary = () => { |
|||
dispatch("click") |
|||
if (readonly) { |
|||
return |
|||
} |
|||
secondaryOpen = true |
|||
} |
|||
|
|||
const onPickPrimary = newValue => { |
|||
dispatch("pickprimary", newValue) |
|||
primaryOpen = false |
|||
} |
|||
|
|||
const onClearPrimary = () => { |
|||
dispatch("pickprimary", null) |
|||
primaryOpen = false |
|||
} |
|||
|
|||
const onPickSecondary = newValue => { |
|||
dispatch("picksecondary", newValue) |
|||
secondaryOpen = false |
|||
} |
|||
|
|||
const onBlur = event => { |
|||
if (readonly) { |
|||
return |
|||
} |
|||
focus = false |
|||
updateValue(event.target.value) |
|||
} |
|||
|
|||
const onInput = event => { |
|||
if (readonly || !updateOnChange) { |
|||
return |
|||
} |
|||
updateValue(event.target.value) |
|||
} |
|||
|
|||
const updateValueOnEnter = event => { |
|||
if (readonly) { |
|||
return |
|||
} |
|||
if (event.key === "Enter") { |
|||
updateValue(event.target.value) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<div |
|||
class="spectrum-InputGroup" |
|||
class:is-invalid={!!error} |
|||
class:is-disabled={disabled} |
|||
> |
|||
<div |
|||
class="spectrum-Textfield spectrum-InputGroup-textfield" |
|||
class:is-invalid={!!error} |
|||
class:is-disabled={disabled} |
|||
class:is-focused={focus} |
|||
class:is-full-width={!secondaryOptions.length} |
|||
> |
|||
{#if iconData} |
|||
<svg |
|||
width="16px" |
|||
height="16px" |
|||
class="spectrum-Icon iconPadding" |
|||
style="color: {iconData?.color}" |
|||
focusable="false" |
|||
> |
|||
<use xlink:href="#spectrum-icon-18-{iconData?.icon}" /> |
|||
</svg> |
|||
{/if} |
|||
<input |
|||
{id} |
|||
on:click={() => (primaryOpen = true)} |
|||
on:blur |
|||
on:focus |
|||
on:input |
|||
on:keyup |
|||
on:blur={onBlur} |
|||
on:input={onInput} |
|||
on:keyup={updateValueOnEnter} |
|||
value={primaryLabel || ""} |
|||
placeholder={placeholder || ""} |
|||
{disabled} |
|||
{readonly} |
|||
class="spectrum-Textfield-input spectrum-InputGroup-input" |
|||
class:labelPadding={iconData} |
|||
/> |
|||
{#if primaryValue} |
|||
<button |
|||
on:click={() => onClearPrimary()} |
|||
type="reset" |
|||
class="spectrum-ClearButton spectrum-Search-clearButton" |
|||
> |
|||
<svg |
|||
class="spectrum-Icon spectrum-UIIcon-Cross75" |
|||
focusable="false" |
|||
aria-hidden="true" |
|||
> |
|||
<use xlink:href="#spectrum-css-icon-Cross75" /> |
|||
</svg> |
|||
</button> |
|||
{/if} |
|||
</div> |
|||
{#if primaryOpen} |
|||
<div |
|||
use:clickOutside={() => (primaryOpen = false)} |
|||
transition:fly|local={{ y: -20, duration: 200 }} |
|||
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
|||
class:auto-width={autoWidth} |
|||
class:is-full-width={!secondaryOptions.length} |
|||
> |
|||
{#if autocomplete} |
|||
<Search |
|||
value={searchTerm} |
|||
on:change={event => updateSearch(event)} |
|||
{disabled} |
|||
placeholder="Search" |
|||
/> |
|||
{/if} |
|||
|
|||
<ul class="spectrum-Menu" role="listbox"> |
|||
{#if placeholderOption} |
|||
<li |
|||
class="spectrum-Menu-item placeholder" |
|||
class:is-selected={isPlaceholder} |
|||
role="option" |
|||
aria-selected="true" |
|||
tabindex="0" |
|||
on:click={() => onSelectOption(null)} |
|||
> |
|||
<span class="spectrum-Menu-itemLabel">{placeholderOption}</span> |
|||
<svg |
|||
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
|||
focusable="false" |
|||
aria-hidden="true" |
|||
> |
|||
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
|||
</svg> |
|||
</li> |
|||
{/if} |
|||
{#each groupTitles as title} |
|||
<div class="spectrum-Menu-item"> |
|||
<Detail>{title}</Detail> |
|||
</div> |
|||
{#if primaryOptions} |
|||
{#each primaryOptions[title].data as option, idx} |
|||
<li |
|||
class="spectrum-Menu-item" |
|||
class:is-selected={isOptionSelected( |
|||
getPrimaryOptionValue(option, idx) |
|||
)} |
|||
role="option" |
|||
aria-selected="true" |
|||
tabindex="0" |
|||
on:click={() => |
|||
onPickPrimary({ |
|||
value: primaryOptions[title].getValue(option), |
|||
label: primaryOptions[title].getLabel(option), |
|||
})} |
|||
> |
|||
{#if primaryOptions[title].getIcon(option)} |
|||
<div |
|||
style="background: {primaryOptions[title].getColour( |
|||
option |
|||
)};" |
|||
class="circle" |
|||
> |
|||
<div> |
|||
<Icon |
|||
size="S" |
|||
name={primaryOptions[title].getIcon(option)} |
|||
/> |
|||
</div> |
|||
</div> |
|||
{:else if getPrimaryOptionColour(option, idx)} |
|||
<span class="option-left"> |
|||
<StatusLight |
|||
square |
|||
color={getPrimaryOptionColour(option, idx)} |
|||
/> |
|||
</span> |
|||
{/if} |
|||
<span class="spectrum-Menu-itemLabel"> |
|||
<span |
|||
class:spacing-group={primaryOptions[title].getIcon(option)} |
|||
> |
|||
{primaryOptions[title].getLabel(option)} |
|||
<span /> |
|||
</span> |
|||
<svg |
|||
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
|||
focusable="false" |
|||
aria-hidden="true" |
|||
> |
|||
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
|||
</svg> |
|||
{#if getPrimaryOptionIcon(option, idx) && getPrimaryOptionColour(option, idx)} |
|||
<span class="option-right"> |
|||
<StatusLight |
|||
square |
|||
color={getPrimaryOptionColour(option, idx)} |
|||
/> |
|||
</span> |
|||
{/if} |
|||
</span> |
|||
</li> |
|||
{/each} |
|||
{/if} |
|||
{/each} |
|||
</ul> |
|||
</div> |
|||
{/if} |
|||
{#if secondaryOptions.length} |
|||
<div style="width: 30%"> |
|||
<button |
|||
{id} |
|||
class="spectrum-Picker spectrum-Picker--sizeM override-borders" |
|||
{disabled} |
|||
class:is-open={secondaryOpen} |
|||
aria-haspopup="listbox" |
|||
on:mousedown={onClickSecondary} |
|||
> |
|||
{#if secondaryFieldIcon} |
|||
<span class="option-left"> |
|||
<Icon name={secondaryFieldIcon} /> |
|||
</span> |
|||
{:else if secondaryFieldColour} |
|||
<span class="option-left"> |
|||
<StatusLight square color={secondaryFieldColour} /> |
|||
</span> |
|||
{/if} |
|||
|
|||
<span class:auto-width={autoWidth} class="spectrum-Picker-label"> |
|||
{secondaryFieldText} |
|||
</span> |
|||
<svg |
|||
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon" |
|||
focusable="false" |
|||
aria-hidden="true" |
|||
> |
|||
<use xlink:href="#spectrum-css-icon-Chevron100" /> |
|||
</svg> |
|||
</button> |
|||
{#if secondaryOpen} |
|||
<div |
|||
use:clickOutside={() => (secondaryOpen = false)} |
|||
transition:fly|local={{ y: -20, duration: 200 }} |
|||
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
|||
style="width: 30%" |
|||
> |
|||
<ul class="spectrum-Menu" role="listbox"> |
|||
{#each secondaryOptions as option, idx} |
|||
<li |
|||
class="spectrum-Menu-item" |
|||
class:is-selected={isOptionSelected( |
|||
getSecondaryOptionValue(option, idx) |
|||
)} |
|||
role="option" |
|||
aria-selected="true" |
|||
tabindex="0" |
|||
on:click={() => |
|||
onPickSecondary(getSecondaryOptionValue(option, idx))} |
|||
> |
|||
{#if getSecondaryOptionColour(option, idx)} |
|||
<span class="option-left"> |
|||
<StatusLight |
|||
square |
|||
color={getSecondaryOptionColour(option, idx)} |
|||
/> |
|||
</span> |
|||
{/if} |
|||
|
|||
<span class="spectrum-Menu-itemLabel"> |
|||
{getSecondaryOptionLabel(option, idx)} |
|||
</span> |
|||
<svg |
|||
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
|||
focusable="false" |
|||
aria-hidden="true" |
|||
> |
|||
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
|||
</svg> |
|||
</li> |
|||
{/each} |
|||
</ul> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
|
|||
<style> |
|||
.spacing-group { |
|||
margin-left: var(--spacing-m); |
|||
} |
|||
.spectrum-InputGroup { |
|||
min-width: 0; |
|||
width: 100%; |
|||
} |
|||
.spectrum-InputGroup :global(.spectrum-Search-input) { |
|||
border: none; |
|||
border-bottom: 1px solid var(--spectrum-global-color-gray-300); |
|||
border-bottom-left-radius: 0; |
|||
border-bottom-right-radius: 0; |
|||
} |
|||
|
|||
.override-borders { |
|||
border-top-left-radius: 0px; |
|||
border-bottom-left-radius: 0px; |
|||
} |
|||
|
|||
.spectrum-Popover { |
|||
max-height: 240px; |
|||
z-index: 999; |
|||
top: 100%; |
|||
} |
|||
|
|||
.option-left { |
|||
padding-right: 8px; |
|||
} |
|||
.option-right { |
|||
padding-left: 8px; |
|||
} |
|||
|
|||
.circle { |
|||
border-radius: 50%; |
|||
height: 28px; |
|||
color: white; |
|||
font-weight: bold; |
|||
line-height: 48px; |
|||
font-size: 1.2em; |
|||
width: 28px; |
|||
position: relative; |
|||
} |
|||
|
|||
.circle > div { |
|||
position: absolute; |
|||
text-decoration: none; |
|||
top: 50%; |
|||
left: 50%; |
|||
transform: translateX(-50%) translateY(-50%); |
|||
} |
|||
|
|||
.iconPadding { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 10px; |
|||
transform: translateY(-50%); |
|||
color: silver; |
|||
margin-right: 10px; |
|||
} |
|||
|
|||
.labelPadding { |
|||
padding-left: calc(1em + 10px + 8px); |
|||
} |
|||
|
|||
.spectrum-Textfield.spectrum-InputGroup-textfield { |
|||
width: 70%; |
|||
} |
|||
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width { |
|||
width: 100%; |
|||
} |
|||
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width input { |
|||
border-right-width: thin; |
|||
} |
|||
|
|||
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open { |
|||
width: 70%; |
|||
} |
|||
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open.is-full-width { |
|||
width: 100%; |
|||
} |
|||
|
|||
.spectrum-Search-clearButton { |
|||
position: absolute; |
|||
} |
|||
</style> |
|||
@ -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> |
|||
@ -0,0 +1,132 @@ |
|||
<script> |
|||
import Field from "./Field.svelte" |
|||
import PickerDropdown from "./Core/PickerDropdown.svelte" |
|||
import { createEventDispatcher } from "svelte" |
|||
|
|||
export let primaryValue = null |
|||
export let secondaryValue = null |
|||
export let inputType = "text" |
|||
export let label = null |
|||
export let labelPosition = "above" |
|||
export let secondaryPlaceholder = null |
|||
export let autocomplete |
|||
export let placeholder = null |
|||
export let disabled = false |
|||
export let readonly = false |
|||
export let error = null |
|||
export let updateOnChange = true |
|||
export let getSecondaryOptionLabel = option => |
|||
extractProperty(option, "label") |
|||
export let getSecondaryOptionValue = option => |
|||
extractProperty(option, "value") |
|||
export let getSecondaryOptionColour = () => {} |
|||
export let getSecondaryOptionIcon = () => {} |
|||
export let quiet = false |
|||
export let dataCy |
|||
export let autofocus |
|||
export let primaryOptions = [] |
|||
export let secondaryOptions = [] |
|||
export let searchTerm |
|||
|
|||
let primaryLabel |
|||
let secondaryLabel |
|||
const dispatch = createEventDispatcher() |
|||
|
|||
$: secondaryFieldText = getSecondaryFieldText( |
|||
secondaryValue, |
|||
secondaryOptions, |
|||
secondaryPlaceholder |
|||
) |
|||
$: secondaryFieldIcon = getSecondaryFieldAttribute( |
|||
getSecondaryOptionIcon, |
|||
secondaryValue, |
|||
secondaryOptions |
|||
) |
|||
$: secondaryFieldColour = getSecondaryFieldAttribute( |
|||
getSecondaryOptionColour, |
|||
secondaryValue, |
|||
secondaryOptions |
|||
) |
|||
|
|||
const getSecondaryFieldAttribute = (getAttribute, value, options) => { |
|||
// Wait for options to load if there is a value but no options |
|||
|
|||
if (!options?.length) { |
|||
return "" |
|||
} |
|||
|
|||
const index = options.findIndex( |
|||
(option, idx) => getSecondaryOptionValue(option, idx) === value |
|||
) |
|||
|
|||
return index !== -1 ? getAttribute(options[index], index) : null |
|||
} |
|||
|
|||
const getSecondaryFieldText = (value, options, placeholder) => { |
|||
// Always use placeholder if no value |
|||
if (value == null || value === "") { |
|||
return placeholder || "Choose an option" |
|||
} |
|||
|
|||
return getSecondaryFieldAttribute(getSecondaryOptionLabel, value, options) |
|||
} |
|||
|
|||
const onPickPrimary = e => { |
|||
primaryLabel = e?.detail?.label || null |
|||
primaryValue = e?.detail?.value || null |
|||
dispatch("pickprimary", e?.detail?.value || {}) |
|||
} |
|||
|
|||
const onPickSecondary = e => { |
|||
secondaryValue = e.detail |
|||
dispatch("picksecondary", e.detail) |
|||
} |
|||
|
|||
const extractProperty = (value, property) => { |
|||
if (value && typeof value === "object") { |
|||
return value[property] |
|||
} |
|||
return value |
|||
} |
|||
|
|||
const updateSearchTerm = e => { |
|||
searchTerm = e.detail |
|||
} |
|||
</script> |
|||
|
|||
<Field {label} {labelPosition} {error}> |
|||
<PickerDropdown |
|||
{searchTerm} |
|||
{autocomplete} |
|||
{dataCy} |
|||
{updateOnChange} |
|||
{error} |
|||
{disabled} |
|||
{readonly} |
|||
{placeholder} |
|||
{inputType} |
|||
{quiet} |
|||
{autofocus} |
|||
{primaryOptions} |
|||
{secondaryOptions} |
|||
{getSecondaryOptionLabel} |
|||
{getSecondaryOptionValue} |
|||
{getSecondaryOptionIcon} |
|||
{getSecondaryOptionColour} |
|||
{secondaryFieldText} |
|||
{secondaryFieldIcon} |
|||
{secondaryFieldColour} |
|||
{primaryValue} |
|||
{secondaryValue} |
|||
{primaryLabel} |
|||
{secondaryLabel} |
|||
on:pickprimary={onPickPrimary} |
|||
on:picksecondary={onPickSecondary} |
|||
on:search={updateSearchTerm} |
|||
on:click |
|||
on:input |
|||
on:blur |
|||
on:focus |
|||
on:keyup |
|||
/> |
|||
</Field> |
|||
@ -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> |
|||
@ -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} |
|||
/> |
|||
@ -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> |
|||
@ -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} |
|||
/> |
|||
@ -0,0 +1,260 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
import { |
|||
ActionButton, |
|||
Button, |
|||
Layout, |
|||
Heading, |
|||
Body, |
|||
Icon, |
|||
Popover, |
|||
notifications, |
|||
List, |
|||
ListItem, |
|||
StatusLight, |
|||
} from "@budibase/bbui" |
|||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" |
|||
import { createPaginationStore } from "helpers/pagination" |
|||
import { users, apps, groups } from "stores/portal" |
|||
import { onMount } from "svelte" |
|||
import { RoleUtils } from "@budibase/frontend-core" |
|||
import { roles } from "stores/backend" |
|||
|
|||
export let groupId |
|||
|
|||
let popoverAnchor |
|||
let popover |
|||
let searchTerm = "" |
|||
let selectedUsers = [] |
|||
let prevSearch = undefined |
|||
let pageInfo = createPaginationStore() |
|||
let loaded = false |
|||
|
|||
$: page = $pageInfo.page |
|||
$: fetchUsers(page, searchTerm) |
|||
$: group = $groups.find(x => x._id === groupId) |
|||
|
|||
async function addAll() { |
|||
selectedUsers = [...selectedUsers, ...filtered.map(u => u._id)] |
|||
|
|||
let reducedUserObjects = filtered.map(u => { |
|||
return { |
|||
_id: u._id, |
|||
email: u.email, |
|||
} |
|||
}) |
|||
group.users = [...reducedUserObjects, ...group.users] |
|||
|
|||
await groups.actions.save(group) |
|||
|
|||
$users.data.forEach(async user => { |
|||
let userToEdit = await users.get(user._id) |
|||
let userGroups = userToEdit.userGroups || [] |
|||
userGroups.push(groupId) |
|||
await users.save({ |
|||
...userToEdit, |
|||
userGroups, |
|||
}) |
|||
}) |
|||
} |
|||
|
|||
async function selectUser(id) { |
|||
let selectedUser = selectedUsers.includes(id) |
|||
if (selectedUser) { |
|||
selectedUsers = selectedUsers.filter(id => id !== selectedUser) |
|||
let newUsers = group.users.filter(user => user._id !== id) |
|||
group.users = newUsers |
|||
} else { |
|||
let enrichedUser = $users.data |
|||
.filter(user => user._id === id) |
|||
.map(u => { |
|||
return { |
|||
_id: u._id, |
|||
email: u.email, |
|||
} |
|||
})[0] |
|||
selectedUsers = [...selectedUsers, id] |
|||
group.users.push(enrichedUser) |
|||
} |
|||
|
|||
await groups.actions.save(group) |
|||
|
|||
let user = await users.get(id) |
|||
|
|||
let userGroups = user.userGroups || [] |
|||
userGroups.push(groupId) |
|||
await users.save({ |
|||
...user, |
|||
userGroups, |
|||
}) |
|||
} |
|||
$: filtered = |
|||
$users.data?.filter(x => !group?.users.map(y => y._id).includes(x._id)) || |
|||
[] |
|||
|
|||
$: groupApps = $apps.filter(x => group.apps.includes(x.appId)) |
|||
async function removeUser(id) { |
|||
let newUsers = group.users.filter(user => user._id !== id) |
|||
group.users = newUsers |
|||
let user = await users.get(id) |
|||
|
|||
await users.save({ |
|||
...user, |
|||
userGroups: [], |
|||
}) |
|||
|
|||
await groups.actions.save(group) |
|||
} |
|||
|
|||
async function fetchUsers(page, search) { |
|||
if ($pageInfo.loading) { |
|||
return |
|||
} |
|||
// need to remove the page if they've started searching |
|||
if (search && !prevSearch) { |
|||
pageInfo.reset() |
|||
page = undefined |
|||
} |
|||
prevSearch = search |
|||
try { |
|||
pageInfo.loading() |
|||
await users.search({ page, email: search }) |
|||
pageInfo.fetched($users.hasNextPage, $users.nextPage) |
|||
} catch (error) { |
|||
notifications.error("Error getting user list") |
|||
} |
|||
} |
|||
|
|||
const getRoleLabel = appId => { |
|||
const roleId = group?.roles?.[`app_${appId}`] |
|||
const role = $roles.find(x => x._id === roleId) |
|||
return role?.name || "Custom role" |
|||
} |
|||
|
|||
onMount(async () => { |
|||
try { |
|||
await Promise.all([groups.actions.init(), apps.load(), roles.fetch()]) |
|||
loaded = true |
|||
} catch (error) { |
|||
notifications.error("Error fetching user group data") |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
{#if loaded} |
|||
<Layout noPadding> |
|||
<div> |
|||
<ActionButton |
|||
on:click={() => $goto("../groups")} |
|||
size="S" |
|||
icon="ArrowLeft" |
|||
> |
|||
Back |
|||
</ActionButton> |
|||
</div> |
|||
<div class="header"> |
|||
<div class="title"> |
|||
<div style="background: {group?.color};" class="circle"> |
|||
<div> |
|||
<Icon size="M" name={group?.icon} /> |
|||
</div> |
|||
</div> |
|||
<div class="text-padding"> |
|||
<Heading>{group?.name}</Heading> |
|||
</div> |
|||
</div> |
|||
<div bind:this={popoverAnchor}> |
|||
<Button on:click={popover.show()} icon="UserAdd" cta>Add user</Button> |
|||
</div> |
|||
<Popover align="right" bind:this={popover} anchor={popoverAnchor}> |
|||
<UserGroupPicker |
|||
key={"email"} |
|||
title={"User"} |
|||
bind:searchTerm |
|||
bind:selected={selectedUsers} |
|||
bind:filtered |
|||
{addAll} |
|||
select={selectUser} |
|||
/> |
|||
</Popover> |
|||
</div> |
|||
|
|||
<List> |
|||
{#if group?.users.length} |
|||
{#each group.users as user} |
|||
<ListItem title={user?.email} avatar |
|||
><Icon |
|||
on:click={() => removeUser(user?._id)} |
|||
hoverable |
|||
size="L" |
|||
name="Close" |
|||
/></ListItem |
|||
> |
|||
{/each} |
|||
{:else} |
|||
<ListItem icon="UserGroup" title="You have no users in this team" /> |
|||
{/if} |
|||
</List> |
|||
<div |
|||
style="flex-direction: column; margin-top: var(--spacing-m)" |
|||
class="title" |
|||
> |
|||
<Heading weight="light" size="XS">Apps</Heading> |
|||
<div style="margin-top: var(--spacing-xs)"> |
|||
<Body size="S" |
|||
>Manage apps that this User group has been assigned to</Body |
|||
> |
|||
</div> |
|||
</div> |
|||
|
|||
<List> |
|||
{#if groupApps.length} |
|||
{#each groupApps as app} |
|||
<ListItem |
|||
title={app.name} |
|||
icon={app?.icon?.name || "Apps"} |
|||
iconBackground={app?.icon?.color || ""} |
|||
> |
|||
<div class="title "> |
|||
<StatusLight |
|||
square |
|||
color={RoleUtils.getRoleColour(group.roles[`app_${app.appId}`])} |
|||
> |
|||
{getRoleLabel(app.appId)} |
|||
</StatusLight> |
|||
</div> |
|||
</ListItem> |
|||
{/each} |
|||
{:else} |
|||
<ListItem icon="UserGroup" title="No apps" /> |
|||
{/if} |
|||
</List> |
|||
</Layout> |
|||
{/if} |
|||
|
|||
<style> |
|||
.text-padding { |
|||
margin-left: var(--spacing-l); |
|||
} |
|||
|
|||
.header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
} |
|||
.title { |
|||
display: flex; |
|||
} |
|||
.circle { |
|||
border-radius: 50%; |
|||
height: 30px; |
|||
color: white; |
|||
font-weight: bold; |
|||
display: inline-block; |
|||
font-size: 1.2em; |
|||
width: 30px; |
|||
} |
|||
|
|||
.circle > div { |
|||
padding: calc(1.5 * var(--spacing-xs)) var(--spacing-xs); |
|||
} |
|||
</style> |
|||
@ -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,153 @@ |
|||
<script> |
|||
import { |
|||
Layout, |
|||
Heading, |
|||
Body, |
|||
Button, |
|||
Modal, |
|||
Tag, |
|||
Tags, |
|||
notifications, |
|||
} from "@budibase/bbui" |
|||
import { groups, auth } from "stores/portal" |
|||
import { onMount } from "svelte" |
|||
import { Constants } from "@budibase/frontend-core" |
|||
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte" |
|||
import UserGroupsRow from "./_components/UserGroupsRow.svelte" |
|||
import { cloneDeep } from "lodash/fp" |
|||
|
|||
const DefaultGroup = { |
|||
name: "", |
|||
icon: "UserGroup", |
|||
color: "var(--spectrum-global-color-blue-600)", |
|||
users: [], |
|||
apps: [], |
|||
roles: {}, |
|||
} |
|||
let modal |
|||
let group = cloneDeep(DefaultGroup) |
|||
|
|||
$: hasGroupsLicense = $auth.user?.license.features.includes( |
|||
Constants.Features.USER_GROUPS |
|||
) |
|||
|
|||
async function deleteGroup(group) { |
|||
try { |
|||
groups.actions.delete(group) |
|||
} catch (error) { |
|||
notifications.error(`Failed to delete group`) |
|||
} |
|||
} |
|||
|
|||
async function saveGroup(group) { |
|||
try { |
|||
await groups.actions.save(group) |
|||
} catch (error) { |
|||
notifications.error(`Failed to save group`) |
|||
} |
|||
} |
|||
|
|||
const showCreateGroupModal = () => { |
|||
group = cloneDeep(DefaultGroup) |
|||
modal?.show() |
|||
} |
|||
|
|||
onMount(async () => { |
|||
try { |
|||
if (hasGroupsLicense) { |
|||
await groups.actions.init() |
|||
} |
|||
} catch (error) { |
|||
notifications.error("Error getting User groups") |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<Layout noPadding> |
|||
<Layout gap="XS" noPadding> |
|||
<div style="display: flex;"> |
|||
<Heading size="M">User groups</Heading> |
|||
{#if !hasGroupsLicense} |
|||
<Tags> |
|||
<div class="tags"> |
|||
<div class="tag"> |
|||
<Tag icon="LockClosed">Pro plan</Tag> |
|||
</div> |
|||
</div> |
|||
</Tags> |
|||
{/if} |
|||
</div> |
|||
<Body>Easily assign and manage your users access with User Groups</Body> |
|||
</Layout> |
|||
<div class="align-buttons"> |
|||
<Button |
|||
newStyles |
|||
icon={hasGroupsLicense ? "UserGroup" : ""} |
|||
cta={hasGroupsLicense} |
|||
on:click={hasGroupsLicense |
|||
? showCreateGroupModal |
|||
: window.open("https://budibase.com/pricing/", "_blank")} |
|||
> |
|||
{hasGroupsLicense ? "Create user group" : "Upgrade Account"} |
|||
</Button> |
|||
{#if !hasGroupsLicense} |
|||
<Button |
|||
newStyles |
|||
secondary |
|||
on:click={() => { |
|||
window.open("https://budibase.com/pricing/", "_blank") |
|||
}}>View Plans</Button |
|||
> |
|||
{/if} |
|||
</div> |
|||
|
|||
{#if hasGroupsLicense && $groups.length} |
|||
<div class="groupTable"> |
|||
{#each $groups as group} |
|||
<div> |
|||
<UserGroupsRow {saveGroup} {deleteGroup} {group} /> |
|||
</div> |
|||
{/each} |
|||
</div> |
|||
{/if} |
|||
</Layout> |
|||
|
|||
<Modal bind:this={modal}> |
|||
<CreateEditGroupModal bind:group {saveGroup} /> |
|||
</Modal> |
|||
|
|||
<style> |
|||
.align-buttons { |
|||
display: flex; |
|||
column-gap: var(--spacing-xl); |
|||
} |
|||
.tag { |
|||
margin-top: var(--spacing-xs); |
|||
margin-left: var(--spacing-m); |
|||
} |
|||
|
|||
.groupTable { |
|||
display: grid; |
|||
grid-template-rows: auto; |
|||
align-items: center; |
|||
border-bottom: 1px solid var(--spectrum-alias-border-color-mid); |
|||
border-left: 1px solid var(--spectrum-alias-border-color-mid); |
|||
background: var(--spectrum-global-color-gray-50); |
|||
} |
|||
|
|||
.groupTable :global(> div) { |
|||
background: var(--bg-color); |
|||
|
|||
height: 55px; |
|||
display: grid; |
|||
align-items: center; |
|||
grid-gap: var(--spacing-xl); |
|||
grid-template-columns: 2fr 2fr 2fr auto; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
padding: 0 var(--spacing-s); |
|||
border-top: 1px solid var(--spectrum-alias-border-color-mid); |
|||
border-right: 1px solid var(--spectrum-alias-border-color-mid); |
|||
} |
|||
</style> |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue