1
0
Fork 0
mirror of synced 2024-10-02 10:08:09 +13:00

Merge pull request #10712 from Budibase/data-section-multidev

Multiple user collaboration for data section
This commit is contained in:
Andrew Kingston 2023-05-30 09:08:14 +01:00 committed by GitHub
commit be40d72f55
55 changed files with 989 additions and 647 deletions

View file

@ -27,6 +27,7 @@ export enum Databases {
GENERIC_CACHE = "data_cache",
WRITE_THROUGH = "writeThrough",
LOCKS = "locks",
SOCKET_IO = "socket_io",
}
/**

View file

@ -13,10 +13,12 @@
export let url = ""
export let disabled = false
export let initials = "JD"
export let color = null
const DefaultColor = "#3aab87"
$: color = getColor(initials)
$: avatarColor = color || getColor(initials)
$: style = getStyle(size, avatarColor)
const getColor = initials => {
if (!initials?.length) {
@ -26,6 +28,12 @@
const hue = ((code % 26) / 26) * 360
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>
{#if url}
@ -37,13 +45,7 @@
style="width: var({sizes.get(size)}); height: var({sizes.get(size)});"
/>
{:else}
<div
class="spectrum-Avatar"
class:is-disabled={disabled}
style="width: var({sizes.get(size)}); height: var({sizes.get(
size
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
>
<div class="spectrum-Avatar" class:is-disabled={disabled} {style}>
{initials || ""}
</div>
{/if}

View file

@ -2,6 +2,7 @@ import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users"
import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core"
@ -12,6 +13,7 @@ export const store = getFrontendStore()
export const automationStore = getAutomationStore()
export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore()
export const userStore = getUserStore()
// Setup history for screens
export const screenHistoryStore = createHistoryStore({

View file

@ -37,8 +37,10 @@ import {
} from "builderStore/dataBinding"
import { makePropSafe as safe } from "@budibase/string-templates"
import { getComponentFieldOptions } from "helpers/formFields"
import { createBuilderWebsocket } from "builderStore/websocket"
const INITIAL_FRONTEND_STATE = {
initialised: false,
apps: [],
name: "",
url: "",
@ -70,6 +72,7 @@ const INITIAL_FRONTEND_STATE = {
previewDevice: "desktop",
highlightedSettingKey: null,
builderSidePanel: false,
hasLock: true,
// URL params
selectedScreenId: null,
@ -86,6 +89,7 @@ const INITIAL_FRONTEND_STATE = {
export const getFrontendStore = () => {
const store = writable({ ...INITIAL_FRONTEND_STATE })
let websocket
// This is a fake implementation of a "patch" API endpoint to try and prevent
// 409s. All screen doc mutations (aside from creation) use this function,
@ -110,10 +114,11 @@ export const getFrontendStore = () => {
store.actions = {
reset: () => {
store.set({ ...INITIAL_FRONTEND_STATE })
websocket?.disconnect()
},
initialise: async pkg => {
const { layouts, screens, application, clientLibPath } = pkg
const { layouts, screens, application, clientLibPath, hasLock } = pkg
websocket = createBuilderWebsocket()
await store.actions.components.refreshDefinitions(application.appId)
// Reset store state
@ -137,6 +142,8 @@ export const getFrontendStore = () => {
upgradableVersion: application.upgradableVersion,
navigation: application.navigation || {},
usedPlugins: application.usedPlugins || [],
hasLock,
initialised: true,
}))
screenHistoryStore.reset()
automationHistoryStore.reset()

View file

@ -0,0 +1,42 @@
import { writable, get } from "svelte/store"
export const getUserStore = () => {
const store = writable([])
const init = users => {
store.set(users)
}
const updateUser = user => {
const $users = get(store)
if (!$users.some(x => x.sessionId === user.sessionId)) {
store.set([...$users, user])
} else {
store.update(state => {
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
}
}
const removeUser = user => {
store.update(state => {
return state.filter(x => x.sessionId !== user.sessionId)
})
}
const reset = () => {
store.set([])
}
return {
...store,
actions: {
init,
updateUser,
removeUser,
reset,
},
}
}

View file

@ -0,0 +1,39 @@
import { createWebsocket } from "@budibase/frontend-core"
import { userStore } from "builderStore"
import { datasources, tables } from "stores/backend"
export const createBuilderWebsocket = () => {
const socket = createWebsocket("/socket/builder")
// Connection events
socket.on("connect", () => {
socket.emit("get-users", null, response => {
userStore.actions.init(response.users)
})
})
socket.on("connect_error", err => {
console.log("Failed to connect to builder websocket:", err.message)
})
// User events
socket.on("user-update", userStore.actions.updateUser)
socket.on("user-disconnect", userStore.actions.removeUser)
// Table events
socket.on("table-change", ({ id, table }) => {
tables.replaceTable(id, table)
})
// Table events
socket.on("datasource-change", ({ id, datasource }) => {
datasources.replaceDatasource(id, datasource)
})
return {
...socket,
disconnect: () => {
socket?.disconnect()
userStore.actions.reset()
},
}
}

View file

@ -16,11 +16,11 @@
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
const userSchemaOverrides = {
firstName: { name: "First name", disabled: true },
lastName: { name: "Last name", disabled: true },
email: { name: "Email", disabled: true },
roleId: { name: "Role", disabled: true },
status: { name: "Status", disabled: true },
firstName: { displayName: "First name", disabled: true },
lastName: { displayName: "Last name", disabled: true },
email: { displayName: "Email", disabled: true },
roleId: { displayName: "Role", disabled: true },
status: { displayName: "Status", disabled: true },
}
$: id = $tables.selected?._id
@ -36,7 +36,7 @@
allowAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
on:updatetable={e => tables.updateTable(e.detail)}
showAvatars={false}
>
<svelte:fragment slot="controls">
{#if isInternal}

View file

@ -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="LockClosed"
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>

View file

@ -113,109 +113,113 @@
})
</script>
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
<RevertModal />
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
{#if $store.hasLock}
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
{/if}
<RevertModal />
{#if !isPublished}
<ActionButton
quiet
icon="GlobeStrike"
size="M"
tooltip="Your app has not been published yet"
disabled
/>
{/if}
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
</div>
{/if}
<TourWrap
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<span id="builder-app-users-button">
{#if !isPublished}
<ActionButton
quiet
icon="UserGroup"
icon="GlobeStrike"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
tooltip="Your app has not been published yet"
disabled
/>
{/if}
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<TourWrap
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<span id="builder-app-users-button">
<ActionButton
quiet
icon="UserGroup"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
{/if}
<div class="buttons">
<Button on:click={previewApp} secondary>Preview</Button>
<DeployModal onOk={completePublish} />
{#if $store.hasLock}
<DeployModal onOk={completePublish} />
{/if}
</div>
<style>

View file

@ -1,13 +1,16 @@
<script>
import { Heading, Body, Button, Icon, notifications } from "@budibase/bbui"
import AppLockModal from "../common/AppLockModal.svelte"
import { Heading, Body, Button, Icon } from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates"
import { goto } from "@roxi/routify"
import { helpers } from "@budibase/shared-core"
import { UserAvatar } from "@budibase/frontend-core"
export let app
export let lockedAction
$: editing = app?.lockedBy != null
$: initials = helpers.getUserInitials(app?.lockedBy)
const handleDefaultClick = () => {
if (window.innerWidth < 640) {
goToOverview()
@ -17,12 +20,6 @@
}
const goToBuilder = () => {
if (app.lockedOther) {
notifications.error(
`App locked by ${app.lockedBy.email}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../app/${app.devId}`)
}
@ -44,7 +41,10 @@
</div>
<div class="updated">
{#if app.updatedAt}
{#if editing}
Currently editing
<UserAvatar user={app.lockedBy} />
{:else if app.updatedAt}
{processStringSync("Updated {{ duration time 'millisecond' }} ago", {
time: new Date().getTime() - new Date(app.updatedAt).getTime(),
})}
@ -59,12 +59,12 @@
</div>
<div class="app-row-actions">
<AppLockModal {app} buttonSize="M" />
<Button size="S" secondary on:click={lockedAction || goToOverview}
>Manage</Button
>
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button
>
<Button size="S" secondary on:click={lockedAction || goToOverview}>
Manage
</Button>
<Button size="S" primary on:click={lockedAction || goToBuilder}>
Edit
</Button>
</div>
</div>
@ -87,6 +87,9 @@
.updated {
color: var(--spectrum-global-color-gray-700);
display: flex;
align-items: center;
gap: 8px;
}
.title,

View file

@ -0,0 +1,28 @@
<script>
import { UserAvatar } from "@budibase/frontend-core"
export let users = []
$: uniqueUsers = unique(users)
const unique = users => {
let uniqueUsers = {}
users?.forEach(user => {
uniqueUsers[user.email] = user
})
return Object.values(uniqueUsers)
}
</script>
<div class="avatars">
{#each uniqueUsers as user}
<UserAvatar {user} tooltipDirection="bottom" />
{/each}
</div>
<style>
.avatars {
display: flex;
gap: 4px;
}
</style>

View file

@ -1,5 +1,5 @@
<script>
import { store, automationStore } from "builderStore"
import { store, automationStore, userStore } from "builderStore"
import { roles, flags } from "stores/backend"
import { auth } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
@ -13,7 +13,6 @@
Modal,
notifications,
} from "@budibase/bbui"
import AppActions from "components/deploy/AppActions.svelte"
import { API } from "api"
import { isActive, goto, layout, redirect } from "@roxi/routify"
@ -23,6 +22,7 @@
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import UserAvatars from "./_components/UserAvatars.svelte"
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
export let application
@ -30,7 +30,9 @@
let promise = getPackage()
let hasSynced = false
let commandPaletteModal
let loaded = false
$: loaded && initTour()
$: selected = capitalise(
$layout.children.find(layout => $isActive(layout.path))?.title ?? "data"
)
@ -43,6 +45,7 @@
await automationStore.actions.fetch()
await roles.fetch()
await flags.fetch()
loaded = true
return pkg
} catch (error) {
notifications.error(`Error initialising app: ${error?.message}`)
@ -67,13 +70,18 @@
// Event handler for the command palette
const handleKeyDown = e => {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
if (e.key === "k" && (e.ctrlKey || e.metaKey) && $store.hasLock) {
e.preventDefault()
commandPaletteModal.toggle()
}
}
const initTour = async () => {
// Skip tour if we don't have the lock
if (!$store.hasLock) {
return
}
// Check if onboarding is enabled.
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) {
if (!$auth.user?.onboardedAt) {
@ -110,7 +118,6 @@
// check if user has beta access
// const betaResponse = await API.checkBetaAccess($auth?.user?.email)
// betaAccess = betaResponse.access
initTour()
} catch (error) {
notifications.error("Failed to sync with production database")
}
@ -119,10 +126,7 @@
})
onDestroy(() => {
store.update(state => {
state.appId = null
return state
})
store.actions.reset()
})
</script>
@ -134,74 +138,89 @@
<div class="root">
<div class="top-nav">
<div class="topleftnav">
<ActionMenu>
<div slot="control">
<Icon size="M" hoverable name="ShowMenu" />
</div>
<MenuItem on:click={() => $goto("../../portal/apps")}>
Exit to portal
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}`)}
>
Overview
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}/access`)}
>
Access
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/automation-history`)}
>
Automation history
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}/backups`)}
>
Backups
</MenuItem>
{#if $store.initialised}
<div class="topleftnav">
<ActionMenu>
<div slot="control">
<Icon size="M" hoverable name="ShowMenu" />
</div>
<MenuItem on:click={() => $goto("../../portal/apps")}>
Exit to portal
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}`)}
>
Overview
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/access`)}
>
Access
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/automation-history`)}
>
Automation history
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/backups`)}
>
Backups
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/name-and-url`)}
>
Name and URL
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}/version`)}
>
Version
</MenuItem>
</ActionMenu>
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="topcenternav">
<Tabs {selected} size="M">
{#each $layout.children as { path, title }}
<TourWrap tourStepKey={`builder-${title}-section`}>
<Tab
quiet
selected={$isActive(path)}
on:click={topItemNavigate(path)}
title={capitalise(title)}
id={`builder-${title}-tab`}
/>
</TourWrap>
{/each}
</Tabs>
</div>
<div class="toprightnav">
<AppActions {application} />
</div>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/name-and-url`)}
>
Name and URL
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/version`)}
>
Version
</MenuItem>
</ActionMenu>
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="topcenternav">
{#if $store.hasLock}
<Tabs {selected} size="M">
{#each $layout.children as { path, title }}
<TourWrap tourStepKey={`builder-${title}-section`}>
<Tab
quiet
selected={$isActive(path)}
on:click={topItemNavigate(path)}
title={capitalise(title)}
id={`builder-${title}-tab`}
/>
</TourWrap>
{/each}
</Tabs>
{:else}
<div class="secondary-editor">
<Icon name="LockClosed" />
Another user is currently editing your screens and automations
</div>
{/if}
</div>
<div class="toprightnav">
<UserAvatars users={$userStore} />
<AppActions {application} />
</div>
{/if}
</div>
{#await promise}
<!-- This should probably be some kind of loading state? -->
<div class="loading" />
{:then _}
<slot />
<div class="body">
<slot />
</div>
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}
@ -237,6 +256,7 @@
box-sizing: border-box;
align-items: stretch;
border-bottom: var(--border-light);
z-index: 2;
}
.topleftnav {
@ -270,4 +290,18 @@
align-items: center;
gap: var(--spacing-l);
}
.secondary-editor {
align-self: center;
display: flex;
flex-direction: row;
gap: 8px;
}
.body {
flex: 1 1 auto;
z-index: 1;
display: flex;
flex-direction: column;
}
</style>

View file

@ -8,6 +8,15 @@
import { onDestroy, onMount } from "svelte"
import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify"
import { store } from "builderStore"
import { redirect } from "@roxi/routify"
// Prevent access for other users than the lock holder
$: {
if (!$store.hasLock) {
$redirect("../data")
}
}
// Keep URL and state in sync for selected screen ID
const stopSyncing = syncURLToState({

View file

@ -1,2 +1,14 @@
<script>
import { store } from "builderStore"
import { redirect } from "@roxi/routify"
// Prevent access for other users than the lock holder
$: {
if (!$store.hasLock) {
$redirect("../data")
}
}
</script>
<!-- routify:options index=2 -->
<slot />

View file

@ -5,7 +5,6 @@
Divider,
ActionMenu,
MenuItem,
Avatar,
Page,
Icon,
Body,
@ -22,6 +21,8 @@
import { processStringSync } from "@budibase/string-templates"
import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg"
import { UserAvatar } from "@budibase/frontend-core"
import { helpers } from "@budibase/shared-core"
let loaded = false
let userInfoModal
@ -96,11 +97,7 @@
<img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
<ActionMenu align="right">
<div slot="control" class="avatar">
<Avatar
size="M"
initials={$auth.initials}
url={$auth.user.pictureUrl}
/>
<UserAvatar user={$auth.user} showTooltip={false} />
<Icon size="XL" name="ChevronDown" />
</div>
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
@ -125,7 +122,7 @@
</div>
<Layout noPadding gap="XS">
<Heading size="M">
Hey {$auth.user.firstName || $auth.user.email}
Hey {helpers.getUserLabel($auth.user)}
</Heading>
<Body>
Welcome to the {$organisation.company} portal. Below you'll find the

View file

@ -1,11 +1,12 @@
<script>
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 ProfileModal from "components/settings/ProfileModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import ThemeModal from "components/settings/ThemeModal.svelte"
import APIKeyModal from "components/settings/APIKeyModal.svelte"
import { UserAvatar } from "@budibase/frontend-core"
let themeModal
let profileModal
@ -23,7 +24,7 @@
<ActionMenu align="right">
<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" />
</div>
<MenuItem icon="UserEdit" on:click={() => profileModal.show()}>

View file

@ -1,15 +1,10 @@
<script>
import { Avatar, Tooltip } from "@budibase/bbui"
import { Tooltip } from "@budibase/bbui"
import { UserAvatar } from "@budibase/frontend-core"
export let row
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>
{#if row?.user?.email}
@ -19,7 +14,7 @@
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Avatar size="M" initials={getInitials(row.user)} />
<UserAvatar user={row.user} />
</div>
{#if showTooltip}
<div class="tooltip">

View file

@ -24,7 +24,6 @@
import { AppStatus } from "constants"
import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore"
import AppLockModal from "components/common/AppLockModal.svelte"
import EditableIcon from "components/common/EditableIcon.svelte"
import { API } from "api"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -80,13 +79,6 @@
}
const editApp = () => {
if (appLocked && !lockedByYou) {
const identifier = app?.lockedBy?.firstName || app?.lockedBy?.email
notifications.warning(
`App locked by ${identifier}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../../app/${app.devId}`)
}
@ -135,7 +127,6 @@
/>
</div>
<div slot="buttons">
<AppLockModal {app} />
<span class="desktop">
<Button
size="M"

View file

@ -1,14 +1,11 @@
<script>
import getUserInitials from "helpers/userInitials.js"
import { Avatar } from "@budibase/bbui"
import { UserAvatar } from "@budibase/frontend-core"
export let value
$: initials = getUserInitials(value)
</script>
<div title={value.email} class="cell">
<Avatar size="M" {initials} />
<div class="cell">
<UserAvatar user={value} />
</div>
<style>

View file

@ -7,7 +7,6 @@
Icon,
Heading,
Link,
Avatar,
Layout,
Body,
notifications,
@ -15,7 +14,7 @@
import { store } from "builderStore"
import { processStringSync } from "@budibase/string-templates"
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 GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -56,14 +55,6 @@
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 () => {
try {
await API.unpublishApp(app.prodId)
@ -140,7 +131,7 @@
<div class="last-edited-content">
<div class="updated-by">
{#if appEditor}
<Avatar size="M" initials={getInitials(appEditor)} />
<UserAvatar user={appEditor} showTooltip={false} />
<div class="editor-name">
{appEditor._id === $auth.user._id ? "You" : appEditorText}
</div>
@ -201,7 +192,7 @@
<div class="users">
<div class="list">
{#each appUsers.slice(0, 4) as user}
<Avatar size="M" initials={getInitials(user)} />
<UserAvatar {user} />
{/each}
</div>
<div class="text">

View file

@ -2,7 +2,6 @@
import { goto, url } from "@roxi/routify"
import {
ActionMenu,
Avatar,
Button,
Layout,
Heading,
@ -25,13 +24,14 @@
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import DeleteUserModal from "./_components/DeleteUserModal.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 RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte"
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte"
import { helpers } from "@budibase/shared-core"
export let userId
@ -91,7 +91,7 @@
$: readonly = !$auth.isAdmin || scimEnabled
$: privileged = user?.admin?.global || user?.builder?.global
$: nameLabel = getNameLabel(user)
$: initials = getInitials(nameLabel)
$: initials = helpers.getUserInitials(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles)
$: userGroups = $groups.filter(x => {
@ -150,17 +150,6 @@
return label
}
const getInitials = nameLabel => {
if (!nameLabel) {
return "?"
}
return nameLabel
.split(" ")
.slice(0, 2)
.map(x => x[0])
.join("")
}
async function updateUserFirstName(evt) {
try {
await users.save({ ...user, firstName: evt.target.value })
@ -238,7 +227,7 @@
<div class="title">
<div class="user-info">
<Avatar size="XXL" {initials} />
<UserAvatar size="XXL" {user} showTooltip={false} />
<div class="subtitle">
<Heading size="M">{nameLabel}</Heading>
{#if nameLabel !== user?.email}

View file

@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store"
import { writable, derived, get } from "svelte/store"
import { queries, tables } from "./"
import { API } from "api"
@ -91,6 +91,39 @@ export function createDatasourcesStore() {
})
}
// Handles external updates of datasources
const replaceDatasource = (datasourceId, datasource) => {
if (!datasourceId) {
return
}
// Handle deletion
if (!datasource) {
store.update(state => ({
...state,
list: state.list.filter(x => x._id !== datasourceId),
}))
return
}
// Add new datasource
const index = get(store).list.findIndex(x => x._id === datasource._id)
if (index === -1) {
store.update(state => ({
...state,
list: [...state.list, datasource],
}))
}
// Update existing datasource
else if (datasource) {
store.update(state => {
state.list[index] = datasource
return state
})
}
}
return {
subscribe: derivedStore.subscribe,
fetch,
@ -100,6 +133,7 @@ export function createDatasourcesStore() {
save,
delete: deleteDatasource,
removeSchemaError,
replaceDatasource,
}
}

View file

@ -22,18 +22,6 @@ export function createTablesStore() {
}))
}
const fetchTable = async tableId => {
const table = await API.fetchTableDefinition(tableId)
store.update(state => {
const indexToUpdate = state.list.findIndex(t => t._id === table._id)
state.list[indexToUpdate] = table
return {
...state,
}
})
}
const select = tableId => {
store.update(state => ({
...state,
@ -74,20 +62,23 @@ export function createTablesStore() {
}
const savedTable = await API.saveTable(updatedTable)
await fetch()
replaceTable(table._id, savedTable)
if (table.type === "external") {
await datasources.fetch()
}
await select(savedTable._id)
select(savedTable._id)
return savedTable
}
const deleteTable = async table => {
if (!table?._id || !table?._rev) {
return
}
await API.deleteTable({
tableId: table?._id,
tableRev: table?._rev,
tableId: table._id,
tableRev: table._rev,
})
await fetch()
replaceTable(table._id, null)
}
const saveField = async ({
@ -135,35 +126,56 @@ export function createTablesStore() {
await save(draft)
}
const updateTable = table => {
const index = get(store).list.findIndex(x => x._id === table._id)
if (index === -1) {
// Handles external updates of tables
const replaceTable = (tableId, table) => {
if (!tableId) {
return
}
// This function has to merge state as there discrepancies with the table
// API endpoints. The table list endpoint and get table endpoint use the
// "type" property to mean different things.
store.update(state => {
state.list[index] = {
...table,
type: state.list[index].type,
}
return state
})
// Handle deletion
if (!table) {
store.update(state => ({
...state,
list: state.list.filter(x => x._id !== tableId),
}))
return
}
// Add new table
const index = get(store).list.findIndex(x => x._id === table._id)
if (index === -1) {
store.update(state => ({
...state,
list: [...state.list, table],
}))
}
// Update existing table
else if (table) {
// This function has to merge state as there discrepancies with the table
// API endpoints. The table list endpoint and get table endpoint use the
// "type" property to mean different things.
store.update(state => {
state.list[index] = {
...table,
type: state.list[index].type,
}
return state
})
}
}
return {
...store,
subscribe: derivedStore.subscribe,
fetch,
fetchTable,
init: fetch,
select,
save,
delete: deleteTable,
saveField,
deleteField,
updateTable,
replaceTable,
}
}

View file

@ -1,4 +1,4 @@
import { writable, get, derived } from "svelte/store"
import { writable, derived } from "svelte/store"
import { tables } from "./"
import { API } from "api"
@ -27,21 +27,31 @@ export function createViewsStore() {
const deleteView = async view => {
await API.deleteView(view)
await tables.fetch()
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table) {
delete table.views[view.name]
}
return { ...state }
})
}
const save = async view => {
const savedView = await API.saveView(view)
const viewMeta = {
name: view.name,
...savedView,
}
const viewTable = get(tables).list.find(table => table._id === view.tableId)
if (view.originalName) delete viewTable.views[view.originalName]
viewTable.views[view.name] = viewMeta
await tables.save(viewTable)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table) {
if (view.originalName) {
delete table.views[view.originalName]
}
table.views[view.name] = savedView
}
return { ...state }
})
}
return {

View file

@ -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>

View file

@ -16,7 +16,7 @@
const getStyle = (width, selectedUser) => {
let style = `flex: 0 0 ${width}px;`
if (selectedUser) {
style += `--cell-color:${selectedUser.color};`
style += `--user-color:${selectedUser.color};`
}
return style
}
@ -99,14 +99,15 @@
}
/* Cell border for cells with labels */
.cell.error:after,
.cell.selected-other:not(.focused):after {
.cell.error:after {
border-radius: 0 2px 2px 2px;
}
.cell.top.error:after,
.cell.top.selected-other:not(.focused):after {
.cell.top.error:after {
border-radius: 2px 2px 2px 0;
}
.cell.selected-other:not(.focused):after {
border-radius: 2px;
}
/* Cell z-index */
.cell.error,
@ -116,14 +117,8 @@
.cell.focused {
z-index: 2;
}
.cell.focused {
--cell-color: var(--spectrum-global-color-blue-400);
}
.cell.error {
--cell-color: var(--spectrum-global-color-red-500);
}
.cell.readonly {
--cell-color: var(--spectrum-global-color-gray-600);
.cell.selected-other:hover {
z-index: 2;
}
.cell:not(.focused) {
user-select: none;
@ -131,6 +126,21 @@
.cell:hover {
cursor: default;
}
/* Cell color overrides */
.cell.selected-other {
--cell-color: var(--user-color);
}
.cell.focused {
--cell-color: var(--spectrum-global-color-blue-400);
}
.cell.error {
--cell-color: var(--spectrum-global-color-red-500);
}
.cell.focused.readonly {
--cell-color: var(--spectrum-global-color-gray-600);
}
.cell.highlighted:not(.focused),
.cell.focused.readonly {
--cell-background: var(--cell-background-hover);
@ -146,7 +156,7 @@
left: 0;
padding: 1px 4px 3px 4px;
margin: 0 0 -2px 0;
background: var(--user-color);
background: var(--cell-color);
border-radius: 2px;
display: block;
color: white;
@ -160,11 +170,16 @@
.cell.top .label {
bottom: auto;
top: 100%;
border-radius: 0 2px 2px 2px;
padding: 2px 4px 2px 4px;
margin: -2px 0 0 0;
}
.error .label {
background: var(--spectrum-global-color-red-500);
}
.selected-other:not(.error) .label {
display: none;
}
.selected-other:not(.error):hover .label {
display: block;
}
</style>

View file

@ -52,7 +52,7 @@
{:else}
<div class="text-cell" class:number={type === "number"}>
<div class="value">
{value || ""}
{value ?? ""}
</div>
</div>
{/if}

View file

@ -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>

View file

@ -1,5 +1,5 @@
<script>
import { setContext } from "svelte"
import { setContext, onMount } from "svelte"
import { writable } from "svelte/store"
import { fade } from "svelte/transition"
import { clickOutside, ProgressCircle } from "@budibase/bbui"
@ -24,6 +24,7 @@
import RowHeightButton from "../controls/RowHeightButton.svelte"
import ColumnWidthButton from "../controls/ColumnWidthButton.svelte"
import NewRow from "./NewRow.svelte"
import { createGridWebsocket } from "../lib/websocket"
import {
MaxCellRenderHeight,
MaxCellRenderWidthOverflow,
@ -42,6 +43,8 @@
export let allowEditRows = true
export let allowDeleteRows = true
export let stripeRows = false
export let collaboration = true
export let showAvatars = true
// Unique identifier for DOM nodes inside this instance
const rand = Math.random()
@ -102,7 +105,11 @@
export const getContext = () => context
// Initialise websocket for multi-user
// onMount(() => createWebsocket(context))
onMount(() => {
if (collaboration) {
return createGridWebsocket(context)
}
})
</script>
<div
@ -124,7 +131,9 @@
<RowHeightButton />
</div>
<div class="controls-right">
<UserAvatars />
{#if showAvatars}
<UserAvatars />
{/if}
</div>
</div>
{#if $loaded}

View file

@ -1,13 +1,23 @@
<script>
import { getContext } from "svelte"
import Avatar from "./Avatar.svelte"
import UserAvatar from "../../UserAvatar.svelte"
const { users } = getContext("grid")
$: uniqueUsers = unique($users)
const unique = users => {
let uniqueUsers = {}
users?.forEach(user => {
uniqueUsers[user.email] = user
})
return Object.values(uniqueUsers)
}
</script>
<div class="users">
{#each $users as user}
<Avatar {user} />
{#each uniqueUsers as user}
<UserAvatar {user} />
{/each}
</div>
@ -15,6 +25,6 @@
.users {
display: flex;
flex-direction: row;
gap: 8px;
gap: 4px;
}
</style>

View file

@ -1,24 +1,9 @@
import { get } from "svelte/store"
import { io } from "socket.io-client"
import { createWebsocket } from "../../../utils"
export const createWebsocket = context => {
const { rows, tableId, users, userId, focusedCellId } = context
// Determine connection info
const tls = location.protocol === "https:"
const proto = tls ? "wss:" : "ws:"
const host = location.hostname
const port = location.port || (tls ? 443 : 80)
const socket = io(`${proto}//${host}:${port}`, {
path: "/socket/grid",
// Cap reconnection attempts to 3 (total of 15 seconds before giving up)
reconnectionAttempts: 3,
// Delay reconnection attempt by 5 seconds
reconnectionDelay: 5000,
reconnectionDelayMax: 5000,
// Timeout after 4 seconds so we never stack requests
timeout: 4000,
})
export const createGridWebsocket = context => {
const { rows, tableId, users, focusedCellId, table } = context
const socket = createWebsocket("/socket/grid")
const connectToTable = tableId => {
if (!socket.connected) {
@ -28,27 +13,42 @@ export const createWebsocket = context => {
socket.emit("select-table", tableId, response => {
// handle initial connection info
users.set(response.users)
userId.set(response.id)
})
}
// Event handlers
// Connection events
socket.on("connect", () => {
connectToTable(get(tableId))
})
socket.on("row-update", data => {
if (data.id) {
rows.actions.refreshRow(data.id)
}
socket.on("connect_error", err => {
console.log("Failed to connect to grid websocket:", err.message)
})
// User events
socket.on("user-update", user => {
users.actions.updateUser(user)
})
socket.on("user-disconnect", user => {
users.actions.removeUser(user)
})
socket.on("connect_error", err => {
console.log("Failed to connect to grid websocket:", err.message)
// Row events
socket.on("row-change", async data => {
if (data.id) {
rows.actions.replaceRow(data.id, data.row)
} else if (data.row.id) {
// Handle users table edge case
await rows.actions.refreshRow(data.row.id)
}
})
// Table events
socket.on("table-change", data => {
// Only update table if one exists. If the table was deleted then we don't
// want to know - let the builder navigate away
if (data.table) {
table.set(data.table)
}
})
// Change websocket connection when table changes

View file

@ -14,6 +14,7 @@
dispatch,
selectedRows,
config,
menu,
} = getContext("grid")
const ignoredOriginSelectors = [
@ -61,6 +62,7 @@
} else {
$focusedCellId = null
}
menu.actions.close()
return
} else if (e.key === "Tab") {
e.preventDefault()

View file

@ -46,7 +46,7 @@ export const createStores = () => {
}
export const deriveStores = context => {
const { table, columns, stickyColumn, API, dispatch } = context
const { table, columns, stickyColumn, API } = context
// Updates the tables primary display column
const changePrimaryDisplay = async column => {
@ -90,10 +90,6 @@ export const deriveStores = context => {
// Update local state
table.set(newTable)
// Broadcast event so that we can keep sync with external state
// (e.g. data section which maintains a list of table definitions)
dispatch("updatetable", newTable)
// Update server
await API.saveTable(newTable)
}
@ -116,10 +112,24 @@ export const initialise = context => {
const schema = derived(
[table, schemaOverrides],
([$table, $schemaOverrides]) => {
let newSchema = $table?.schema
if (!newSchema) {
if (!$table?.schema) {
return null
}
let newSchema = { ...$table?.schema }
// Edge case to temporarily allow deletion of duplicated user
// fields that were saved with the "disabled" flag set.
// By overriding the saved schema we ensure only overrides can
// set the disabled flag.
// TODO: remove in future
Object.keys(newSchema).forEach(field => {
newSchema[field] = {
...newSchema[field],
disabled: false,
}
})
// Apply schema overrides
Object.keys($schemaOverrides || {}).forEach(field => {
if (newSchema[field]) {
newSchema[field] = {
@ -160,7 +170,7 @@ export const initialise = context => {
fields
.map(field => ({
name: field,
label: $schema[field].name || field,
label: $schema[field].displayName || field,
schema: $schema[field],
width: $schema[field].width || DefaultColumnWidth,
visible: $schema[field].visible ?? true,

View file

@ -268,27 +268,25 @@ export const deriveStores = context => {
return res?.rows?.[0]
}
// Refreshes a specific row, handling updates, addition or deletion
const refreshRow = async id => {
// Fetch row from the server again
const newRow = await fetchRow(id)
// Replaces a row in state with the newly defined row, handling updates,
// addition and deletion
const replaceRow = (id, row) => {
// Get index of row to check if it exists
const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[id]
// Process as either an update, addition or deletion
if (newRow) {
if (row) {
if (index != null) {
// An existing row was updated
rows.update(state => {
state[index] = { ...newRow }
state[index] = { ...row }
return state
})
} else {
// A new row was created
handleNewRows([newRow])
handleNewRows([row])
}
} else if (index != null) {
// A row was removed
@ -296,6 +294,12 @@ export const deriveStores = context => {
}
}
// Refreshes a specific row
const refreshRow = async id => {
const row = await fetchRow(id)
replaceRow(id, row)
}
// Refreshes all data
const refreshData = () => {
get(fetch)?.getInitialData()
@ -341,10 +345,15 @@ export const deriveStores = context => {
const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] })
// Update state after a successful change
rows.update(state => {
state[index] = saved
return state.slice()
})
if (saved?._id) {
rows.update(state => {
state[index] = saved
return state.slice()
})
} else if (saved?.id) {
// Handle users table edge case
await refreshRow(saved.id)
}
rowChangeCache.update(state => {
delete state[rowId]
return state
@ -455,6 +464,7 @@ export const deriveStores = context => {
hasRow,
loadNextPage,
refreshRow,
replaceRow,
refreshData,
refreshTableDefinition,
},

View file

@ -1,95 +1,50 @@
import { writable, get, derived } from "svelte/store"
import { helpers } from "@budibase/shared-core"
export const createStores = () => {
const users = writable([])
const userId = writable(null)
// Enrich users with unique colours
const enrichedUsers = derived(
[users, userId],
([$users, $userId]) => {
return (
$users
.slice()
// Place current user first
.sort((a, b) => {
if (a.id === $userId) {
return -1
} else if (b.id === $userId) {
return 1
} else {
return 0
}
})
// Enrich users with colors
.map((user, idx) => {
// Generate random colour hue
let hue = 1
for (let i = 0; i < user.email.length && i < 5; i++) {
hue *= user.email.charCodeAt(i + 1)
hue /= 17
}
hue = hue % 360
const color =
idx === 0
? "var(--spectrum-global-color-blue-400)"
: `hsl(${hue}, 50%, 40%)`
// Generate friendly label
let label = user.email
if (user.firstName) {
label = user.firstName
if (user.lastName) {
label += ` ${user.lastName}`
}
}
return {
...user,
color,
label,
}
})
)
},
[]
)
const enrichedUsers = derived(users, $users => {
return $users.map(user => ({
...user,
color: helpers.getUserColor(user),
label: helpers.getUserLabel(user),
}))
})
return {
users: {
...users,
subscribe: enrichedUsers.subscribe,
},
userId,
}
}
export const deriveStores = context => {
const { users, userId } = context
const { users, focusedCellId } = context
// Generate a lookup map of cell ID to the user that has it selected, to make
// lookups inside cells extremely fast
const selectedCellMap = derived(
[users, userId],
([$enrichedUsers, $userId]) => {
[users, focusedCellId],
([$users, $focusedCellId]) => {
let map = {}
$enrichedUsers.forEach(user => {
if (user.focusedCellId && user.id !== $userId) {
$users.forEach(user => {
if (user.focusedCellId && user.focusedCellId !== $focusedCellId) {
map[user.focusedCellId] = user
}
})
return map
},
{}
}
)
const updateUser = user => {
const $users = get(users)
const index = $users.findIndex(x => x.id === user.id)
if (index === -1) {
if (!$users.some(x => x.sessionId === user.sessionId)) {
users.set([...$users, user])
} else {
users.update(state => {
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
@ -98,7 +53,7 @@ export const deriveStores = context => {
const removeUser = user => {
users.update(state => {
return state.filter(x => x.id !== user.id)
return state.filter(x => x.sessionId !== user.sessionId)
})
}

View file

@ -1,4 +1,5 @@
export { default as SplitPage } from "./SplitPage.svelte"
export { default as TestimonialPage } from "./TestimonialPage.svelte"
export { default as Testimonial } from "./Testimonial.svelte"
export { default as UserAvatar } from "./UserAvatar.svelte"
export { Grid } from "./grid"

View file

@ -3,3 +3,4 @@ export * as JSONUtils from "./json"
export * as CookieUtils from "./cookies"
export * as RoleUtils from "./roles"
export * as Utils from "./utils"
export { createWebsocket } from "./websocket"

View file

@ -0,0 +1,23 @@
import { io } from "socket.io-client"
export const createWebsocket = path => {
if (!path) {
throw "A websocket path must be provided"
}
// Determine connection info
const tls = location.protocol === "https:"
const proto = tls ? "wss:" : "ws:"
const host = location.hostname
const port = location.port || (tls ? 443 : 80)
return io(`${proto}//${host}:${port}`, {
path,
// Cap reconnection attempts to 3 (total of 15 seconds before giving up)
reconnectionAttempts: 3,
// Delay reconnection attempt by 5 seconds
reconnectionDelay: 5000,
reconnectionDelayMax: 5000,
// Timeout after 4 seconds so we never stack requests
timeout: 4000,
})
}

View file

@ -59,6 +59,7 @@
"@koa/router": "8.0.8",
"@sendgrid/mail": "7.1.1",
"@sentry/node": "6.17.7",
"@socket.io/redis-adapter": "^8.2.1",
"airtable": "0.10.1",
"arangojs": "7.2.0",
"aws-sdk": "2.1030.0",

View file

@ -29,7 +29,7 @@ import { USERS_TABLE_SCHEMA } from "../../constants"
import { buildDefaultDocs } from "../../db/defaultData/datasource_bb_default"
import { removeAppFromUserRoles } from "../../utilities/workerRequests"
import { stringToReadStream, isQsTrue } from "../../utilities"
import { getLocksById } from "../../utilities/redis"
import { getLocksById, doesUserHaveLock } from "../../utilities/redis"
import {
updateClientLibrary,
backupClientLibrary,
@ -227,6 +227,7 @@ export async function fetchAppPackage(ctx: UserCtx) {
screens,
layouts,
clientLibPath,
hasLock: await doesUserHaveLock(application.appId, ctx.user),
}
}

View file

@ -26,6 +26,7 @@ import {
DatasourcePlus,
} from "@budibase/types"
import sdk from "../../sdk"
import { builderSocket } from "../../websockets"
function getErrorTables(errors: any, errorType: string) {
return Object.entries(errors)
@ -296,6 +297,7 @@ export async function update(ctx: UserCtx<any, UpdateDatasourceResponse>) {
ctx.body = {
datasource: await sdk.datasources.removeSecretSingle(datasource),
}
builderSocket.emitDatasourceUpdate(ctx, datasource)
}
export async function save(
@ -338,6 +340,7 @@ export async function save(
response.error = schemaError
}
ctx.body = response
builderSocket.emitDatasourceUpdate(ctx, datasource)
}
async function destroyInternalTablesBySourceId(datasourceId: string) {
@ -397,6 +400,7 @@ export async function destroy(ctx: UserCtx) {
ctx.message = `Datasource deleted.`
ctx.status = 200
builderSocket.emitDatasourceDeletion(ctx, datasourceId)
}
export async function find(ctx: UserCtx) {

View file

@ -4,6 +4,7 @@ import * as external from "./external"
import { isExternalTable } from "../../../integrations/utils"
import { Ctx } from "@budibase/types"
import * as utils from "./utils"
import { gridSocket } from "../../../websockets"
function pickApi(tableId: any) {
if (isExternalTable(tableId)) {
@ -12,21 +13,9 @@ function pickApi(tableId: any) {
return internal
}
function getTableId(ctx: any) {
if (ctx.request.body && ctx.request.body.tableId) {
return ctx.request.body.tableId
}
if (ctx.params && ctx.params.tableId) {
return ctx.params.tableId
}
if (ctx.params && ctx.params.viewName) {
return ctx.params.viewName
}
}
export async function patch(ctx: any): Promise<any> {
const appId = ctx.appId
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
const body = ctx.request.body
// if it doesn't have an _id then its save
if (body && !body._id) {
@ -47,6 +36,7 @@ export async function patch(ctx: any): Promise<any> {
ctx.eventEmitter.emitRow(`row:update`, appId, row, table)
ctx.message = `${table.name} updated successfully.`
ctx.body = row
gridSocket?.emitRowUpdate(ctx, row)
} catch (err) {
ctx.throw(400, err)
}
@ -54,7 +44,7 @@ export async function patch(ctx: any): Promise<any> {
export const save = async (ctx: any) => {
const appId = ctx.appId
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
const body = ctx.request.body
// if it has an ID already then its a patch
if (body && body._id) {
@ -69,23 +59,24 @@ export const save = async (ctx: any) => {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table)
ctx.message = `${table.name} saved successfully`
ctx.body = row
gridSocket?.emitRowUpdate(ctx, row)
}
export async function fetchView(ctx: any) {
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(() => pickApi(tableId).fetchView(ctx), {
datasourceId: tableId,
})
}
export async function fetch(ctx: any) {
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(() => pickApi(tableId).fetch(ctx), {
datasourceId: tableId,
})
}
export async function find(ctx: any) {
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(() => pickApi(tableId).find(ctx), {
datasourceId: tableId,
})
@ -94,7 +85,7 @@ export async function find(ctx: any) {
export async function destroy(ctx: any) {
const appId = ctx.appId
const inputs = ctx.request.body
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
let response, row
if (inputs.rows) {
let { rows } = await quotas.addQuery(
@ -107,6 +98,7 @@ export async function destroy(ctx: any) {
response = rows
for (let row of rows) {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
gridSocket?.emitRowDeletion(ctx, row._id)
}
} else {
let resp = await quotas.addQuery(() => pickApi(tableId).destroy(ctx), {
@ -116,6 +108,7 @@ export async function destroy(ctx: any) {
response = resp.response
row = resp.row
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
gridSocket?.emitRowDeletion(ctx, row._id)
}
ctx.status = 200
// for automations include the row that was deleted
@ -124,7 +117,7 @@ export async function destroy(ctx: any) {
}
export async function search(ctx: any) {
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
ctx.status = 200
ctx.body = await quotas.addQuery(() => pickApi(tableId).search(ctx), {
datasourceId: tableId,
@ -132,7 +125,7 @@ export async function search(ctx: any) {
}
export async function validate(ctx: Ctx) {
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
// external tables are hard to validate currently
if (isExternalTable(tableId)) {
ctx.body = { valid: true }
@ -145,7 +138,7 @@ export async function validate(ctx: Ctx) {
}
export async function fetchEnrichedRow(ctx: any) {
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(
() => pickApi(tableId).fetchEnrichedRow(ctx),
{
@ -155,7 +148,7 @@ export async function fetchEnrichedRow(ctx: any) {
}
export const exportRows = async (ctx: any) => {
const tableId = getTableId(ctx)
const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(() => pickApi(tableId).exportRows(ctx), {
datasourceId: tableId,
})

View file

@ -154,3 +154,15 @@ export function cleanExportRows(
return cleanRows
}
export function getTableId(ctx: any) {
if (ctx.request.body && ctx.request.body.tableId) {
return ctx.request.body.tableId
}
if (ctx.params && ctx.params.tableId) {
return ctx.params.tableId
}
if (ctx.params && ctx.params.viewName) {
return ctx.params.viewName
}
}

View file

@ -11,6 +11,7 @@ import { context, events } from "@budibase/backend-core"
import { Table, UserCtx } from "@budibase/types"
import sdk from "../../../sdk"
import { jsonFromCsvString } from "../../../utilities/csv"
import { builderSocket } from "../../../websockets"
function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
if (table && !tableId) {
@ -77,6 +78,7 @@ export async function save(ctx: UserCtx) {
ctx.eventEmitter &&
ctx.eventEmitter.emitTable(`table:save`, appId, savedTable)
ctx.body = savedTable
builderSocket.emitTableUpdate(ctx, savedTable)
}
export async function destroy(ctx: UserCtx) {
@ -89,6 +91,7 @@ export async function destroy(ctx: UserCtx) {
ctx.status = 200
ctx.table = deletedTable
ctx.body = { message: `Table ${tableId} deleted.` }
builderSocket.emitTableDeletion(ctx, tableId)
}
export async function bulkImport(ctx: UserCtx) {

View file

@ -16,6 +16,7 @@ import {
View,
} from "@budibase/types"
import { cleanExportRows } from "../row/utils"
import { builderSocket } from "../../../websockets"
const { cloneDeep, isEqual } = require("lodash")
@ -48,7 +49,7 @@ export async function save(ctx: Ctx) {
if (!view.meta.schema) {
view.meta.schema = table.schema
}
table.views[viewName] = view.meta
table.views[viewName] = { ...view.meta, name: viewName }
if (originalName) {
delete table.views[originalName]
existingTable.views[viewName] = existingTable.views[originalName]
@ -56,10 +57,8 @@ export async function save(ctx: Ctx) {
await db.put(table)
await handleViewEvents(existingTable.views[viewName], table.views[viewName])
ctx.body = {
...table.views[viewToSave.name],
name: viewToSave.name,
}
ctx.body = table.views[viewName]
builderSocket.emitTableUpdate(ctx, table)
}
export async function calculationEvents(existingView: View, newView: View) {
@ -128,6 +127,7 @@ export async function destroy(ctx: Ctx) {
await events.view.deleted(view)
ctx.body = view
builderSocket.emitTableUpdate(ctx, table)
}
export async function exportView(ctx: Ctx) {

View file

@ -61,7 +61,6 @@ if (env.isProd()) {
const server = http.createServer(app.callback())
destroyable(server)
initialiseWebsockets(app, server)
let shuttingDown = false,
errCode = 0

View file

@ -35,12 +35,11 @@ async function checkDevAppLocks(ctx: BBContext) {
if (!appId || !appId.startsWith(APP_DEV_PREFIX)) {
return
}
if (!(await doesUserHaveLock(appId, ctx.user))) {
ctx.throw(400, "User does not hold app lock.")
}
// they do have lock, update it
await updateLock(appId, ctx.user)
// If this user already owns the lock, then update it
if (await doesUserHaveLock(appId, ctx.user)) {
await updateLock(appId, ctx.user)
}
}
async function updateAppUpdatedAt(ctx: BBContext) {

View file

@ -16,6 +16,7 @@ import * as bullboard from "./automations/bullboard"
import * as pro from "@budibase/pro"
import * as api from "./api"
import sdk from "./sdk"
import { initialise as initialiseWebsockets } from "./websockets"
let STARTUP_RAN = false
@ -64,6 +65,7 @@ export async function startup(app?: any, server?: any) {
fileSystem.init()
await redis.init()
eventInit()
initialiseWebsockets(app, server)
// run migrations on startup if not done via http
// not recommended in a clustered environment

View file

@ -4,23 +4,33 @@ import { ContextUser } from "@budibase/types"
const APP_DEV_LOCK_SECONDS = 600
const AUTOMATION_TEST_FLAG_SECONDS = 60
let devAppClient: any, debounceClient: any, flagClient: any
let devAppClient: any, debounceClient: any, flagClient: any, socketClient: any
// we init this as we want to keep the connection open all the time
// We need to maintain a duplicate client for socket.io pub/sub
let socketSubClient: any
// We init this as we want to keep the connection open all the time
// reduces the performance hit
export async function init() {
devAppClient = new redis.Client(redis.utils.Databases.DEV_LOCKS)
debounceClient = new redis.Client(redis.utils.Databases.DEBOUNCE)
flagClient = new redis.Client(redis.utils.Databases.FLAGS)
socketClient = new redis.Client(redis.utils.Databases.SOCKET_IO)
await devAppClient.init()
await debounceClient.init()
await flagClient.init()
await socketClient.init()
// Duplicate the socket client for pub/sub
socketSubClient = socketClient.getClient().duplicate()
}
export async function shutdown() {
if (devAppClient) await devAppClient.finish()
if (debounceClient) await debounceClient.finish()
if (flagClient) await flagClient.finish()
if (socketClient) await socketClient.finish()
if (socketSubClient) socketSubClient.disconnect()
// shutdown core clients
await redis.clients.shutdown()
console.log("Redis shutdown")
@ -86,3 +96,10 @@ export async function checkTestFlag(id: string) {
export async function clearTestFlag(id: string) {
await devAppClient.delete(id)
}
export function getSocketPubSubClients() {
return {
pub: socketClient.getClient(),
sub: socketSubClient,
}
}

View file

@ -0,0 +1,69 @@
import authorized from "../middleware/authorized"
import Socket from "./websocket"
import { permissions } from "@budibase/backend-core"
import http from "http"
import Koa from "koa"
import { Datasource, Table } from "@budibase/types"
import { gridSocket } from "./index"
import { clearLock } from "../utilities/redis"
export default class BuilderSocket extends Socket {
constructor(app: Koa, server: http.Server) {
super(app, server, "/socket/builder", [authorized(permissions.BUILDER)])
this.io.on("connection", socket => {
// Join a room for this app
const user = socket.data.user
const appId = socket.data.appId
socket.join(appId)
socket.to(appId).emit("user-update", user)
// Initial identification of connected spreadsheet
socket.on("get-users", async (payload, callback) => {
const sockets = await this.io.in(appId).fetchSockets()
callback({
users: sockets.map(socket => socket.data.user),
})
})
// Disconnection cleanup
socket.on("disconnect", async () => {
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
}
})
})
}
emitTableUpdate(ctx: any, table: Table) {
this.io.in(ctx.appId).emit("table-change", { id: table._id, table })
gridSocket.emitTableUpdate(table)
}
emitTableDeletion(ctx: any, id: string) {
this.io.in(ctx.appId).emit("table-change", { id, table: null })
gridSocket.emitTableDeletion(id)
}
emitDatasourceUpdate(ctx: any, datasource: Datasource) {
this.io
.in(ctx.appId)
.emit("datasource-change", { id: datasource._id, datasource })
}
emitDatasourceDeletion(ctx: any, id: string) {
this.io.in(ctx.appId).emit("datasource-change", { id, datasource: null })
}
}

View file

@ -3,6 +3,8 @@ import Socket from "./websocket"
import { permissions } from "@budibase/backend-core"
import http from "http"
import Koa from "koa"
import { getTableId } from "../api/controllers/row/utils"
import { Row, Table } from "@budibase/types"
export default class GridSocket extends Socket {
constructor(app: Koa, server: http.Server) {
@ -10,7 +12,6 @@ export default class GridSocket extends Socket {
this.io.on("connection", socket => {
const user = socket.data.user
console.log(`Spreadsheet user connected: ${user?.id}`)
// Socket state
let currentRoom: string
@ -19,37 +20,54 @@ export default class GridSocket extends Socket {
socket.on("select-table", async (tableId, callback) => {
// Leave current room
if (currentRoom) {
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
socket.to(currentRoom).emit("user-disconnect", user)
socket.leave(currentRoom)
}
// Join new room
currentRoom = tableId
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
const sockets = await this.io.in(currentRoom).fetchSockets()
callback({
users: sockets.map(socket => socket.data.user),
id: user.id,
})
})
// Handle users selecting a new cell
socket.on("select-cell", cellId => {
socket.data.user.selectedCellId = cellId
socket.data.user.focusedCellId = cellId
if (currentRoom) {
socket.to(currentRoom).emit("user-update", socket.data.user)
socket.to(currentRoom).emit("user-update", user)
}
})
// Disconnection cleanup
socket.on("disconnect", () => {
if (currentRoom) {
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
socket.to(currentRoom).emit("user-disconnect", user)
}
})
})
}
emitRowUpdate(ctx: any, row: Row) {
const tableId = getTableId(ctx)
this.io.in(tableId).emit("row-change", { id: row._id, row })
}
emitRowDeletion(ctx: any, id: string) {
const tableId = getTableId(ctx)
this.io.in(tableId).emit("row-change", { id, row: null })
}
emitTableUpdate(table: Table) {
this.io.in(table._id!).emit("table-change", { id: table._id, table })
}
emitTableDeletion(id: string) {
this.io.in(id).emit("table-change", { id, table: null })
}
}

View file

@ -1,14 +1,17 @@
import http from "http"
import Koa from "koa"
import GridSocket from "./grid"
import ClientAppSocket from "./client"
import GridSocket from "./grid"
import BuilderSocket from "./builder"
let clientAppSocket: ClientAppSocket
let gridSocket: GridSocket
let builderSocket: BuilderSocket
export const initialise = (app: Koa, server: http.Server) => {
clientAppSocket = new ClientAppSocket(app, server)
gridSocket = new GridSocket(app, server)
builderSocket = new BuilderSocket(app, server)
}
export { clientAppSocket, gridSocket }
export { clientAppSocket, gridSocket, builderSocket }

View file

@ -5,6 +5,9 @@ import Cookies from "cookies"
import { userAgent } from "koa-useragent"
import { auth } from "@budibase/backend-core"
import currentApp from "../middleware/currentapp"
import { createAdapter } from "@socket.io/redis-adapter"
import { getSocketPubSubClients } from "../utilities/redis"
import uuid from "uuid"
export default class Socket {
io: Server
@ -12,7 +15,7 @@ export default class Socket {
constructor(
app: Koa,
server: http.Server,
path: string,
path: string = "/",
additionalMiddlewares?: any[]
) {
this.io = new Server(server, {
@ -59,13 +62,21 @@ export default class Socket {
for (let [idx, middleware] of middlewares.entries()) {
await middleware(ctx, () => {
if (idx === middlewares.length - 1) {
// Middlewares are finished.
// Middlewares are finished
// Extract some data from our enriched koa context to persist
// as metadata for the socket
// Add user info, including a deterministic color and label
const { _id, email, firstName, lastName } = ctx.user
socket.data.user = {
id: ctx.user._id,
email: ctx.user.email,
_id,
email,
firstName,
lastName,
sessionId: uuid.v4(),
}
// Add app ID to help split sockets into rooms
socket.data.appId = ctx.appId
next()
}
})
@ -74,6 +85,11 @@ export default class Socket {
next(error)
}
})
// Instantiate redis adapter
const { pub, sub } = getSocketPubSubClients()
const opts = { key: `socket.io-${path}` }
this.io.adapter(createAdapter(pub, sub, opts))
}
// Emit an event to all sockets

View file

@ -1,3 +1,5 @@
import { User } from "@budibase/types"
/**
* Gets a key within an object. The key supports dot syntax for retrieving deep
* fields - e.g. "a.b.c".
@ -21,3 +23,60 @@ export const deepGet = (obj: { [x: string]: any }, key: string) => {
}
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
}
}

View file

@ -4610,6 +4610,15 @@
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
"@socket.io/redis-adapter@^8.2.1":
version "8.2.1"
resolved "https://registry.yarnpkg.com/@socket.io/redis-adapter/-/redis-adapter-8.2.1.tgz#36f75afc518d0e1fa4fa7c29e6d042f53ee7563b"
integrity sha512-6Dt7EZgGSBP0qvXeOKGx7NnSr2tPMbVDfDyL97zerZo+v69hMfL99skMCL3RKZlWVqLyRme2T0wcy3udHhtOsg==
dependencies:
debug "~4.3.1"
notepack.io "~3.0.1"
uid2 "1.0.0"
"@spectrum-css/accordion@3.0.24":
version "3.0.24"
resolved "https://registry.yarnpkg.com/@spectrum-css/accordion/-/accordion-3.0.24.tgz#f89066c120c57b0cfc9aba66d60c39fc1cf69f74"
@ -18643,6 +18652,11 @@ normalize-url@^6.0.1:
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
notepack.io@~3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/notepack.io/-/notepack.io-3.0.1.tgz#2c2c9de1bd4e64a79d34e33c413081302a0d4019"
integrity sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==
npm-bundled@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1"
@ -24804,6 +24818,11 @@ uid2@0.0.x:
resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.4.tgz#033f3b1d5d32505f5ce5f888b9f3b667123c0a44"
integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==
uid2@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/uid2/-/uid2-1.0.0.tgz#ef8d95a128d7c5c44defa1a3d052eecc17a06bfb"
integrity sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==
unbox-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"