Merge remote-tracking branch 'origin/develop' into feature/binding-v2-updates
This commit is contained in:
commit
cd95df629e
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.6.19-alpha.26",
|
||||
"version": "2.6.19-alpha.32",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/backend-core",
|
||||
|
|
|
@ -72,16 +72,12 @@ describe("writethrough", () => {
|
|||
writethrough.put({ ...current, value: 4 }),
|
||||
])
|
||||
|
||||
// with a lock, this will work
|
||||
const newRev = responses.map(x => x.rev).find(x => x !== current._rev)
|
||||
expect(newRev).toBeDefined()
|
||||
expect(responses.map(x => x.rev)).toEqual(
|
||||
expect.arrayContaining([current._rev, current._rev, newRev])
|
||||
)
|
||||
expectFunctionWasCalledTimesWith(
|
||||
mocks.alerts.logWarn,
|
||||
2,
|
||||
"Ignoring redlock conflict in write-through cache"
|
||||
)
|
||||
|
||||
const output = await db.get(current._id)
|
||||
expect(output.value).toBe(4)
|
||||
|
|
|
@ -96,6 +96,7 @@ if (!env.DISABLE_PINO_LOGGER) {
|
|||
|
||||
const mergingObject: any = {
|
||||
err: error,
|
||||
pid: process.pid,
|
||||
...contextObject,
|
||||
}
|
||||
|
||||
|
|
|
@ -4,10 +4,10 @@ import { LockOptions, LockType } from "@budibase/types"
|
|||
import * as context from "../context"
|
||||
import env from "../environment"
|
||||
|
||||
const getClient = async (
|
||||
async function getClient(
|
||||
type: LockType,
|
||||
opts?: Redlock.Options
|
||||
): Promise<Redlock> => {
|
||||
): Promise<Redlock> {
|
||||
if (type === LockType.CUSTOM) {
|
||||
return newRedlock(opts)
|
||||
}
|
||||
|
@ -18,6 +18,9 @@ const getClient = async (
|
|||
case LockType.TRY_ONCE: {
|
||||
return newRedlock(OPTIONS.TRY_ONCE)
|
||||
}
|
||||
case LockType.TRY_TWICE: {
|
||||
return newRedlock(OPTIONS.TRY_TWICE)
|
||||
}
|
||||
case LockType.DEFAULT: {
|
||||
return newRedlock(OPTIONS.DEFAULT)
|
||||
}
|
||||
|
@ -35,6 +38,9 @@ const OPTIONS = {
|
|||
// immediately throws an error if the lock is already held
|
||||
retryCount: 0,
|
||||
},
|
||||
TRY_TWICE: {
|
||||
retryCount: 1,
|
||||
},
|
||||
TEST: {
|
||||
// higher retry count in unit tests
|
||||
// due to high contention.
|
||||
|
@ -62,7 +68,7 @@ const OPTIONS = {
|
|||
},
|
||||
}
|
||||
|
||||
const newRedlock = async (opts: Redlock.Options = {}) => {
|
||||
export async function newRedlock(opts: Redlock.Options = {}) {
|
||||
let options = { ...OPTIONS.DEFAULT, ...opts }
|
||||
const redisWrapper = await getLockClient()
|
||||
const client = redisWrapper.getClient()
|
||||
|
@ -81,22 +87,26 @@ type RedlockExecution<T> =
|
|||
| SuccessfulRedlockExecution<T>
|
||||
| UnsuccessfulRedlockExecution
|
||||
|
||||
export const doWithLock = async <T>(
|
||||
function getLockName(opts: LockOptions) {
|
||||
// determine lock name
|
||||
// by default use the tenantId for uniqueness, unless using a system lock
|
||||
const prefix = opts.systemLock ? "system" : context.getTenantId()
|
||||
let name: string = `lock:${prefix}_${opts.name}`
|
||||
// add additional unique name if required
|
||||
if (opts.resource) {
|
||||
name = name + `_${opts.resource}`
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
export async function doWithLock<T>(
|
||||
opts: LockOptions,
|
||||
task: () => Promise<T>
|
||||
): Promise<RedlockExecution<T>> => {
|
||||
): Promise<RedlockExecution<T>> {
|
||||
const redlock = await getClient(opts.type, opts.customOptions)
|
||||
let lock
|
||||
try {
|
||||
// determine lock name
|
||||
// by default use the tenantId for uniqueness, unless using a system lock
|
||||
const prefix = opts.systemLock ? "system" : context.getTenantId()
|
||||
let name: string = `lock:${prefix}_${opts.name}`
|
||||
|
||||
// add additional unique name if required
|
||||
if (opts.resource) {
|
||||
name = name + `_${opts.resource}`
|
||||
}
|
||||
const name = getLockName(opts)
|
||||
|
||||
// create the lock
|
||||
lock = await redlock.lock(name, opts.ttl)
|
||||
|
@ -112,7 +122,6 @@ export const doWithLock = async <T>(
|
|||
if (opts.type === LockType.TRY_ONCE) {
|
||||
// don't throw for try-once locks, they will always error
|
||||
// due to retry count (0) exceeded
|
||||
console.warn(e)
|
||||
return { executed: false }
|
||||
} else {
|
||||
console.error(e)
|
||||
|
|
|
@ -27,6 +27,7 @@ export enum Databases {
|
|||
GENERIC_CACHE = "data_cache",
|
||||
WRITE_THROUGH = "writeThrough",
|
||||
LOCKS = "locks",
|
||||
SOCKET_IO = "socket_io",
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -165,7 +165,7 @@
|
|||
{/if}
|
||||
{#if !disabled}
|
||||
<div class="delete-button" on:click={removeFile}>
|
||||
<Icon name="Close" />
|
||||
<Icon name="Delete" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -209,7 +209,7 @@
|
|||
{/if}
|
||||
{#if !disabled}
|
||||
<div class="delete-button" on:click={removeFile}>
|
||||
<Icon name="Close" />
|
||||
<Icon name="Delete" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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: "",
|
||||
|
@ -71,6 +73,7 @@ const INITIAL_FRONTEND_STATE = {
|
|||
highlightedSettingKey: null,
|
||||
propertyFocus: null,
|
||||
builderSidePanel: false,
|
||||
hasLock: true,
|
||||
|
||||
// URL params
|
||||
selectedScreenId: null,
|
||||
|
@ -87,6 +90,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,
|
||||
|
@ -111,10 +115,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
|
||||
|
@ -138,6 +143,8 @@ export const getFrontendStore = () => {
|
|||
upgradableVersion: application.upgradableVersion,
|
||||
navigation: application.navigation || {},
|
||||
usedPlugins: application.usedPlugins || [],
|
||||
hasLock,
|
||||
initialised: true,
|
||||
}))
|
||||
screenHistoryStore.reset()
|
||||
automationHistoryStore.reset()
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
|
||||
// Datasource events
|
||||
socket.on("datasource-change", ({ id, datasource }) => {
|
||||
datasources.replaceDatasource(id, datasource)
|
||||
})
|
||||
|
||||
return {
|
||||
...socket,
|
||||
disconnect: () => {
|
||||
socket?.disconnect()
|
||||
userStore.actions.reset()
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -1,255 +0,0 @@
|
|||
<script>
|
||||
import {
|
||||
ModalContent,
|
||||
Modal,
|
||||
Body,
|
||||
Layout,
|
||||
Detail,
|
||||
Heading,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import ICONS from "../icons"
|
||||
import { API } from "api"
|
||||
import { IntegrationTypes, DatasourceTypes } from "constants/backend"
|
||||
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
|
||||
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
|
||||
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
|
||||
import { createRestDatasource } from "builderStore/datasource"
|
||||
import { goto } from "@roxi/routify"
|
||||
import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte"
|
||||
import DatasourceCard from "../_components/DatasourceCard.svelte"
|
||||
|
||||
export let modal
|
||||
let integrations = {}
|
||||
let integration = {}
|
||||
let internalTableModal
|
||||
let externalDatasourceModal
|
||||
let importModal
|
||||
|
||||
$: showImportButton = false
|
||||
$: customIntegrations = Object.entries(integrations).filter(
|
||||
entry => entry[1].custom
|
||||
)
|
||||
$: sortedIntegrations = sortIntegrations(integrations)
|
||||
|
||||
checkShowImport()
|
||||
|
||||
onMount(() => {
|
||||
fetchIntegrations()
|
||||
})
|
||||
|
||||
function selectIntegration(integrationType) {
|
||||
const selected = integrations[integrationType]
|
||||
|
||||
// build the schema
|
||||
const config = {}
|
||||
for (let key of Object.keys(selected.datasource)) {
|
||||
config[key] = selected.datasource[key].default
|
||||
}
|
||||
integration = {
|
||||
type: integrationType,
|
||||
plus: selected.plus,
|
||||
config,
|
||||
schema: selected.datasource,
|
||||
auth: selected.auth,
|
||||
features: selected.features || [],
|
||||
}
|
||||
if (selected.friendlyName) {
|
||||
integration.name = selected.friendlyName
|
||||
}
|
||||
checkShowImport()
|
||||
}
|
||||
|
||||
function checkShowImport() {
|
||||
showImportButton = integration.type === "REST"
|
||||
}
|
||||
|
||||
function showImportModal() {
|
||||
importModal.show()
|
||||
}
|
||||
|
||||
async function chooseNextModal() {
|
||||
if (integration.type === IntegrationTypes.INTERNAL) {
|
||||
externalDatasourceModal.hide()
|
||||
internalTableModal.show()
|
||||
} else if (integration.type === IntegrationTypes.REST) {
|
||||
try {
|
||||
// Skip modal for rest, create straight away
|
||||
const resp = await createRestDatasource(integration)
|
||||
$goto(`./datasource/${resp._id}`)
|
||||
} catch (error) {
|
||||
notifications.error("Error creating datasource")
|
||||
}
|
||||
} else {
|
||||
externalDatasourceModal.show()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchIntegrations() {
|
||||
let newIntegrations = {
|
||||
[IntegrationTypes.INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
|
||||
}
|
||||
try {
|
||||
const integrationList = await API.getIntegrations()
|
||||
newIntegrations = {
|
||||
...newIntegrations,
|
||||
...integrationList,
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.error("Error fetching integrations")
|
||||
}
|
||||
integrations = newIntegrations
|
||||
}
|
||||
|
||||
function sortIntegrations(integrations) {
|
||||
let integrationsArray = Object.entries(integrations)
|
||||
function getTypeOrder(schema) {
|
||||
if (schema.type === DatasourceTypes.API) {
|
||||
return 1
|
||||
}
|
||||
if (schema.type === DatasourceTypes.RELATIONAL) {
|
||||
return 2
|
||||
}
|
||||
return schema.type?.charCodeAt(0)
|
||||
}
|
||||
|
||||
integrationsArray.sort((a, b) => {
|
||||
let typeOrderA = getTypeOrder(a[1])
|
||||
let typeOrderB = getTypeOrder(b[1])
|
||||
if (typeOrderA === typeOrderB) {
|
||||
return a[1].friendlyName?.localeCompare(b[1].friendlyName)
|
||||
}
|
||||
return typeOrderA < typeOrderB ? -1 : 1
|
||||
})
|
||||
return integrationsArray
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={internalTableModal}>
|
||||
<CreateTableModal />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={externalDatasourceModal}>
|
||||
{#if integration?.auth?.type === "google"}
|
||||
<GoogleDatasourceConfigModal {integration} {modal} />
|
||||
{:else}
|
||||
<DatasourceConfigModal {integration} {modal} />
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={importModal}>
|
||||
{#if integration.type === "REST"}
|
||||
<ImportRestQueriesModal
|
||||
navigateDatasource={true}
|
||||
createDatasource={true}
|
||||
onCancel={() => modal.show()}
|
||||
/>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<ModalContent
|
||||
disabled={!Object.keys(integration).length}
|
||||
title="Add datasource"
|
||||
confirmText="Continue"
|
||||
showSecondaryButton={showImportButton}
|
||||
secondaryButtonText="Import"
|
||||
secondaryAction={() => showImportModal()}
|
||||
showCancelButton={false}
|
||||
size="M"
|
||||
onConfirm={() => {
|
||||
chooseNextModal()
|
||||
}}
|
||||
>
|
||||
<Layout noPadding gap="XS">
|
||||
<Body size="S">Get started with Budibase DB</Body>
|
||||
<div
|
||||
class:selected={integration.type === IntegrationTypes.INTERNAL}
|
||||
on:click={() => selectIntegration(IntegrationTypes.INTERNAL)}
|
||||
class="item hoverable"
|
||||
>
|
||||
<div class="item-body with-type">
|
||||
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
|
||||
<div class="text">
|
||||
<Heading size="XXS">Budibase DB</Heading>
|
||||
<Detail size="S" class="type">Non-relational</Detail>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<Layout noPadding gap="XS">
|
||||
<Body size="S">Connect to an external datasource</Body>
|
||||
<div class="item-list">
|
||||
{#each sortedIntegrations.filter(([key, val]) => key !== IntegrationTypes.INTERNAL && !val.custom) as [integrationType, schema]}
|
||||
<DatasourceCard
|
||||
on:selected={evt => selectIntegration(evt.detail)}
|
||||
{schema}
|
||||
bind:integrationType
|
||||
{integration}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
{#if customIntegrations.length > 0}
|
||||
<Layout noPadding gap="XS">
|
||||
<Body size="S">Custom datasource</Body>
|
||||
<div class="item-list">
|
||||
{#each customIntegrations as [integrationType, schema]}
|
||||
<DatasourceCard
|
||||
on:selected={evt => selectIntegration(evt.detail)}
|
||||
{schema}
|
||||
bind:integrationType
|
||||
{integration}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.item-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||||
grid-gap: var(--spectrum-alias-grid-baseline);
|
||||
}
|
||||
|
||||
.item {
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
|
||||
padding: var(--spectrum-alias-item-padding-s)
|
||||
var(--spectrum-alias-item-padding-m);
|
||||
background: var(--spectrum-alias-background-color-secondary);
|
||||
transition: background 0.13s ease-out;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
border-width: 2px;
|
||||
}
|
||||
.item:hover,
|
||||
.item.selected {
|
||||
background: var(--spectrum-alias-background-color-tertiary);
|
||||
}
|
||||
|
||||
.item-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.item-body.with-type {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.item-body.with-type :global(svg) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.text :global(.spectrum-Detail) {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
</style>
|
|
@ -11,7 +11,6 @@
|
|||
import { DatasourceFeature } from "@budibase/types"
|
||||
|
||||
export let integration
|
||||
export let modal
|
||||
|
||||
// kill the reference so the input isn't saved
|
||||
let datasource = cloneDeep(integration)
|
||||
|
@ -62,7 +61,6 @@
|
|||
<ModalContent
|
||||
title={`Connect to ${name}`}
|
||||
onConfirm={() => saveDatasource()}
|
||||
onCancel={() => modal.show()}
|
||||
confirmText={datasource.plus ? "Connect" : "Save and continue to query"}
|
||||
cancelText="Back"
|
||||
showSecondaryButton={datasource.plus}
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import { onMount } from "svelte"
|
||||
|
||||
export let integration
|
||||
export let modal
|
||||
|
||||
// kill the reference so the input isn't saved
|
||||
let datasource = cloneDeep(integration)
|
||||
|
@ -21,7 +20,6 @@
|
|||
|
||||
<ModalContent
|
||||
title={`Connect to ${IntegrationNames[datasource.type]}`}
|
||||
onCancel={() => modal.show()}
|
||||
cancelText="Back"
|
||||
size="L"
|
||||
>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import { API } from "api"
|
||||
import { parseFile } from "./utils"
|
||||
|
||||
let fileInput
|
||||
let error = null
|
||||
let fileName = null
|
||||
let fileType = null
|
||||
|
@ -16,6 +17,7 @@
|
|||
export let schema = {}
|
||||
export let allValid = true
|
||||
export let displayColumn = null
|
||||
export let promptUpload = false
|
||||
|
||||
const typeOptions = [
|
||||
{
|
||||
|
@ -99,10 +101,19 @@
|
|||
schema[name].type = e.detail
|
||||
schema[name].constraints = FIELDS[e.detail.toUpperCase()].constraints
|
||||
}
|
||||
|
||||
const openFileUpload = (promptUpload, fileInput) => {
|
||||
if (promptUpload && fileInput) {
|
||||
fileInput.click()
|
||||
}
|
||||
}
|
||||
|
||||
$: openFileUpload(promptUpload, fileInput)
|
||||
</script>
|
||||
|
||||
<div class="dropzone">
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
disabled={loading}
|
||||
id="file-upload"
|
||||
accept="text/csv,application/json"
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
? selectedSource._id
|
||||
: BUDIBASE_INTERNAL_DB_ID
|
||||
|
||||
export let promptUpload = false
|
||||
export let name
|
||||
export let beforeSave = async () => {}
|
||||
export let afterSave = async table => {
|
||||
|
@ -136,7 +137,13 @@
|
|||
<Label grey extraSmall
|
||||
>Create a Table from a CSV or JSON file (Optional)</Label
|
||||
>
|
||||
<TableDataImport bind:rows bind:schema bind:allValid bind:displayColumn />
|
||||
<TableDataImport
|
||||
{promptUpload}
|
||||
bind:rows
|
||||
bind:schema
|
||||
bind:allValid
|
||||
bind:displayColumn
|
||||
/>
|
||||
</Layout>
|
||||
</div>
|
||||
</ModalContent>
|
||||
|
|
|
@ -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>
|
|
@ -8,6 +8,7 @@
|
|||
faLock,
|
||||
faFileArrowUp,
|
||||
faChevronLeft,
|
||||
faCircleInfo,
|
||||
} from "@fortawesome/free-solid-svg-icons"
|
||||
import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons"
|
||||
|
||||
|
@ -20,7 +21,8 @@
|
|||
faDiscord,
|
||||
faEnvelope,
|
||||
faFileArrowUp,
|
||||
faChevronLeft
|
||||
faChevronLeft,
|
||||
faCircleInfo
|
||||
)
|
||||
dom.watch()
|
||||
</script>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -58,6 +58,7 @@
|
|||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
transition: width 130ms ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel.borderLeft {
|
||||
border-left: var(--border-light);
|
||||
|
|
|
@ -71,6 +71,9 @@
|
|||
tourStep.onComplete()
|
||||
}
|
||||
popover.hide()
|
||||
if (tourStep.endRoute) {
|
||||
$goto(tourStep.endRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -76,6 +76,7 @@ const getTours = () => {
|
|||
title: "Publish",
|
||||
layout: OnboardingPublish,
|
||||
route: "/builder/app/:application/design",
|
||||
endRoute: "/builder/app/:application/data",
|
||||
query: ".toprightnav #builder-app-publish-button",
|
||||
onLoad: () => {
|
||||
tourEvent(TOUR_STEP_KEYS.BUILDER_APP_PUBLISH)
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
<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 { UserAvatar } from "@budibase/frontend-core"
|
||||
|
||||
export let app
|
||||
|
||||
export let lockedAction
|
||||
|
||||
$: editing = app?.lockedBy != null
|
||||
|
||||
const handleDefaultClick = () => {
|
||||
if (window.innerWidth < 640) {
|
||||
goToOverview()
|
||||
|
@ -17,12 +18,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 +39,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 +57,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 +85,9 @@
|
|||
|
||||
.updated {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title,
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
<script>
|
||||
import { writable, get as svelteGet } from "svelte/store"
|
||||
import {
|
||||
notifications,
|
||||
Input,
|
||||
ModalContent,
|
||||
Dropzone,
|
||||
Toggle,
|
||||
} from "@budibase/bbui"
|
||||
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
|
||||
import { store, automationStore } from "builderStore"
|
||||
import { API } from "api"
|
||||
import { apps, admin, auth } from "stores/portal"
|
||||
|
@ -22,7 +16,6 @@
|
|||
|
||||
let creating = false
|
||||
let defaultAppName
|
||||
let includeSampleDB = true
|
||||
|
||||
const values = writable({ name: "", url: null })
|
||||
const validation = createValidationStore()
|
||||
|
@ -117,8 +110,6 @@
|
|||
data.append("templateName", template.name)
|
||||
data.append("templateKey", template.key)
|
||||
data.append("templateFile", $values.file)
|
||||
} else {
|
||||
data.append("sampleData", includeSampleDB)
|
||||
}
|
||||
|
||||
// Create App
|
||||
|
@ -213,15 +204,6 @@
|
|||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
{#if !template && !template?.fromFile}
|
||||
<span>
|
||||
<Toggle
|
||||
text="Include sample data"
|
||||
bind:value={includeSampleDB}
|
||||
disabled={creating}
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
</ModalContent>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
<script>
|
||||
import { Body, Label } from "@budibase/bbui"
|
||||
|
||||
export let title
|
||||
export let description
|
||||
export let disabled
|
||||
</script>
|
||||
|
||||
<div on:click class:disabled class="option">
|
||||
<div class="header">
|
||||
<div class="icon">
|
||||
<slot />
|
||||
</div>
|
||||
<Body>{title}</Body>
|
||||
</div>
|
||||
<Label>{description}</Label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.option {
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--grey-4);
|
||||
padding: 10px 16px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option :global(label) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background-color: var(--background-alt);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -1,21 +1,20 @@
|
|||
<script>
|
||||
import { Button, Layout } from "@budibase/bbui"
|
||||
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
|
||||
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
|
||||
import Panel from "components/design/Panel.svelte"
|
||||
|
||||
let modal
|
||||
import { isActive, goto } from "@roxi/routify"
|
||||
</script>
|
||||
|
||||
<!-- routify:options index=1 -->
|
||||
<div class="data">
|
||||
<Panel title="Sources" borderRight>
|
||||
<Layout paddingX="L" paddingY="XL" gap="S">
|
||||
<Button cta on:click={modal.show}>Add source</Button>
|
||||
<CreateDatasourceModal bind:modal />
|
||||
<DatasourceNavigator />
|
||||
</Layout>
|
||||
</Panel>
|
||||
{#if !$isActive("./new")}
|
||||
<Panel title="Sources" borderRight>
|
||||
<Layout paddingX="L" paddingY="XL" gap="S">
|
||||
<Button cta on:click={() => $goto("./new")}>Add source</Button>
|
||||
<DatasourceNavigator />
|
||||
</Layout>
|
||||
</Panel>
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
<slot />
|
||||
|
|
|
@ -1,22 +1,17 @@
|
|||
<script>
|
||||
import { redirect } from "@roxi/routify"
|
||||
import { onMount } from "svelte"
|
||||
import { admin } from "stores/portal"
|
||||
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
|
||||
import { datasources } from "stores/backend"
|
||||
|
||||
let modal
|
||||
$: setupComplete =
|
||||
$: hasData =
|
||||
$datasources.list.find(x => (x._id = "bb_internal"))?.entities?.length >
|
||||
1 || $datasources.list.length > 1
|
||||
|
||||
onMount(() => {
|
||||
if (!setupComplete && !$admin.isDev) {
|
||||
modal.show()
|
||||
if (!hasData) {
|
||||
$redirect("./new")
|
||||
} else {
|
||||
$redirect("./table")
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<CreateDatasourceModal bind:modal />
|
||||
|
|
|
@ -0,0 +1,257 @@
|
|||
<script>
|
||||
import { API } from "api"
|
||||
import { tables, datasources } from "stores/backend"
|
||||
|
||||
import { Icon, Modal, notifications, Heading, Body } from "@budibase/bbui"
|
||||
import { params, goto } from "@roxi/routify"
|
||||
import {
|
||||
IntegrationTypes,
|
||||
DatasourceTypes,
|
||||
DEFAULT_BB_DATASOURCE_ID,
|
||||
} from "constants/backend"
|
||||
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
|
||||
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
|
||||
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
|
||||
import { createRestDatasource } from "builderStore/datasource"
|
||||
import DatasourceOption from "./_DatasourceOption.svelte"
|
||||
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
|
||||
import ICONS from "components/backend/DatasourceNavigator/icons/index.js"
|
||||
import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte"
|
||||
|
||||
let internalTableModal
|
||||
let externalDatasourceModal
|
||||
let integrations = []
|
||||
let integration = null
|
||||
let disabled = false
|
||||
let promptUpload = false
|
||||
|
||||
$: hasData = $datasources.list.length > 1 || $tables.list.length > 1
|
||||
$: hasDefaultData =
|
||||
$datasources.list.findIndex(
|
||||
datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID
|
||||
) !== -1
|
||||
|
||||
const createSampleData = async () => {
|
||||
disabled = true
|
||||
|
||||
try {
|
||||
await API.addSampleData($params.application)
|
||||
await tables.fetch()
|
||||
await datasources.fetch()
|
||||
$goto("./table")
|
||||
} catch (e) {
|
||||
disabled = false
|
||||
notifications.error("Error creating datasource")
|
||||
}
|
||||
}
|
||||
|
||||
const handleIntegrationSelect = integrationType => {
|
||||
const selected = integrations.find(([type]) => type === integrationType)[1]
|
||||
|
||||
// build the schema
|
||||
const config = {}
|
||||
|
||||
for (let key of Object.keys(selected.datasource)) {
|
||||
config[key] = selected.datasource[key].default
|
||||
}
|
||||
|
||||
integration = {
|
||||
type: integrationType,
|
||||
plus: selected.plus,
|
||||
config,
|
||||
schema: selected.datasource,
|
||||
auth: selected.auth,
|
||||
features: selected.features || [],
|
||||
}
|
||||
|
||||
if (selected.friendlyName) {
|
||||
integration.name = selected.friendlyName
|
||||
}
|
||||
|
||||
if (integration.type === IntegrationTypes.REST) {
|
||||
disabled = true
|
||||
|
||||
// Skip modal for rest, create straight away
|
||||
createRestDatasource(integration)
|
||||
.then(response => {
|
||||
$goto(`./datasource/${response._id}`)
|
||||
})
|
||||
.catch(() => {
|
||||
disabled = false
|
||||
notifications.error("Error creating datasource")
|
||||
})
|
||||
} else {
|
||||
externalDatasourceModal.show()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInternalTable = () => {
|
||||
promptUpload = false
|
||||
internalTableModal.show()
|
||||
}
|
||||
|
||||
const handleDataImport = () => {
|
||||
promptUpload = true
|
||||
internalTableModal.show()
|
||||
}
|
||||
|
||||
const handleInternalTableSave = table => {
|
||||
notifications.success(`Table created successfully.`)
|
||||
$goto(`./table/${table._id}`)
|
||||
}
|
||||
|
||||
function sortIntegrations(integrations) {
|
||||
let integrationsArray = Object.entries(integrations)
|
||||
|
||||
function getTypeOrder(schema) {
|
||||
if (schema.type === DatasourceTypes.API) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (schema.type === DatasourceTypes.RELATIONAL) {
|
||||
return 2
|
||||
}
|
||||
|
||||
return schema.type?.charCodeAt(0)
|
||||
}
|
||||
|
||||
integrationsArray.sort((a, b) => {
|
||||
let typeOrderA = getTypeOrder(a[1])
|
||||
let typeOrderB = getTypeOrder(b[1])
|
||||
|
||||
if (typeOrderA === typeOrderB) {
|
||||
return a[1].friendlyName?.localeCompare(b[1].friendlyName)
|
||||
}
|
||||
|
||||
return typeOrderA < typeOrderB ? -1 : 1
|
||||
})
|
||||
|
||||
return integrationsArray
|
||||
}
|
||||
|
||||
const fetchIntegrations = async () => {
|
||||
const unsortedIntegrations = await API.getIntegrations()
|
||||
integrations = sortIntegrations(unsortedIntegrations)
|
||||
}
|
||||
|
||||
$: fetchIntegrations()
|
||||
</script>
|
||||
|
||||
<Modal bind:this={internalTableModal}>
|
||||
<CreateTableModal {promptUpload} afterSave={handleInternalTableSave} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={externalDatasourceModal}>
|
||||
{#if integration?.auth?.type === "google"}
|
||||
<GoogleDatasourceConfigModal {integration} />
|
||||
{:else}
|
||||
<DatasourceConfigModal {integration} />
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<div class="page">
|
||||
<div class="closeButton">
|
||||
{#if hasData}
|
||||
<Icon hoverable name="Close" on:click={$goto("./table")} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="heading">
|
||||
<Heading weight="light">Add new data source</Heading>
|
||||
</div>
|
||||
|
||||
<div class="subHeading">
|
||||
<Body>Get started with our Budibase DB</Body>
|
||||
<div
|
||||
role="tooltip"
|
||||
title="Budibase DB is built with CouchDB"
|
||||
class="tooltip"
|
||||
>
|
||||
<FontAwesomeIcon name="fa-solid fa-circle-info" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
<DatasourceOption
|
||||
on:click={handleInternalTable}
|
||||
title="Create new table"
|
||||
description="Non-relational"
|
||||
{disabled}
|
||||
>
|
||||
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
|
||||
</DatasourceOption>
|
||||
<DatasourceOption
|
||||
on:click={createSampleData}
|
||||
title="Use sample data"
|
||||
description="Non-relational"
|
||||
disabled={disabled || hasDefaultData}
|
||||
>
|
||||
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
|
||||
</DatasourceOption>
|
||||
<DatasourceOption
|
||||
on:click={handleDataImport}
|
||||
title="Upload data"
|
||||
description="Non-relational"
|
||||
{disabled}
|
||||
>
|
||||
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
|
||||
</DatasourceOption>
|
||||
</div>
|
||||
|
||||
<div class="subHeading">
|
||||
<Body>Or connect to an external datasource</Body>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
{#each integrations as [key, value]}
|
||||
<DatasourceOption
|
||||
on:click={() => handleIntegrationSelect(key)}
|
||||
title={value.friendlyName}
|
||||
description={value.type}
|
||||
{disabled}
|
||||
>
|
||||
<IntegrationIcon integrationType={key} schema={value} />
|
||||
</DatasourceOption>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
height: 38px;
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.subHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.options {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
column-gap: 24px;
|
||||
row-gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, 235px);
|
||||
justify-content: center;
|
||||
margin-bottom: 48px;
|
||||
max-width: 1050px;
|
||||
}
|
||||
</style>
|
|
@ -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 />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()}>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -355,7 +355,6 @@
|
|||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-wrapper {
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
<script>
|
||||
import PanelHeader from "./PanelHeader.svelte"
|
||||
export let onBack = () => {}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<PanelHeader
|
||||
title="Give it some data"
|
||||
subtitle="Not ready to add yours? Get started with sample data!"
|
||||
{onBack}
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
|
@ -1,120 +0,0 @@
|
|||
<script>
|
||||
import { Button, FancyForm, FancyInput, FancyCheckbox } from "@budibase/bbui"
|
||||
import GoogleButton from "components/backend/DatasourceNavigator/_components/GoogleButton.svelte"
|
||||
import { capitalise } from "helpers/helpers"
|
||||
import PanelHeader from "./PanelHeader.svelte"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
export let title = ""
|
||||
export let onBack = null
|
||||
export let onNext = () => {}
|
||||
export let fields = {}
|
||||
export let type = ""
|
||||
|
||||
let errors = {}
|
||||
|
||||
const formatName = name => {
|
||||
if (name === "ca") {
|
||||
return "CA"
|
||||
}
|
||||
|
||||
if (name === "ssl") {
|
||||
return "SSL"
|
||||
}
|
||||
|
||||
if (name === "rejectUnauthorized") {
|
||||
return "Reject Unauthorized"
|
||||
}
|
||||
|
||||
return capitalise(name)
|
||||
}
|
||||
|
||||
const getDefaultValues = fields => {
|
||||
const newValues = {}
|
||||
|
||||
Object.entries(fields).forEach(([name, { default: defaultValue }]) => {
|
||||
if (defaultValue) {
|
||||
newValues[name] = defaultValue
|
||||
}
|
||||
})
|
||||
|
||||
return newValues
|
||||
}
|
||||
|
||||
const values = getDefaultValues(fields)
|
||||
|
||||
const validateRequired = value => {
|
||||
if (value.length < 1) {
|
||||
return "Required field"
|
||||
}
|
||||
}
|
||||
|
||||
const getIsValid = (fields, errors, values) => {
|
||||
for (const [name, { required }] of Object.entries(fields)) {
|
||||
if (required && !values[name]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(errors).every(error => !error)
|
||||
}
|
||||
|
||||
$: isValid = getIsValid(fields, errors, values)
|
||||
$: isGoogle = helpers.isGoogleSheets(type)
|
||||
|
||||
const handleNext = async () => {
|
||||
const parsedValues = {}
|
||||
|
||||
Object.entries(values).forEach(([name, value]) => {
|
||||
if (fields[name].type === "number") {
|
||||
parsedValues[name] = parseInt(value, 10)
|
||||
} else {
|
||||
parsedValues[name] = value
|
||||
}
|
||||
})
|
||||
|
||||
if (isGoogle) {
|
||||
parsedValues.isGoogle = isGoogle
|
||||
}
|
||||
return await onNext(parsedValues)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<PanelHeader
|
||||
{title}
|
||||
subtitle="Fill in the required fields to fetch your tables"
|
||||
{onBack}
|
||||
/>
|
||||
<div class="form">
|
||||
<FancyForm>
|
||||
{#each Object.entries(fields) as [name, { type, default: defaultValue, required }]}
|
||||
{#if type !== "boolean"}
|
||||
<FancyInput
|
||||
bind:value={values[name]}
|
||||
bind:error={errors[name]}
|
||||
validate={required ? validateRequired : () => {}}
|
||||
label={formatName(name)}
|
||||
{type}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.entries(fields) as [name, { type, default: defaultValue, required }]}
|
||||
{#if type === "boolean"}
|
||||
<FancyCheckbox bind:value={values[name]} text={formatName(name)} />
|
||||
{/if}
|
||||
{/each}
|
||||
</FancyForm>
|
||||
</div>
|
||||
{#if isGoogle}
|
||||
<GoogleButton disabled={!isValid} preAuthStep={handleNext} samePage />
|
||||
{:else}
|
||||
<Button cta disabled={!isValid} on:click={handleNext}>Connect</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.form {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
</style>
|
|
@ -1,6 +1,5 @@
|
|||
<script>
|
||||
export let name = ""
|
||||
export let showData = false
|
||||
|
||||
const rows = [
|
||||
{
|
||||
|
@ -49,7 +48,7 @@
|
|||
<h1>{name}</h1>
|
||||
</div>
|
||||
<div class="nav">Home</div>
|
||||
<table class={`table ${showData ? "tableVisible" : ""}`}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>FIRST NAME</th>
|
||||
|
@ -71,7 +70,7 @@
|
|||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class={`sidePanel ${showData ? "sidePanelVisible" : ""}`}>
|
||||
<div class="sidePanel">
|
||||
<h2>{rows[0].firstName}</h2>
|
||||
<div class="field">
|
||||
<label for="exampleLastName">lastName</label>
|
||||
|
@ -199,14 +198,6 @@
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.table {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tableVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidePanel {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
|
@ -216,9 +207,6 @@
|
|||
top: 0;
|
||||
right: -364px;
|
||||
padding: 42px 32px;
|
||||
}
|
||||
|
||||
.sidePanelVisible {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import PanelHeader from "./PanelHeader.svelte"
|
||||
import { APP_URL_REGEX } from "constants"
|
||||
|
||||
export let disabled
|
||||
export let name = ""
|
||||
export let url = ""
|
||||
export let onNext = () => {}
|
||||
|
@ -71,7 +72,9 @@
|
|||
{:else}
|
||||
<p></p>
|
||||
{/if}
|
||||
<Button size="L" cta disabled={!isValid} on:click={onNext}>Lets go!</Button>
|
||||
<Button size="L" cta disabled={!isValid || disabled} on:click={onNext}
|
||||
>Lets go!</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -1,102 +1,50 @@
|
|||
<script>
|
||||
import { goto } from "@roxi/routify"
|
||||
import NamePanel from "./_components/NamePanel.svelte"
|
||||
import DataPanel from "./_components/DataPanel.svelte"
|
||||
import DatasourceConfigPanel from "./_components/DatasourceConfigPanel.svelte"
|
||||
import ExampleApp from "./_components/ExampleApp.svelte"
|
||||
import { FancyButton, notifications, Modal, Body } from "@budibase/bbui"
|
||||
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
|
||||
import { notifications } from "@budibase/bbui"
|
||||
import { SplitPage } from "@budibase/frontend-core"
|
||||
import { API } from "api"
|
||||
import { store, automationStore } from "builderStore"
|
||||
import { saveDatasource } from "builderStore/datasource"
|
||||
import { integrations } from "stores/backend"
|
||||
import { auth, admin, organisation } from "stores/portal"
|
||||
import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte"
|
||||
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
|
||||
import { auth, admin } from "stores/portal"
|
||||
import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen"
|
||||
import { Roles } from "constants/backend"
|
||||
import Spinner from "components/common/Spinner.svelte"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
import { validateDatasourceConfig } from "builderStore/datasource"
|
||||
import { DatasourceFeature } from "@budibase/types"
|
||||
|
||||
let name = "My first app"
|
||||
let url = "my-first-app"
|
||||
let stage = "name"
|
||||
let appId = null
|
||||
|
||||
let plusIntegrations = {}
|
||||
let integrationsLoading = true
|
||||
let creationLoading = false
|
||||
let uploadModal
|
||||
let googleComplete = false
|
||||
let loading = false
|
||||
|
||||
$: getIntegrations()
|
||||
const createApp = async () => {
|
||||
loading = true
|
||||
|
||||
const createApp = async useSampleData => {
|
||||
creationLoading = true
|
||||
// Create form data to create app
|
||||
// This is form based and not JSON
|
||||
try {
|
||||
let data = new FormData()
|
||||
data.append("name", name.trim())
|
||||
data.append("url", url.trim())
|
||||
data.append("useTemplate", false)
|
||||
let data = new FormData()
|
||||
data.append("name", name.trim())
|
||||
data.append("url", url.trim())
|
||||
data.append("useTemplate", false)
|
||||
|
||||
if (useSampleData) {
|
||||
data.append("sampleData", true)
|
||||
}
|
||||
const createdApp = await API.createApp(data)
|
||||
|
||||
const createdApp = await API.createApp(data)
|
||||
// Select Correct Application/DB in prep for creating user
|
||||
const pkg = await API.fetchAppPackage(createdApp.instance._id)
|
||||
await store.actions.initialise(pkg)
|
||||
await automationStore.actions.fetch()
|
||||
// Update checklist - in case first app
|
||||
await admin.init()
|
||||
|
||||
// Select Correct Application/DB in prep for creating user
|
||||
const pkg = await API.fetchAppPackage(createdApp.instance._id)
|
||||
await store.actions.initialise(pkg)
|
||||
await automationStore.actions.fetch()
|
||||
// Update checklist - in case first app
|
||||
await admin.init()
|
||||
// Create user
|
||||
await auth.setInitInfo({})
|
||||
|
||||
// Create user
|
||||
await auth.setInitInfo({})
|
||||
let defaultScreenTemplate = createFromScratchScreen.create()
|
||||
defaultScreenTemplate.routing.route = "/home"
|
||||
defaultScreenTemplate.routing.roldId = Roles.BASIC
|
||||
await store.actions.screens.save(defaultScreenTemplate)
|
||||
|
||||
let defaultScreenTemplate = createFromScratchScreen.create()
|
||||
defaultScreenTemplate.routing.route = "/home"
|
||||
defaultScreenTemplate.routing.roldId = Roles.BASIC
|
||||
await store.actions.screens.save(defaultScreenTemplate)
|
||||
|
||||
appId = createdApp.instance._id
|
||||
return createdApp
|
||||
} catch (e) {
|
||||
creationLoading = false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const getIntegrations = async () => {
|
||||
try {
|
||||
await integrations.init()
|
||||
const newPlusIntegrations = {}
|
||||
|
||||
Object.entries($integrations).forEach(([integrationType, schema]) => {
|
||||
// google sheets not available in self-host
|
||||
if (
|
||||
helpers.isGoogleSheets(integrationType) &&
|
||||
!$organisation.googleDatasourceConfigured
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (schema?.plus) {
|
||||
newPlusIntegrations[integrationType] = schema
|
||||
}
|
||||
})
|
||||
|
||||
plusIntegrations = newPlusIntegrations
|
||||
} catch (e) {
|
||||
notifications.error("There was a problem communicating with the server.")
|
||||
} finally {
|
||||
integrationsLoading = false
|
||||
}
|
||||
appId = createdApp.instance._id
|
||||
return createdApp
|
||||
}
|
||||
|
||||
const goToApp = () => {
|
||||
|
@ -104,152 +52,23 @@
|
|||
notifications.success(`App created successfully`)
|
||||
}
|
||||
|
||||
const handleCreateApp = async ({
|
||||
datasourceConfig,
|
||||
useSampleData,
|
||||
isGoogle,
|
||||
}) => {
|
||||
let app
|
||||
|
||||
const handleCreateApp = async () => {
|
||||
try {
|
||||
if (
|
||||
datasourceConfig &&
|
||||
plusIntegrations[stage].features[DatasourceFeature.CONNECTION_CHECKING]
|
||||
) {
|
||||
const resp = await validateDatasourceConfig({
|
||||
config: datasourceConfig,
|
||||
type: stage,
|
||||
})
|
||||
if (!resp.connected) {
|
||||
notifications.error(
|
||||
`Unable to connect - ${resp.error ?? "Error validating datasource"}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
await createApp()
|
||||
|
||||
app = await createApp(useSampleData)
|
||||
|
||||
let datasource
|
||||
if (datasourceConfig) {
|
||||
datasource = await saveDatasource({
|
||||
plus: true,
|
||||
auth: undefined,
|
||||
name: plusIntegrations[stage].friendlyName,
|
||||
schema: plusIntegrations[stage].datasource,
|
||||
config: datasourceConfig,
|
||||
type: stage,
|
||||
})
|
||||
}
|
||||
|
||||
store.set()
|
||||
|
||||
if (isGoogle) {
|
||||
googleComplete = true
|
||||
return { datasource, appId: app.appId }
|
||||
} else {
|
||||
goToApp()
|
||||
}
|
||||
goToApp()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
creationLoading = false
|
||||
loading = false
|
||||
notifications.error("There was a problem creating your app")
|
||||
|
||||
// Reset the store so that we don't send up stale headers
|
||||
store.actions.reset()
|
||||
|
||||
// If we successfully created an app, delete it again so that we
|
||||
// can try again once the error has been corrected.
|
||||
// This also ensures onboarding can't be skipped by entering invalid
|
||||
// data credentials.
|
||||
if (app?.appId) {
|
||||
await API.deleteApp(app.appId)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={uploadModal}>
|
||||
<CreateTableModal
|
||||
name="Your Data"
|
||||
beforeSave={createApp}
|
||||
afterSave={goToApp}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<div class="full-width">
|
||||
<SplitPage>
|
||||
{#if stage === "name"}
|
||||
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
|
||||
{:else if googleComplete}
|
||||
<div class="centered">
|
||||
<Body
|
||||
>Please login to your Google account in the new tab which as opened to
|
||||
continue.</Body
|
||||
>
|
||||
</div>
|
||||
{:else if integrationsLoading || creationLoading}
|
||||
<div class="centered">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if stage === "data"}
|
||||
<DataPanel onBack={() => (stage = "name")}>
|
||||
<div class="dataButton">
|
||||
<FancyButton
|
||||
on:click={() => handleCreateApp({ useSampleData: true })}
|
||||
>
|
||||
<div class="dataButtonContent">
|
||||
<div class="dataButtonIcon">
|
||||
<img
|
||||
alt="Budibase Logo"
|
||||
class="budibaseLogo"
|
||||
src={"https://i.imgur.com/Xhdt1YP.png"}
|
||||
/>
|
||||
</div>
|
||||
Budibase Sample data
|
||||
</div>
|
||||
</FancyButton>
|
||||
</div>
|
||||
<div class="dataButton">
|
||||
<FancyButton on:click={uploadModal.show}>
|
||||
<div class="dataButtonContent">
|
||||
<div class="dataButtonIcon">
|
||||
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
|
||||
</div>
|
||||
Upload data (CSV or JSON)
|
||||
</div>
|
||||
</FancyButton>
|
||||
</div>
|
||||
{#each Object.entries(plusIntegrations) as [integrationType, schema]}
|
||||
<div class="dataButton">
|
||||
<FancyButton on:click={() => (stage = integrationType)}>
|
||||
<div class="dataButtonContent">
|
||||
<div class="dataButtonIcon">
|
||||
<IntegrationIcon {integrationType} {schema} />
|
||||
</div>
|
||||
{schema.friendlyName}
|
||||
</div>
|
||||
</FancyButton>
|
||||
</div>
|
||||
{/each}
|
||||
</DataPanel>
|
||||
{:else if stage in plusIntegrations}
|
||||
<DatasourceConfigPanel
|
||||
title={plusIntegrations[stage].friendlyName}
|
||||
fields={plusIntegrations[stage].datasource}
|
||||
type={stage}
|
||||
onBack={() => (stage = "data")}
|
||||
onNext={data => {
|
||||
const isGoogle = data.isGoogle
|
||||
delete data.isGoogle
|
||||
return handleCreateApp({ datasourceConfig: data, isGoogle })
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<p>There was an problem. Please refresh the page and try again.</p>
|
||||
{/if}
|
||||
<NamePanel bind:name bind:url disabled={loading} onNext={handleCreateApp} />
|
||||
<div slot="right">
|
||||
<ExampleApp {name} showData={stage !== "name"} />
|
||||
<ExampleApp {name} />
|
||||
</div>
|
||||
</SplitPage>
|
||||
</div>
|
||||
|
@ -258,35 +77,4 @@
|
|||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
.centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.dataButton {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.dataButtonContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.budibaseLogo {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dataButtonIcon {
|
||||
width: 22px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.dataButtonContent :global(svg) {
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,21 @@ export function createTablesStore() {
|
|||
}
|
||||
|
||||
const savedTable = await API.saveTable(updatedTable)
|
||||
await fetch()
|
||||
if (table.type === "external") {
|
||||
await datasources.fetch()
|
||||
}
|
||||
await select(savedTable._id)
|
||||
replaceTable(table._id, savedTable)
|
||||
await datasources.fetch()
|
||||
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 +124,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -41,7 +41,6 @@
|
|||
"sanitize-html": "^2.7.0",
|
||||
"screenfull": "^6.0.1",
|
||||
"shortid": "^2.2.15",
|
||||
"socket.io-client": "^4.5.1",
|
||||
"svelte": "^3.49.0",
|
||||
"svelte-apexcharts": "^1.0.2",
|
||||
"svelte-flatpickr": "^3.1.0",
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
notificationStore,
|
||||
} from "./stores/index.js"
|
||||
import { get } from "svelte/store"
|
||||
import { io } from "socket.io-client"
|
||||
import { createWebsocket } from "@budibase/frontend-core"
|
||||
|
||||
let socket
|
||||
|
||||
|
@ -18,20 +18,7 @@ export const initWebsocket = () => {
|
|||
}
|
||||
|
||||
// Initialise connection
|
||||
const tls = location.protocol === "https:"
|
||||
const proto = tls ? "wss:" : "ws:"
|
||||
const host = location.hostname
|
||||
const port = location.port || (tls ? 443 : 80)
|
||||
socket = io(`${proto}//${host}:${port}`, {
|
||||
path: "/socket/client",
|
||||
// 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,
|
||||
})
|
||||
socket = createWebsocket("/socket/client")
|
||||
|
||||
// Event handlers
|
||||
socket.on("plugin-update", data => {
|
||||
|
|
|
@ -152,4 +152,10 @@ export const buildAppEndpoints = API => ({
|
|||
url: `/api/${appId}/components/definitions`,
|
||||
})
|
||||
},
|
||||
|
||||
addSampleData: async appId => {
|
||||
return await API.post({
|
||||
url: `/api/applications/${appId}/sample`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
{:else}
|
||||
<div class="text-cell" class:number={type === "number"}>
|
||||
<div class="value">
|
||||
{value || ""}
|
||||
{value ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -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,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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
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,
|
||||
// Disable polling and rely on websocket only, as HTTP transport
|
||||
// will only work with sticky sessions which we don't have
|
||||
transports: ["websocket"],
|
||||
})
|
||||
}
|
|
@ -1 +1 @@
|
|||
Subproject commit 2adc101c1ede13f861f282d702f45b94ab91fd41
|
||||
Subproject commit 86c32b80e08d2f19b57dcc2a3159667ac5a86c21
|
|
@ -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",
|
||||
|
@ -99,6 +100,7 @@
|
|||
"mssql": "6.2.3",
|
||||
"mysql2": "2.3.3",
|
||||
"node-fetch": "2.6.7",
|
||||
"object-sizeof": "2.6.1",
|
||||
"open": "8.4.0",
|
||||
"openai": "^3.2.1",
|
||||
"pg": "8.10.0",
|
||||
|
|
|
@ -26,10 +26,13 @@ import {
|
|||
env as envCore,
|
||||
} from "@budibase/backend-core"
|
||||
import { USERS_TABLE_SCHEMA } from "../../constants"
|
||||
import { buildDefaultDocs } from "../../db/defaultData/datasource_bb_default"
|
||||
import {
|
||||
DEFAULT_BB_DATASOURCE_ID,
|
||||
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,
|
||||
|
@ -111,11 +114,7 @@ function checkAppName(
|
|||
}
|
||||
}
|
||||
|
||||
async function createInstance(
|
||||
appId: string,
|
||||
template: any,
|
||||
includeSampleData: boolean
|
||||
) {
|
||||
async function createInstance(appId: string, template: any) {
|
||||
const db = context.getAppDB()
|
||||
await db.put({
|
||||
_id: "_design/database",
|
||||
|
@ -142,21 +141,25 @@ async function createInstance(
|
|||
} else {
|
||||
// create the users table
|
||||
await db.put(USERS_TABLE_SCHEMA)
|
||||
|
||||
if (includeSampleData) {
|
||||
// create ootb stock db
|
||||
await addDefaultTables(db)
|
||||
}
|
||||
}
|
||||
|
||||
return { _id: appId }
|
||||
}
|
||||
|
||||
async function addDefaultTables(db: Database) {
|
||||
const defaultDbDocs = buildDefaultDocs()
|
||||
export const addSampleData = async (ctx: UserCtx) => {
|
||||
const db = context.getAppDB()
|
||||
|
||||
// add in the default db data docs - tables, datasource, rows and links
|
||||
await db.bulkDocs([...defaultDbDocs])
|
||||
try {
|
||||
// Check if default datasource exists before creating it
|
||||
await sdk.datasources.get(DEFAULT_BB_DATASOURCE_ID)
|
||||
} catch (err: any) {
|
||||
const defaultDbDocs = buildDefaultDocs()
|
||||
|
||||
// add in the default db data docs - tables, datasource, rows and links
|
||||
await db.bulkDocs([...defaultDbDocs])
|
||||
}
|
||||
|
||||
ctx.status = 200
|
||||
}
|
||||
|
||||
export async function fetch(ctx: UserCtx) {
|
||||
|
@ -227,6 +230,7 @@ export async function fetchAppPackage(ctx: UserCtx) {
|
|||
screens,
|
||||
layouts,
|
||||
clientLibPath,
|
||||
hasLock: await doesUserHaveLock(application.appId, ctx.user),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -247,16 +251,11 @@ async function performAppCreate(ctx: UserCtx) {
|
|||
if (ctx.request.files && ctx.request.files.templateFile) {
|
||||
instanceConfig.file = ctx.request.files.templateFile
|
||||
}
|
||||
const includeSampleData = isQsTrue(ctx.request.body.sampleData)
|
||||
const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null
|
||||
const appId = generateDevAppID(generateAppID(tenantId))
|
||||
|
||||
return await context.doInAppContext(appId, async () => {
|
||||
const instance = await createInstance(
|
||||
appId,
|
||||
instanceConfig,
|
||||
includeSampleData
|
||||
)
|
||||
const instance = await createInstance(appId, instanceConfig)
|
||||
const db = context.getAppDB()
|
||||
|
||||
let newApplication: App = {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -38,6 +38,11 @@ router
|
|||
authorized(permissions.BUILDER),
|
||||
controller.revertClient
|
||||
)
|
||||
.post(
|
||||
"/api/applications/:appId/sample",
|
||||
authorized(permissions.BUILDER),
|
||||
controller.addSampleData
|
||||
)
|
||||
.post(
|
||||
"/api/applications/:appId/publish",
|
||||
authorized(permissions.BUILDER),
|
||||
|
|
|
@ -61,7 +61,6 @@ if (env.isProd()) {
|
|||
|
||||
const server = http.createServer(app.callback())
|
||||
destroyable(server)
|
||||
initialiseWebsockets(app, server)
|
||||
|
||||
let shuttingDown = false,
|
||||
errCode = 0
|
||||
|
|
|
@ -2,6 +2,23 @@ import env from "../../environment"
|
|||
import { AutomationResults, Automation, App } from "@budibase/types"
|
||||
import { automations } from "@budibase/pro"
|
||||
import { db as dbUtils } from "@budibase/backend-core"
|
||||
import sizeof from "object-sizeof"
|
||||
|
||||
const MAX_LOG_SIZE_MB = 5
|
||||
const MB_IN_BYTES = 1024 * 1024
|
||||
|
||||
function sanitiseResults(results: AutomationResults) {
|
||||
const message = `[removed] - max results size of ${MAX_LOG_SIZE_MB}MB exceeded`
|
||||
for (let step of results.steps) {
|
||||
step.inputs = {
|
||||
message,
|
||||
}
|
||||
step.outputs = {
|
||||
message,
|
||||
success: step.outputs.success,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeLog(
|
||||
automation: Automation,
|
||||
|
@ -11,6 +28,10 @@ export async function storeLog(
|
|||
if (env.DISABLE_AUTOMATION_LOGS) {
|
||||
return
|
||||
}
|
||||
const bytes = sizeof(results)
|
||||
if (bytes / MB_IN_BYTES > MAX_LOG_SIZE_MB) {
|
||||
sanitiseResults(results)
|
||||
}
|
||||
await automations.logs.storeLog(automation, results)
|
||||
}
|
||||
|
||||
|
|
|
@ -80,6 +80,7 @@ const environment = {
|
|||
ENABLE_ANALYTICS: process.env.ENABLE_ANALYTICS,
|
||||
SELF_HOSTED: process.env.SELF_HOSTED,
|
||||
HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT,
|
||||
FORKED_PROCESS_NAME: process.env.FORKED_PROCESS_NAME || "main",
|
||||
// old
|
||||
CLIENT_ID: process.env.CLIENT_ID,
|
||||
_set(key: string, value: any) {
|
||||
|
|
|
@ -9,8 +9,8 @@ import {
|
|||
checkDebounce,
|
||||
setDebounce,
|
||||
} from "../utilities/redis"
|
||||
import { db as dbCore, cache, permissions } from "@budibase/backend-core"
|
||||
import { BBContext, Database } from "@budibase/types"
|
||||
import { db as dbCore, cache } from "@budibase/backend-core"
|
||||
import { UserCtx, Database } from "@budibase/types"
|
||||
|
||||
const DEBOUNCE_TIME_SEC = 30
|
||||
|
||||
|
@ -23,7 +23,7 @@ const DEBOUNCE_TIME_SEC = 30
|
|||
* through the authorized middleware *
|
||||
****************************************************/
|
||||
|
||||
async function checkDevAppLocks(ctx: BBContext) {
|
||||
async function checkDevAppLocks(ctx: UserCtx) {
|
||||
const appId = ctx.appId
|
||||
|
||||
// if any public usage, don't proceed
|
||||
|
@ -35,15 +35,14 @@ 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) {
|
||||
async function updateAppUpdatedAt(ctx: UserCtx) {
|
||||
const appId = ctx.appId
|
||||
// if debouncing skip this update
|
||||
// get methods also aren't updating
|
||||
|
@ -51,20 +50,29 @@ async function updateAppUpdatedAt(ctx: BBContext) {
|
|||
return
|
||||
}
|
||||
await dbCore.doWithDB(appId, async (db: Database) => {
|
||||
const metadata = await db.get(DocumentType.APP_METADATA)
|
||||
metadata.updatedAt = new Date().toISOString()
|
||||
try {
|
||||
const metadata = await db.get(DocumentType.APP_METADATA)
|
||||
metadata.updatedAt = new Date().toISOString()
|
||||
|
||||
metadata.updatedBy = getGlobalIDFromUserMetadataID(ctx.user?.userId!)
|
||||
metadata.updatedBy = getGlobalIDFromUserMetadataID(ctx.user?.userId!)
|
||||
|
||||
const response = await db.put(metadata)
|
||||
metadata._rev = response.rev
|
||||
await cache.app.invalidateAppMetadata(appId, metadata)
|
||||
// set a new debounce record with a short TTL
|
||||
await setDebounce(appId, DEBOUNCE_TIME_SEC)
|
||||
const response = await db.put(metadata)
|
||||
metadata._rev = response.rev
|
||||
await cache.app.invalidateAppMetadata(appId, metadata)
|
||||
// set a new debounce record with a short TTL
|
||||
await setDebounce(appId, DEBOUNCE_TIME_SEC)
|
||||
} catch (err: any) {
|
||||
// if a 409 occurs, then multiple clients connected at the same time - ignore
|
||||
if (err?.status === 409) {
|
||||
return
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default async function builder(ctx: BBContext) {
|
||||
export default async function builder(ctx: UserCtx) {
|
||||
const appId = ctx.appId
|
||||
// this only functions within an app context
|
||||
if (!appId) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
AutomationStatus,
|
||||
AutomationMetadata,
|
||||
AutomationJob,
|
||||
AutomationData,
|
||||
} from "@budibase/types"
|
||||
import {
|
||||
LoopStep,
|
||||
|
@ -37,8 +38,8 @@ const LOOP_STEP_ID = actions.BUILTIN_ACTION_DEFINITIONS.LOOP.stepId
|
|||
const CRON_STEP_ID = triggerDefs.CRON.stepId
|
||||
const STOPPED_STATUS = { success: true, status: AutomationStatus.STOPPED }
|
||||
|
||||
function getLoopIterations(loopStep: LoopStep, input: LoopInput) {
|
||||
const binding = automationUtils.typecastForLooping(loopStep, input)
|
||||
function getLoopIterations(loopStep: LoopStep) {
|
||||
let binding = loopStep.inputs.binding
|
||||
if (!binding) {
|
||||
return 0
|
||||
}
|
||||
|
@ -68,7 +69,6 @@ class Orchestrator {
|
|||
constructor(job: AutomationJob) {
|
||||
let automation = job.data.automation
|
||||
let triggerOutput = job.data.event
|
||||
let timeout = job.data.event.timeout
|
||||
const metadata = triggerOutput.metadata
|
||||
this._chainCount = metadata ? metadata.automationChainCount! : 0
|
||||
this._appId = triggerOutput.appId as string
|
||||
|
@ -252,7 +252,7 @@ class Orchestrator {
|
|||
return
|
||||
}
|
||||
}
|
||||
|
||||
const start = performance.now()
|
||||
for (let step of automation.definition.steps) {
|
||||
if (timeoutFlag) {
|
||||
break
|
||||
|
@ -277,22 +277,17 @@ class Orchestrator {
|
|||
|
||||
if (loopStep) {
|
||||
input = await processObject(loopStep.inputs, this._context)
|
||||
iterations = getLoopIterations(loopStep as LoopStep, input)
|
||||
iterations = getLoopIterations(loopStep as LoopStep)
|
||||
}
|
||||
for (let index = 0; index < iterations; index++) {
|
||||
let originalStepInput = cloneDeep(step.inputs)
|
||||
// Handle if the user has set a max iteration count or if it reaches the max limit set by us
|
||||
if (loopStep && input.binding) {
|
||||
let newInput: any = await processObject(
|
||||
loopStep.inputs,
|
||||
cloneDeep(this._context)
|
||||
)
|
||||
|
||||
let tempOutput = { items: loopSteps, iterations: iterationCount }
|
||||
try {
|
||||
newInput.binding = automationUtils.typecastForLooping(
|
||||
loopStep.inputs.binding = automationUtils.typecastForLooping(
|
||||
loopStep as LoopStep,
|
||||
newInput
|
||||
loopStep.inputs as LoopInput
|
||||
)
|
||||
} catch (err) {
|
||||
this.updateContextAndOutput(loopStepNumber, step, tempOutput, {
|
||||
|
@ -303,13 +298,12 @@ class Orchestrator {
|
|||
loopStep = undefined
|
||||
break
|
||||
}
|
||||
|
||||
let item = []
|
||||
if (
|
||||
typeof loopStep.inputs.binding === "string" &&
|
||||
loopStep.inputs.option === "String"
|
||||
) {
|
||||
item = automationUtils.stringSplit(newInput.binding)
|
||||
item = automationUtils.stringSplit(loopStep.inputs.binding)
|
||||
} else if (Array.isArray(loopStep.inputs.binding)) {
|
||||
item = loopStep.inputs.binding
|
||||
}
|
||||
|
@ -351,6 +345,7 @@ class Orchestrator {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
index === env.AUTOMATION_MAX_ITERATIONS ||
|
||||
index === parseInt(loopStep.inputs.iterations)
|
||||
|
@ -479,8 +474,22 @@ class Orchestrator {
|
|||
}
|
||||
}
|
||||
|
||||
const end = performance.now()
|
||||
const executionTime = end - start
|
||||
|
||||
console.log(`Execution time: ${executionTime} milliseconds`)
|
||||
|
||||
// store the logs for the automation run
|
||||
await storeLog(this._automation, this.executionOutput)
|
||||
try {
|
||||
await storeLog(this._automation, this.executionOutput)
|
||||
} catch (e: any) {
|
||||
if (e.status === 413 && e.request?.data) {
|
||||
// if content is too large we shouldn't log it
|
||||
delete e.request.data
|
||||
e.request.data = { message: "removed due to large size" }
|
||||
}
|
||||
logging.logAlert("Error writing automation log", e)
|
||||
}
|
||||
if (isProdAppID(this._appId) && isRecurring(automation) && metadata) {
|
||||
await this.updateMetadata(metadata)
|
||||
}
|
||||
|
@ -488,23 +497,31 @@ class Orchestrator {
|
|||
}
|
||||
}
|
||||
|
||||
export function execute(job: Job, callback: WorkerCallback) {
|
||||
export function execute(job: Job<AutomationData>, callback: WorkerCallback) {
|
||||
const appId = job.data.event.appId
|
||||
const automationId = job.data.automation._id
|
||||
if (!appId) {
|
||||
throw new Error("Unable to execute, event doesn't contain app ID.")
|
||||
}
|
||||
return context.doInAppContext(appId, async () => {
|
||||
const envVars = await sdkUtils.getEnvironmentVariables()
|
||||
// put into automation thread for whole context
|
||||
await context.doInEnvironmentContext(envVars, async () => {
|
||||
const automationOrchestrator = new Orchestrator(job)
|
||||
try {
|
||||
const response = await automationOrchestrator.execute()
|
||||
callback(null, response)
|
||||
} catch (err) {
|
||||
callback(err)
|
||||
}
|
||||
})
|
||||
if (!automationId) {
|
||||
throw new Error("Unable to execute, event doesn't contain automation ID.")
|
||||
}
|
||||
return context.doInAutomationContext({
|
||||
appId,
|
||||
automationId,
|
||||
task: async () => {
|
||||
const envVars = await sdkUtils.getEnvironmentVariables()
|
||||
// put into automation thread for whole context
|
||||
await context.doInEnvironmentContext(envVars, async () => {
|
||||
const automationOrchestrator = new Orchestrator(job)
|
||||
try {
|
||||
const response = await automationOrchestrator.execute()
|
||||
callback(null, response)
|
||||
} catch (err) {
|
||||
callback(err)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -38,6 +38,9 @@ export class Thread {
|
|||
this.count = opts.count ? opts.count : 1
|
||||
this.disableThreading = this.shouldDisableThreading()
|
||||
if (!this.disableThreading) {
|
||||
console.debug(
|
||||
`[${env.FORKED_PROCESS_NAME}] initialising worker farm type=${type}`
|
||||
)
|
||||
const workerOpts: any = {
|
||||
autoStart: true,
|
||||
maxConcurrentWorkers: this.count,
|
||||
|
@ -45,6 +48,7 @@ export class Thread {
|
|||
env: {
|
||||
...process.env,
|
||||
FORKED_PROCESS: "1",
|
||||
FORKED_PROCESS_NAME: type,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -54,6 +58,10 @@ export class Thread {
|
|||
}
|
||||
this.workers = workerFarm(workerOpts, typeToFile(type), ["execute"])
|
||||
Thread.workerRefs.push(this.workers)
|
||||
} else {
|
||||
console.debug(
|
||||
`[${env.FORKED_PROCESS_NAME}] skipping worker farm type=${type}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,9 +80,7 @@ export class Thread {
|
|||
function fire(worker: any) {
|
||||
worker.execute(job, (err: any, response: any) => {
|
||||
if (err && err.type === "TimeoutError") {
|
||||
reject(
|
||||
new Error(`Query response time exceeded ${timeout}ms timeout.`)
|
||||
)
|
||||
reject(new Error(`Thread timeout exceeded ${timeout}ms timeout.`))
|
||||
} else if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
|
|
|
@ -26,8 +26,10 @@ function makeVariableKey(queryId: string, variable: string) {
|
|||
export function threadSetup() {
|
||||
// don't run this if not threading
|
||||
if (env.isTest() || env.DISABLE_THREADING || !env.isInThread()) {
|
||||
console.debug(`[${env.FORKED_PROCESS_NAME}] thread setup skipped`)
|
||||
return
|
||||
}
|
||||
console.debug(`[${env.FORKED_PROCESS_NAME}] thread setup running`)
|
||||
db.init()
|
||||
}
|
||||
|
||||
|
|
|
@ -35,10 +35,20 @@ export const getComponentLibraryManifest = async (library: string) => {
|
|||
const filename = "manifest.json"
|
||||
|
||||
if (env.isDev() || env.isTest()) {
|
||||
const path = join(TOP_LEVEL_PATH, "packages/client", filename)
|
||||
// always load from new so that updates are refreshed
|
||||
delete require.cache[require.resolve(path)]
|
||||
return require(path)
|
||||
const paths = [
|
||||
join(TOP_LEVEL_PATH, "packages/client", filename),
|
||||
join(process.cwd(), "client", filename),
|
||||
]
|
||||
for (let path of paths) {
|
||||
if (fs.existsSync(path)) {
|
||||
// always load from new so that updates are refreshed
|
||||
delete require.cache[require.resolve(path)]
|
||||
return require(path)
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Unable to find ${filename} in development environment (may need to build).`
|
||||
)
|
||||
}
|
||||
|
||||
if (!appId) {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ export enum LockType {
|
|||
* No retries will take place and no error will be thrown.
|
||||
*/
|
||||
TRY_ONCE = "try_once",
|
||||
TRY_TWICE = "try_twice",
|
||||
DEFAULT = "default",
|
||||
DELAY_500 = "delay_500",
|
||||
CUSTOM = "custom",
|
||||
|
|
|
@ -58,4 +58,10 @@ export default class AccountAPI {
|
|||
}
|
||||
return [response, json]
|
||||
}
|
||||
|
||||
async delete(accountID: string) {
|
||||
const [response, json] = await this.client.del(`/api/accounts/${accountID}`)
|
||||
expect(response).toHaveStatusCode(200)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { DEFAULT_TENANT_ID, logging } from "@budibase/backend-core"
|
|||
import { AccountInternalAPI } from "../account-api"
|
||||
import * as fixtures from "../internal-api/fixtures"
|
||||
import { BudibaseInternalAPI } from "../internal-api"
|
||||
import { CreateAccountRequest, Feature } from "@budibase/types"
|
||||
import { Account, CreateAccountRequest, Feature } from "@budibase/types"
|
||||
import env from "../environment"
|
||||
import { APIRequestOpts } from "../types"
|
||||
|
||||
|
@ -18,13 +18,13 @@ const API_OPTS: APIRequestOpts = { doExpect: false }
|
|||
// @ts-ignore
|
||||
global.qa = {}
|
||||
|
||||
async function createAccount() {
|
||||
async function createAccount(): Promise<[CreateAccountRequest, Account]> {
|
||||
const account = fixtures.accounts.generateAccount()
|
||||
await accountsApi.accounts.validateEmail(account.email, API_OPTS)
|
||||
await accountsApi.accounts.validateTenantId(account.tenantId, API_OPTS)
|
||||
const [res, newAccount] = await accountsApi.accounts.create(account, API_OPTS)
|
||||
await updateLicense(newAccount.accountId)
|
||||
return account
|
||||
return [account, newAccount]
|
||||
}
|
||||
|
||||
const UNLIMITED = { value: -1 }
|
||||
|
@ -85,9 +85,11 @@ async function setup() {
|
|||
console.log(`Environment: ${JSON.stringify(env)}`)
|
||||
|
||||
if (env.multiTenancy) {
|
||||
const account = await createAccount()
|
||||
const [account, newAccount] = await createAccount()
|
||||
// @ts-ignore
|
||||
global.qa.tenantId = account.tenantId
|
||||
// @ts-ignore
|
||||
global.qa.accountId = newAccount.accountId
|
||||
await loginAsAccount(account)
|
||||
} else {
|
||||
// @ts-ignore
|
||||
|
|
|
@ -1,7 +1,24 @@
|
|||
import { AccountInternalAPI } from "../account-api"
|
||||
import { BudibaseInternalAPI } from "../internal-api"
|
||||
import { APIRequestOpts } from "../types"
|
||||
|
||||
const accountsApi = new AccountInternalAPI({})
|
||||
const internalApi = new BudibaseInternalAPI({})
|
||||
|
||||
const API_OPTS: APIRequestOpts = { doExpect: false }
|
||||
|
||||
async function deleteAccount() {
|
||||
// @ts-ignore
|
||||
const accountID = global.qa.accountId
|
||||
await accountsApi.accounts.delete(accountID)
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
console.log("\nGLOBAL TEARDOWN STARTING")
|
||||
|
||||
// TODO: Delete account and apps after test run
|
||||
const env = await internalApi.environment.getEnvironment(API_OPTS)
|
||||
if (env.multiTenancy) {
|
||||
await deleteAccount()
|
||||
}
|
||||
|
||||
console.log("GLOBAL TEARDOWN COMPLETE")
|
||||
}
|
||||
|
|
28
yarn.lock
28
yarn.lock
|
@ -4786,6 +4786,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"
|
||||
|
@ -18824,6 +18833,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"
|
||||
|
@ -19210,6 +19224,13 @@ object-keys@~0.4.0:
|
|||
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336"
|
||||
integrity sha512-ncrLw+X55z7bkl5PnUvHwFK9FcGuFYo9gtjws2XtSzL+aZ8tm830P60WJ0dSmFVaSalWieW5MD7kEdnXda9yJw==
|
||||
|
||||
object-sizeof@2.6.1:
|
||||
version "2.6.1"
|
||||
resolved "https://registry.yarnpkg.com/object-sizeof/-/object-sizeof-2.6.1.tgz#1e2b6a01d182c268dbb07ee3403f539de45f63d3"
|
||||
integrity sha512-a7VJ1Zx7ZuHceKwjgfsSqzV/X0PVGvpZz7ho3Dn4Cs0LLcR5e5WuV+gsbizmplD8s0nAXMJmckKB2rkSiPm/Gg==
|
||||
dependencies:
|
||||
buffer "^6.0.3"
|
||||
|
||||
object-visit@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb"
|
||||
|
@ -23099,7 +23120,7 @@ socket.io-adapter@~2.5.2:
|
|||
dependencies:
|
||||
ws "~8.11.0"
|
||||
|
||||
socket.io-client@^4.5.1, socket.io-client@^4.6.1:
|
||||
socket.io-client@^4.6.1:
|
||||
version "4.6.1"
|
||||
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.6.1.tgz#80d97d5eb0feca448a0fb6d69a7b222d3d547eab"
|
||||
integrity sha512-5UswCV6hpaRsNg5kkEHVcbBIXEYoVbMQaHJBXJCyEQ+CiFPV1NIOY0XOFWG4XR4GZcB8Kn6AsRs/9cy9TbqVMQ==
|
||||
|
@ -24990,6 +25011,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"
|
||||
|
|
Loading…
Reference in New Issue