Standardise usage of user avatars and colours across the entire platform
This commit is contained in:
parent
f246a982db
commit
cc7df474c9
|
@ -13,10 +13,12 @@
|
||||||
export let url = ""
|
export let url = ""
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let initials = "JD"
|
export let initials = "JD"
|
||||||
|
export let color = null
|
||||||
|
|
||||||
const DefaultColor = "#3aab87"
|
const DefaultColor = "#3aab87"
|
||||||
|
|
||||||
$: color = getColor(initials)
|
$: avatarColor = color || getColor(initials)
|
||||||
|
$: style = getStyle(size, avatarColor)
|
||||||
|
|
||||||
const getColor = initials => {
|
const getColor = initials => {
|
||||||
if (!initials?.length) {
|
if (!initials?.length) {
|
||||||
|
@ -26,6 +28,12 @@
|
||||||
const hue = ((code % 26) / 26) * 360
|
const hue = ((code % 26) / 26) * 360
|
||||||
return `hsl(${hue}, 50%, 50%)`
|
return `hsl(${hue}, 50%, 50%)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStyle = (sizeKey, color) => {
|
||||||
|
const size = `var(${sizes.get(sizeKey)})`
|
||||||
|
const fontSize = `calc(${size} / 2)`
|
||||||
|
return `width:${size}; height:${size}; font-size:${fontSize}; background:${color};`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if url}
|
{#if url}
|
||||||
|
@ -37,13 +45,7 @@
|
||||||
style="width: var({sizes.get(size)}); height: var({sizes.get(size)});"
|
style="width: var({sizes.get(size)}); height: var({sizes.get(size)});"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div class="spectrum-Avatar" class:is-disabled={disabled} {style}>
|
||||||
class="spectrum-Avatar"
|
|
||||||
class:is-disabled={disabled}
|
|
||||||
style="width: var({sizes.get(size)}); height: var({sizes.get(
|
|
||||||
size
|
|
||||||
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
|
|
||||||
>
|
|
||||||
{initials || ""}
|
{initials || ""}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -9,11 +9,11 @@ export const getUserStore = () => {
|
||||||
|
|
||||||
const updateUser = user => {
|
const updateUser = user => {
|
||||||
const $users = get(store)
|
const $users = get(store)
|
||||||
if (!$users.some(x => x.id === user.id)) {
|
if (!$users.some(x => x.sessionId === user.sessionId)) {
|
||||||
store.set([...$users, user])
|
store.set([...$users, user])
|
||||||
} else {
|
} else {
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
const index = state.findIndex(x => x.id === user.id)
|
const index = state.findIndex(x => x.sessionId === user.sessionId)
|
||||||
state[index] = user
|
state[index] = user
|
||||||
return state.slice()
|
return state.slice()
|
||||||
})
|
})
|
||||||
|
@ -22,7 +22,7 @@ export const getUserStore = () => {
|
||||||
|
|
||||||
const removeUser = user => {
|
const removeUser = user => {
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
return state.filter(x => x.id !== user.id)
|
return state.filter(x => x.sessionId !== user.sessionId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,143 +0,0 @@
|
||||||
<script>
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
ModalContent,
|
|
||||||
Modal,
|
|
||||||
notifications,
|
|
||||||
ProgressCircle,
|
|
||||||
Layout,
|
|
||||||
Body,
|
|
||||||
Icon,
|
|
||||||
} from "@budibase/bbui"
|
|
||||||
import { auth, apps } from "stores/portal"
|
|
||||||
import { processStringSync } from "@budibase/string-templates"
|
|
||||||
import { API } from "api"
|
|
||||||
|
|
||||||
export let app
|
|
||||||
export let buttonSize = "M"
|
|
||||||
|
|
||||||
let APP_DEV_LOCK_SECONDS = 600 //common area for this?
|
|
||||||
let appLockModal
|
|
||||||
let processing = false
|
|
||||||
|
|
||||||
$: lockedBy = app?.lockedBy
|
|
||||||
$: lockedByYou = $auth.user.email === lockedBy?.email
|
|
||||||
|
|
||||||
$: lockIdentifer = `${
|
|
||||||
lockedBy && lockedBy.firstName ? lockedBy?.firstName : lockedBy?.email
|
|
||||||
}`
|
|
||||||
|
|
||||||
$: lockedByHeading =
|
|
||||||
lockedBy && lockedByYou ? "Locked by you" : `Locked by ${lockIdentifer}`
|
|
||||||
|
|
||||||
const getExpiryDuration = app => {
|
|
||||||
if (!app?.lockedBy?.lockedAt) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
let expiry =
|
|
||||||
new Date(app.lockedBy.lockedAt).getTime() + APP_DEV_LOCK_SECONDS * 1000
|
|
||||||
return expiry - new Date().getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
const releaseLock = async () => {
|
|
||||||
processing = true
|
|
||||||
if (app) {
|
|
||||||
try {
|
|
||||||
await API.releaseAppLock(app.devId)
|
|
||||||
await apps.load()
|
|
||||||
notifications.success("Lock released successfully")
|
|
||||||
} catch (err) {
|
|
||||||
notifications.error("Error releasing lock")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
notifications.error("No application is selected")
|
|
||||||
}
|
|
||||||
processing = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if lockedBy}
|
|
||||||
<div class="lock-status">
|
|
||||||
<Icon
|
|
||||||
name="User"
|
|
||||||
hoverable
|
|
||||||
size={buttonSize}
|
|
||||||
on:click={e => {
|
|
||||||
e.stopPropagation()
|
|
||||||
appLockModal.show()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Modal bind:this={appLockModal}>
|
|
||||||
<ModalContent
|
|
||||||
title={lockedByHeading}
|
|
||||||
showConfirmButton={false}
|
|
||||||
showCancelButton={false}
|
|
||||||
>
|
|
||||||
<Layout noPadding>
|
|
||||||
<Body size="S">
|
|
||||||
Apps are locked to prevent work being lost from overlapping changes
|
|
||||||
between your team.
|
|
||||||
</Body>
|
|
||||||
{#if lockedByYou && getExpiryDuration(app) > 0}
|
|
||||||
<span class="lock-expiry-body">
|
|
||||||
{processStringSync(
|
|
||||||
"This lock will expire in {{ duration time 'millisecond' }} from now.",
|
|
||||||
{
|
|
||||||
time: getExpiryDuration(app),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<div class="lock-modal-actions">
|
|
||||||
<ButtonGroup>
|
|
||||||
<Button
|
|
||||||
secondary
|
|
||||||
quiet={lockedBy && lockedByYou}
|
|
||||||
disabled={processing}
|
|
||||||
on:click={() => {
|
|
||||||
appLockModal.hide()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span class="cancel"
|
|
||||||
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
{#if lockedByYou}
|
|
||||||
<Button
|
|
||||||
cta
|
|
||||||
disabled={processing}
|
|
||||||
on:click={() => {
|
|
||||||
releaseLock()
|
|
||||||
appLockModal.hide()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if processing}
|
|
||||||
<ProgressCircle overBackground={true} size="S" />
|
|
||||||
{:else}
|
|
||||||
<span class="unlock">Release Lock</span>
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</ButtonGroup>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.lock-modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: var(--spacing-l);
|
|
||||||
gap: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
.lock-status {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-s);
|
|
||||||
max-width: 175px;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,13 +1,16 @@
|
||||||
<script>
|
<script>
|
||||||
import { Heading, Body, Button, Icon, notifications } from "@budibase/bbui"
|
import { Heading, Body, Button, Icon, Tooltip } from "@budibase/bbui"
|
||||||
import AppLockModal from "../common/AppLockModal.svelte"
|
|
||||||
import { processStringSync } from "@budibase/string-templates"
|
import { processStringSync } from "@budibase/string-templates"
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
import { UserAvatar } from "@budibase/frontend-core"
|
||||||
|
|
||||||
export let app
|
export let app
|
||||||
|
|
||||||
export let lockedAction
|
export let lockedAction
|
||||||
|
|
||||||
|
$: editing = app?.lockedBy != null
|
||||||
|
$: initials = helpers.getUserInitials(app?.lockedBy)
|
||||||
|
|
||||||
const handleDefaultClick = () => {
|
const handleDefaultClick = () => {
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
goToOverview()
|
goToOverview()
|
||||||
|
@ -38,7 +41,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="updated">
|
<div class="updated">
|
||||||
{#if app.updatedAt}
|
{#if editing}
|
||||||
|
Currently editing
|
||||||
|
<UserAvatar user={app.lockedBy} />
|
||||||
|
{:else if app.updatedAt}
|
||||||
{processStringSync("Updated {{ duration time 'millisecond' }} ago", {
|
{processStringSync("Updated {{ duration time 'millisecond' }} ago", {
|
||||||
time: new Date().getTime() - new Date(app.updatedAt).getTime(),
|
time: new Date().getTime() - new Date(app.updatedAt).getTime(),
|
||||||
})}
|
})}
|
||||||
|
@ -53,12 +59,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="app-row-actions">
|
<div class="app-row-actions">
|
||||||
<AppLockModal {app} buttonSize="M" />
|
<Button size="S" secondary on:click={lockedAction || goToOverview}>
|
||||||
<Button size="S" secondary on:click={lockedAction || goToOverview}
|
Manage
|
||||||
>Manage</Button
|
</Button>
|
||||||
>
|
<Button size="S" primary on:click={lockedAction || goToBuilder}>
|
||||||
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button
|
Edit
|
||||||
>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -81,6 +87,9 @@
|
||||||
|
|
||||||
.updated {
|
.updated {
|
||||||
color: var(--spectrum-global-color-gray-700);
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title,
|
.title,
|
||||||
|
|
|
@ -1,12 +1,22 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { UserAvatar } from "@budibase/frontend-core"
|
||||||
|
|
||||||
export let users = []
|
export let users = []
|
||||||
|
|
||||||
|
$: uniqueUsers = unique(users)
|
||||||
|
|
||||||
|
const unique = users => {
|
||||||
|
let uniqueUsers = {}
|
||||||
|
users?.forEach(user => {
|
||||||
|
uniqueUsers[user.email] = user
|
||||||
|
})
|
||||||
|
return Object.values(uniqueUsers)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="avatars">
|
<div class="avatars">
|
||||||
{#each users as user}
|
{#each uniqueUsers as user}
|
||||||
<div class="avatar" style="background:{user.color};" title={user.email}>
|
<UserAvatar {user} tooltipDirection="bottom" />
|
||||||
{user.email[0]}
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -15,19 +25,4 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.avatar {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
.avatar:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -218,7 +218,9 @@
|
||||||
<!-- This should probably be some kind of loading state? -->
|
<!-- This should probably be some kind of loading state? -->
|
||||||
<div class="loading" />
|
<div class="loading" />
|
||||||
{:then _}
|
{:then _}
|
||||||
<slot />
|
<div class="body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
{:catch error}
|
{:catch error}
|
||||||
<p>Something went wrong: {error.message}</p>
|
<p>Something went wrong: {error.message}</p>
|
||||||
{/await}
|
{/await}
|
||||||
|
@ -254,6 +256,7 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
border-bottom: var(--border-light);
|
border-bottom: var(--border-light);
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topleftnav {
|
.topleftnav {
|
||||||
|
@ -294,4 +297,11 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
Divider,
|
Divider,
|
||||||
ActionMenu,
|
ActionMenu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Avatar,
|
|
||||||
Page,
|
Page,
|
||||||
Icon,
|
Icon,
|
||||||
Body,
|
Body,
|
||||||
|
@ -22,6 +21,8 @@
|
||||||
import { processStringSync } from "@budibase/string-templates"
|
import { processStringSync } from "@budibase/string-templates"
|
||||||
import Spaceman from "assets/bb-space-man.svg"
|
import Spaceman from "assets/bb-space-man.svg"
|
||||||
import Logo from "assets/bb-emblem.svg"
|
import Logo from "assets/bb-emblem.svg"
|
||||||
|
import { UserAvatar } from "@budibase/frontend-core"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
let loaded = false
|
let loaded = false
|
||||||
let userInfoModal
|
let userInfoModal
|
||||||
|
@ -96,11 +97,7 @@
|
||||||
<img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
|
<img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
|
||||||
<ActionMenu align="right">
|
<ActionMenu align="right">
|
||||||
<div slot="control" class="avatar">
|
<div slot="control" class="avatar">
|
||||||
<Avatar
|
<UserAvatar user={$auth.user} showTooltip={false} />
|
||||||
size="M"
|
|
||||||
initials={$auth.initials}
|
|
||||||
url={$auth.user.pictureUrl}
|
|
||||||
/>
|
|
||||||
<Icon size="XL" name="ChevronDown" />
|
<Icon size="XL" name="ChevronDown" />
|
||||||
</div>
|
</div>
|
||||||
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
|
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
|
||||||
|
@ -125,7 +122,7 @@
|
||||||
</div>
|
</div>
|
||||||
<Layout noPadding gap="XS">
|
<Layout noPadding gap="XS">
|
||||||
<Heading size="M">
|
<Heading size="M">
|
||||||
Hey {$auth.user.firstName || $auth.user.email}
|
Hey {helpers.getUserLabel($auth.user)}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Body>
|
<Body>
|
||||||
Welcome to the {$organisation.company} portal. Below you'll find the
|
Welcome to the {$organisation.company} portal. Below you'll find the
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
<script>
|
<script>
|
||||||
import { auth } from "stores/portal"
|
import { auth } from "stores/portal"
|
||||||
import { ActionMenu, Avatar, MenuItem, Icon, Modal } from "@budibase/bbui"
|
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
import ProfileModal from "components/settings/ProfileModal.svelte"
|
import ProfileModal from "components/settings/ProfileModal.svelte"
|
||||||
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
|
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
|
||||||
import ThemeModal from "components/settings/ThemeModal.svelte"
|
import ThemeModal from "components/settings/ThemeModal.svelte"
|
||||||
import APIKeyModal from "components/settings/APIKeyModal.svelte"
|
import APIKeyModal from "components/settings/APIKeyModal.svelte"
|
||||||
|
import { UserAvatar } from "@budibase/frontend-core"
|
||||||
|
|
||||||
let themeModal
|
let themeModal
|
||||||
let profileModal
|
let profileModal
|
||||||
|
@ -23,7 +24,7 @@
|
||||||
|
|
||||||
<ActionMenu align="right">
|
<ActionMenu align="right">
|
||||||
<div slot="control" class="user-dropdown">
|
<div slot="control" class="user-dropdown">
|
||||||
<Avatar size="M" initials={$auth.initials} url={$auth.user.pictureUrl} />
|
<UserAvatar user={$auth.user} showTooltip={false} />
|
||||||
<Icon size="XL" name="ChevronDown" />
|
<Icon size="XL" name="ChevronDown" />
|
||||||
</div>
|
</div>
|
||||||
<MenuItem icon="UserEdit" on:click={() => profileModal.show()}>
|
<MenuItem icon="UserEdit" on:click={() => profileModal.show()}>
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
<script>
|
<script>
|
||||||
import { Avatar, Tooltip } from "@budibase/bbui"
|
import { Tooltip } from "@budibase/bbui"
|
||||||
|
import { UserAvatar } from "@budibase/frontend-core"
|
||||||
|
|
||||||
export let row
|
export let row
|
||||||
|
|
||||||
let showTooltip
|
let showTooltip
|
||||||
const getInitials = user => {
|
|
||||||
let initials = ""
|
|
||||||
initials += user.firstName ? user.firstName[0] : ""
|
|
||||||
initials += user.lastName ? user.lastName[0] : ""
|
|
||||||
|
|
||||||
return initials === "" ? user.email[0] : initials
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if row?.user?.email}
|
{#if row?.user?.email}
|
||||||
|
@ -19,7 +14,7 @@
|
||||||
on:focus={() => (showTooltip = true)}
|
on:focus={() => (showTooltip = true)}
|
||||||
on:mouseleave={() => (showTooltip = false)}
|
on:mouseleave={() => (showTooltip = false)}
|
||||||
>
|
>
|
||||||
<Avatar size="M" initials={getInitials(row.user)} />
|
<UserAvatar user={row.user} />
|
||||||
</div>
|
</div>
|
||||||
{#if showTooltip}
|
{#if showTooltip}
|
||||||
<div class="tooltip">
|
<div class="tooltip">
|
||||||
|
|
|
@ -24,7 +24,6 @@
|
||||||
import { AppStatus } from "constants"
|
import { AppStatus } from "constants"
|
||||||
import analytics, { Events, EventSource } from "analytics"
|
import analytics, { Events, EventSource } from "analytics"
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import AppLockModal from "components/common/AppLockModal.svelte"
|
|
||||||
import EditableIcon from "components/common/EditableIcon.svelte"
|
import EditableIcon from "components/common/EditableIcon.svelte"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
|
@ -128,7 +127,6 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div slot="buttons">
|
<div slot="buttons">
|
||||||
<AppLockModal {app} />
|
|
||||||
<span class="desktop">
|
<span class="desktop">
|
||||||
<Button
|
<Button
|
||||||
size="M"
|
size="M"
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
<script>
|
<script>
|
||||||
import getUserInitials from "helpers/userInitials.js"
|
import { UserAvatar } from "@budibase/frontend-core"
|
||||||
import { Avatar } from "@budibase/bbui"
|
|
||||||
|
|
||||||
export let value
|
export let value
|
||||||
|
|
||||||
$: initials = getUserInitials(value)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div title={value.email} class="cell">
|
<div class="cell">
|
||||||
<Avatar size="M" {initials} />
|
<UserAvatar user={value} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -15,11 +15,12 @@
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import { processStringSync } from "@budibase/string-templates"
|
import { processStringSync } from "@budibase/string-templates"
|
||||||
import { users, auth, apps, groups, overview } from "stores/portal"
|
import { users, auth, apps, groups, overview } from "stores/portal"
|
||||||
import { fetchData } from "@budibase/frontend-core"
|
import { fetchData, UserAvatar } from "@budibase/frontend-core"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
|
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
|
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
let appEditor
|
let appEditor
|
||||||
let unpublishModal
|
let unpublishModal
|
||||||
|
@ -56,14 +57,6 @@
|
||||||
appEditor = await users.get(editorId)
|
appEditor = await users.get(editorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInitials = user => {
|
|
||||||
let initials = ""
|
|
||||||
initials += user.firstName ? user.firstName[0] : ""
|
|
||||||
initials += user.lastName ? user.lastName[0] : ""
|
|
||||||
|
|
||||||
return initials === "" ? user.email[0] : initials
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmUnpublishApp = async () => {
|
const confirmUnpublishApp = async () => {
|
||||||
try {
|
try {
|
||||||
await API.unpublishApp(app.prodId)
|
await API.unpublishApp(app.prodId)
|
||||||
|
@ -140,7 +133,7 @@
|
||||||
<div class="last-edited-content">
|
<div class="last-edited-content">
|
||||||
<div class="updated-by">
|
<div class="updated-by">
|
||||||
{#if appEditor}
|
{#if appEditor}
|
||||||
<Avatar size="M" initials={getInitials(appEditor)} />
|
<UserAvatar user={appEditor} showTooltip={false} />
|
||||||
<div class="editor-name">
|
<div class="editor-name">
|
||||||
{appEditor._id === $auth.user._id ? "You" : appEditorText}
|
{appEditor._id === $auth.user._id ? "You" : appEditorText}
|
||||||
</div>
|
</div>
|
||||||
|
@ -201,7 +194,7 @@
|
||||||
<div class="users">
|
<div class="users">
|
||||||
<div class="list">
|
<div class="list">
|
||||||
{#each appUsers.slice(0, 4) as user}
|
{#each appUsers.slice(0, 4) as user}
|
||||||
<Avatar size="M" initials={getInitials(user)} />
|
<UserAvatar {user} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="text">
|
<div class="text">
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
import { goto, url } from "@roxi/routify"
|
import { goto, url } from "@roxi/routify"
|
||||||
import {
|
import {
|
||||||
ActionMenu,
|
ActionMenu,
|
||||||
Avatar,
|
|
||||||
Button,
|
Button,
|
||||||
Layout,
|
Layout,
|
||||||
Heading,
|
Heading,
|
||||||
|
@ -25,13 +24,14 @@
|
||||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||||
import DeleteUserModal from "./_components/DeleteUserModal.svelte"
|
import DeleteUserModal from "./_components/DeleteUserModal.svelte"
|
||||||
import GroupIcon from "../groups/_components/GroupIcon.svelte"
|
import GroupIcon from "../groups/_components/GroupIcon.svelte"
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants, UserAvatar } from "@budibase/frontend-core"
|
||||||
import { Breadcrumbs, Breadcrumb } from "components/portal/page"
|
import { Breadcrumbs, Breadcrumb } from "components/portal/page"
|
||||||
import RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte"
|
import RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte"
|
||||||
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
|
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
|
||||||
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
|
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
|
||||||
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
|
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
|
||||||
import ScimBanner from "../_components/SCIMBanner.svelte"
|
import ScimBanner from "../_components/SCIMBanner.svelte"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
export let userId
|
export let userId
|
||||||
|
|
||||||
|
@ -91,7 +91,7 @@
|
||||||
$: readonly = !$auth.isAdmin || scimEnabled
|
$: readonly = !$auth.isAdmin || scimEnabled
|
||||||
$: privileged = user?.admin?.global || user?.builder?.global
|
$: privileged = user?.admin?.global || user?.builder?.global
|
||||||
$: nameLabel = getNameLabel(user)
|
$: nameLabel = getNameLabel(user)
|
||||||
$: initials = getInitials(nameLabel)
|
$: initials = helpers.getUserInitials(user)
|
||||||
$: filteredGroups = getFilteredGroups($groups, searchTerm)
|
$: filteredGroups = getFilteredGroups($groups, searchTerm)
|
||||||
$: availableApps = getAvailableApps($apps, privileged, user?.roles)
|
$: availableApps = getAvailableApps($apps, privileged, user?.roles)
|
||||||
$: userGroups = $groups.filter(x => {
|
$: userGroups = $groups.filter(x => {
|
||||||
|
@ -150,17 +150,6 @@
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInitials = nameLabel => {
|
|
||||||
if (!nameLabel) {
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
return nameLabel
|
|
||||||
.split(" ")
|
|
||||||
.slice(0, 2)
|
|
||||||
.map(x => x[0])
|
|
||||||
.join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateUserFirstName(evt) {
|
async function updateUserFirstName(evt) {
|
||||||
try {
|
try {
|
||||||
await users.save({ ...user, firstName: evt.target.value })
|
await users.save({ ...user, firstName: evt.target.value })
|
||||||
|
@ -238,7 +227,7 @@
|
||||||
|
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<Avatar size="XXL" {initials} />
|
<UserAvatar size="XXL" {user} showTooltip={false} />
|
||||||
<div class="subtitle">
|
<div class="subtitle">
|
||||||
<Heading size="M">{nameLabel}</Heading>
|
<Heading size="M">{nameLabel}</Heading>
|
||||||
{#if nameLabel !== user?.email}
|
{#if nameLabel !== user?.email}
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script>
|
||||||
|
import { Avatar, Tooltip } from "@budibase/bbui"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
|
export let user
|
||||||
|
export let size
|
||||||
|
export let tooltipDirection = "top"
|
||||||
|
export let showTooltip = true
|
||||||
|
|
||||||
|
$: tooltipStyle = getTooltipStyle(tooltipDirection)
|
||||||
|
|
||||||
|
const getTooltipStyle = direction => {
|
||||||
|
if (!direction) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if (direction === "top") {
|
||||||
|
return "transform: translateX(-50%) translateY(-100%);"
|
||||||
|
} else if (direction === "bottom") {
|
||||||
|
return "transform: translateX(-50%) translateY(100%);"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if user}
|
||||||
|
<div class="user-avatar">
|
||||||
|
<Avatar
|
||||||
|
{size}
|
||||||
|
initials={helpers.getUserInitials(user)}
|
||||||
|
color={helpers.getUserColor(user)}
|
||||||
|
/>
|
||||||
|
{#if showTooltip}
|
||||||
|
<div class="tooltip" style={tooltipStyle}>
|
||||||
|
<Tooltip
|
||||||
|
direction={tooltipDirection}
|
||||||
|
textWrapping
|
||||||
|
text={user.email}
|
||||||
|
size="S"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.user-avatar {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.user-avatar:hover .tooltip {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,24 +0,0 @@
|
||||||
<script>
|
|
||||||
export let user
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="user" style="background:{user.color};" title={user.email}>
|
|
||||||
{user.email[0]}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
div {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
div:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,13 +1,23 @@
|
||||||
<script>
|
<script>
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
import Avatar from "./Avatar.svelte"
|
import UserAvatar from "../../UserAvatar.svelte"
|
||||||
|
|
||||||
const { users } = getContext("grid")
|
const { users } = getContext("grid")
|
||||||
|
|
||||||
|
$: uniqueUsers = unique($users)
|
||||||
|
|
||||||
|
const unique = users => {
|
||||||
|
let uniqueUsers = {}
|
||||||
|
users?.forEach(user => {
|
||||||
|
uniqueUsers[user.email] = user
|
||||||
|
})
|
||||||
|
return Object.values(uniqueUsers)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="users">
|
<div class="users">
|
||||||
{#each $users as user}
|
{#each uniqueUsers as user}
|
||||||
<Avatar {user} />
|
<UserAvatar {user} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,22 @@
|
||||||
import { writable, get, derived } from "svelte/store"
|
import { writable, get, derived } from "svelte/store"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
export const createStores = () => {
|
export const createStores = () => {
|
||||||
const users = writable([])
|
const users = writable([])
|
||||||
|
|
||||||
|
const enrichedUsers = derived(users, $users => {
|
||||||
|
return $users.map(user => ({
|
||||||
|
...user,
|
||||||
|
color: helpers.getUserColor(user),
|
||||||
|
label: helpers.getUserLabel(user),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users,
|
users: {
|
||||||
|
...users,
|
||||||
|
subscribe: enrichedUsers.subscribe,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,11 +40,11 @@ export const deriveStores = context => {
|
||||||
|
|
||||||
const updateUser = user => {
|
const updateUser = user => {
|
||||||
const $users = get(users)
|
const $users = get(users)
|
||||||
if (!$users.some(x => x.id === user.id)) {
|
if (!$users.some(x => x.sessionId === user.sessionId)) {
|
||||||
users.set([...$users, user])
|
users.set([...$users, user])
|
||||||
} else {
|
} else {
|
||||||
users.update(state => {
|
users.update(state => {
|
||||||
const index = state.findIndex(x => x.id === user.id)
|
const index = state.findIndex(x => x.sessionId === user.sessionId)
|
||||||
state[index] = user
|
state[index] = user
|
||||||
return state.slice()
|
return state.slice()
|
||||||
})
|
})
|
||||||
|
@ -41,7 +53,7 @@ export const deriveStores = context => {
|
||||||
|
|
||||||
const removeUser = user => {
|
const removeUser = user => {
|
||||||
users.update(state => {
|
users.update(state => {
|
||||||
return state.filter(x => x.id !== user.id)
|
return state.filter(x => x.sessionId !== user.sessionId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export { default as SplitPage } from "./SplitPage.svelte"
|
export { default as SplitPage } from "./SplitPage.svelte"
|
||||||
export { default as TestimonialPage } from "./TestimonialPage.svelte"
|
export { default as TestimonialPage } from "./TestimonialPage.svelte"
|
||||||
export { default as Testimonial } from "./Testimonial.svelte"
|
export { default as Testimonial } from "./Testimonial.svelte"
|
||||||
|
export { default as UserAvatar } from "./UserAvatar.svelte"
|
||||||
export { Grid } from "./grid"
|
export { Grid } from "./grid"
|
||||||
|
|
|
@ -5,6 +5,7 @@ import http from "http"
|
||||||
import Koa from "koa"
|
import Koa from "koa"
|
||||||
import { Datasource, Table } from "@budibase/types"
|
import { Datasource, Table } from "@budibase/types"
|
||||||
import { gridSocket } from "./index"
|
import { gridSocket } from "./index"
|
||||||
|
import { clearLock } from "../utilities/redis"
|
||||||
|
|
||||||
export default class BuilderSocket extends Socket {
|
export default class BuilderSocket extends Socket {
|
||||||
constructor(app: Koa, server: http.Server) {
|
constructor(app: Koa, server: http.Server) {
|
||||||
|
@ -15,8 +16,7 @@ export default class BuilderSocket extends Socket {
|
||||||
const user = socket.data.user
|
const user = socket.data.user
|
||||||
const appId = socket.data.appId
|
const appId = socket.data.appId
|
||||||
socket.join(appId)
|
socket.join(appId)
|
||||||
socket.to(appId).emit("user-update", socket.data.user)
|
socket.to(appId).emit("user-update", user)
|
||||||
console.log(`Builder user connected: ${user?.id}`)
|
|
||||||
|
|
||||||
// Initial identification of connected spreadsheet
|
// Initial identification of connected spreadsheet
|
||||||
socket.on("get-users", async (payload, callback) => {
|
socket.on("get-users", async (payload, callback) => {
|
||||||
|
@ -27,8 +27,22 @@ export default class BuilderSocket extends Socket {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Disconnection cleanup
|
// Disconnection cleanup
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", async () => {
|
||||||
socket.to(appId).emit("user-disconnect", socket.data.user)
|
socket.to(appId).emit("user-disconnect", user)
|
||||||
|
|
||||||
|
// Remove app lock from this user if they have no other connections
|
||||||
|
try {
|
||||||
|
const sockets = await this.io.in(appId).fetchSockets()
|
||||||
|
const hasOtherConnection = sockets.some(socket => {
|
||||||
|
const { _id, sessionId } = socket.data.user
|
||||||
|
return _id === user._id && sessionId !== user.sessionId
|
||||||
|
})
|
||||||
|
if (!hasOtherConnection) {
|
||||||
|
await clearLock(appId, user)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// This is fine, just means this user didn't hold the lock
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@ export default class GridSocket extends Socket {
|
||||||
|
|
||||||
this.io.on("connection", socket => {
|
this.io.on("connection", socket => {
|
||||||
const user = socket.data.user
|
const user = socket.data.user
|
||||||
console.log(`Spreadsheet user connected: ${user?.id}`)
|
|
||||||
|
|
||||||
// Socket state
|
// Socket state
|
||||||
let currentRoom: string
|
let currentRoom: string
|
||||||
|
@ -21,14 +20,14 @@ export default class GridSocket extends Socket {
|
||||||
socket.on("select-table", async (tableId, callback) => {
|
socket.on("select-table", async (tableId, callback) => {
|
||||||
// Leave current room
|
// Leave current room
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
|
socket.to(currentRoom).emit("user-disconnect", user)
|
||||||
socket.leave(currentRoom)
|
socket.leave(currentRoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join new room
|
// Join new room
|
||||||
currentRoom = tableId
|
currentRoom = tableId
|
||||||
socket.join(currentRoom)
|
socket.join(currentRoom)
|
||||||
socket.to(currentRoom).emit("user-update", socket.data.user)
|
socket.to(currentRoom).emit("user-update", user)
|
||||||
|
|
||||||
// Reply with all users in current room
|
// Reply with all users in current room
|
||||||
const sockets = await this.io.in(currentRoom).fetchSockets()
|
const sockets = await this.io.in(currentRoom).fetchSockets()
|
||||||
|
@ -41,14 +40,14 @@ export default class GridSocket extends Socket {
|
||||||
socket.on("select-cell", cellId => {
|
socket.on("select-cell", cellId => {
|
||||||
socket.data.user.focusedCellId = cellId
|
socket.data.user.focusedCellId = cellId
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
socket.to(currentRoom).emit("user-update", socket.data.user)
|
socket.to(currentRoom).emit("user-update", user)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Disconnection cleanup
|
// Disconnection cleanup
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
if (currentRoom) {
|
if (currentRoom) {
|
||||||
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
|
socket.to(currentRoom).emit("user-disconnect", user)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { auth } from "@budibase/backend-core"
|
||||||
import currentApp from "../middleware/currentapp"
|
import currentApp from "../middleware/currentapp"
|
||||||
import { createAdapter } from "@socket.io/redis-adapter"
|
import { createAdapter } from "@socket.io/redis-adapter"
|
||||||
import { getSocketPubSubClients } from "../utilities/redis"
|
import { getSocketPubSubClients } from "../utilities/redis"
|
||||||
|
import uuid from "uuid"
|
||||||
|
|
||||||
export default class Socket {
|
export default class Socket {
|
||||||
io: Server
|
io: Server
|
||||||
|
@ -64,29 +65,14 @@ export default class Socket {
|
||||||
// Middlewares are finished
|
// Middlewares are finished
|
||||||
// Extract some data from our enriched koa context to persist
|
// Extract some data from our enriched koa context to persist
|
||||||
// as metadata for the socket
|
// as metadata for the socket
|
||||||
|
// Add user info, including a deterministic color and label
|
||||||
// Add user info, including a deterministic color and friendly
|
|
||||||
// label
|
|
||||||
const { _id, email, firstName, lastName } = ctx.user
|
const { _id, email, firstName, lastName } = ctx.user
|
||||||
let hue = 1
|
|
||||||
for (let i = 0; i < email.length && i < 5; i++) {
|
|
||||||
hue *= email.charCodeAt(i + 1)
|
|
||||||
hue /= 17
|
|
||||||
}
|
|
||||||
hue = hue % 360
|
|
||||||
const color = `hsl(${hue}, 50%, 40%)`
|
|
||||||
let label = email
|
|
||||||
if (firstName) {
|
|
||||||
label = firstName
|
|
||||||
if (lastName) {
|
|
||||||
label += ` ${lastName}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.data.user = {
|
socket.data.user = {
|
||||||
id: _id,
|
_id,
|
||||||
email,
|
email,
|
||||||
color,
|
firstName,
|
||||||
label,
|
lastName,
|
||||||
|
sessionId: uuid.v4(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add app ID to help split sockets into rooms
|
// Add app ID to help split sockets into rooms
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { User } from "@budibase/types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets a key within an object. The key supports dot syntax for retrieving deep
|
* Gets a key within an object. The key supports dot syntax for retrieving deep
|
||||||
* fields - e.g. "a.b.c".
|
* fields - e.g. "a.b.c".
|
||||||
|
@ -21,3 +23,60 @@ export const deepGet = (obj: { [x: string]: any }, key: string) => {
|
||||||
}
|
}
|
||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the initials to show in a user avatar.
|
||||||
|
* @param user the user
|
||||||
|
*/
|
||||||
|
export const getUserInitials = (user: User) => {
|
||||||
|
if (!user) {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
let initials = ""
|
||||||
|
initials += user.firstName ? user.firstName[0] : ""
|
||||||
|
initials += user.lastName ? user.lastName[0] : ""
|
||||||
|
return initials === "" ? user.email[0] : initials
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a deterministic colour for a particular user
|
||||||
|
* @param user the user
|
||||||
|
*/
|
||||||
|
export const getUserColor = (user: User) => {
|
||||||
|
let id = user?._id
|
||||||
|
if (!id) {
|
||||||
|
return "var(--spectrum-global-color-blue-400)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// In order to generate the same color for global users as app users, we need
|
||||||
|
// to remove the app-specific table prefix
|
||||||
|
id = id.replace("ro_ta_users_", "")
|
||||||
|
|
||||||
|
// Generate a hue based on the ID
|
||||||
|
let hue = 1
|
||||||
|
for (let i = 0; i < id.length; i++) {
|
||||||
|
hue += id.charCodeAt(i)
|
||||||
|
hue = hue % 36
|
||||||
|
}
|
||||||
|
return `hsl(${hue * 10}, 50%, 40%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a friendly label to describe who a user is.
|
||||||
|
* @param user the user
|
||||||
|
*/
|
||||||
|
export const getUserLabel = (user: User) => {
|
||||||
|
if (!user) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
const { firstName, lastName, email } = user
|
||||||
|
if (firstName && lastName) {
|
||||||
|
return `${firstName} ${lastName}`
|
||||||
|
} else if (firstName) {
|
||||||
|
return firstName
|
||||||
|
} else if (lastName) {
|
||||||
|
return lastName
|
||||||
|
} else {
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue