Merge branch 'develop' into feature/user-column-type
This commit is contained in:
commit
6487d76b1d
|
@ -55,7 +55,7 @@ http {
|
|||
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
|
||||
set $csp_object "object-src 'none'";
|
||||
set $csp_base_uri "base-uri 'self'";
|
||||
set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibase.qa https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
|
||||
set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
|
||||
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
|
||||
set $csp_frame "frame-src 'self' https:";
|
||||
set $csp_img "img-src http: https: data: blob:";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.10.12-alpha.10",
|
||||
"version": "2.10.12-alpha.14",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
|
|
|
@ -86,8 +86,8 @@ export const useAuditLogs = () => {
|
|||
return useFeature(Feature.AUDIT_LOGS)
|
||||
}
|
||||
|
||||
export const usePublicApiUserRoles = () => {
|
||||
return useFeature(Feature.USER_ROLE_PUBLIC_API)
|
||||
export const useExpandedPublicApi = () => {
|
||||
return useFeature(Feature.EXPANDED_PUBLIC_API)
|
||||
}
|
||||
|
||||
export const useScimIntegration = () => {
|
||||
|
|
|
@ -14,12 +14,12 @@
|
|||
export let autocomplete = false
|
||||
export let sort = false
|
||||
export let autoWidth = false
|
||||
export let fetchTerm = null
|
||||
export let useFetch = false
|
||||
export let searchTerm = null
|
||||
export let customPopoverHeight
|
||||
export let customPopoverOffsetBelow
|
||||
export let customPopoverMaxHeight
|
||||
export let open = false
|
||||
export let loading
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
@ -82,6 +82,7 @@
|
|||
</script>
|
||||
|
||||
<Picker
|
||||
on:loadMore
|
||||
{id}
|
||||
{error}
|
||||
{disabled}
|
||||
|
@ -90,9 +91,8 @@
|
|||
{options}
|
||||
isPlaceholder={!arrayValue.length}
|
||||
{autocomplete}
|
||||
bind:fetchTerm
|
||||
bind:searchTerm
|
||||
bind:open
|
||||
{useFetch}
|
||||
{isOptionSelected}
|
||||
{getOptionLabel}
|
||||
{getOptionValue}
|
||||
|
@ -102,4 +102,5 @@
|
|||
{customPopoverHeight}
|
||||
{customPopoverOffsetBelow}
|
||||
{customPopoverMaxHeight}
|
||||
{loading}
|
||||
/>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import "@spectrum-css/picker/dist/index-vars.css"
|
||||
import "@spectrum-css/popover/dist/index-vars.css"
|
||||
import "@spectrum-css/menu/dist/index-vars.css"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { createEventDispatcher, onDestroy } from "svelte"
|
||||
import clickOutside from "../../Actions/click_outside"
|
||||
import Search from "./Search.svelte"
|
||||
import Icon from "../../Icon/Icon.svelte"
|
||||
|
@ -10,6 +10,7 @@
|
|||
import Popover from "../../Popover/Popover.svelte"
|
||||
import Tags from "../../Tags/Tags.svelte"
|
||||
import Tag from "../../Tags/Tag.svelte"
|
||||
import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte"
|
||||
|
||||
export let id = null
|
||||
export let disabled = false
|
||||
|
@ -35,19 +36,20 @@
|
|||
export let autoWidth = false
|
||||
export let autocomplete = false
|
||||
export let sort = false
|
||||
export let fetchTerm = null
|
||||
export let useFetch = false
|
||||
export let searchTerm = null
|
||||
export let customPopoverHeight
|
||||
export let customPopoverOffsetBelow
|
||||
export let customPopoverMaxHeight
|
||||
export let align = "left"
|
||||
export let footer = null
|
||||
export let customAnchor = null
|
||||
export let loading
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let searchTerm = null
|
||||
let button
|
||||
let popover
|
||||
let component
|
||||
|
||||
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
|
||||
$: filteredOptions = getFilteredOptions(
|
||||
|
@ -82,7 +84,7 @@
|
|||
}
|
||||
|
||||
const getFilteredOptions = (options, term, getLabel) => {
|
||||
if (autocomplete && term && !fetchTerm) {
|
||||
if (autocomplete && term) {
|
||||
const lowerCaseTerm = term.toLowerCase()
|
||||
return options.filter(option => {
|
||||
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
|
||||
|
@ -90,6 +92,20 @@
|
|||
}
|
||||
return options
|
||||
}
|
||||
|
||||
const onScroll = e => {
|
||||
const scrollPxThreshold = 100
|
||||
const scrollPositionFromBottom =
|
||||
e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop
|
||||
if (scrollPositionFromBottom < scrollPxThreshold) {
|
||||
dispatch("loadMore")
|
||||
}
|
||||
}
|
||||
|
||||
$: component?.addEventListener("scroll", onScroll)
|
||||
onDestroy(() => {
|
||||
component?.removeEventListener("scroll", null)
|
||||
})
|
||||
</script>
|
||||
|
||||
<button
|
||||
|
@ -163,14 +179,13 @@
|
|||
>
|
||||
{#if autocomplete}
|
||||
<Search
|
||||
value={useFetch ? fetchTerm : searchTerm}
|
||||
on:change={event =>
|
||||
useFetch ? (fetchTerm = event.detail) : (searchTerm = event.detail)}
|
||||
value={searchTerm}
|
||||
on:change={event => (searchTerm = event.detail)}
|
||||
{disabled}
|
||||
placeholder="Search"
|
||||
/>
|
||||
{/if}
|
||||
<ul class="spectrum-Menu" role="listbox">
|
||||
<ul class="spectrum-Menu" role="listbox" bind:this={component}>
|
||||
{#if placeholderOption}
|
||||
<li
|
||||
class="spectrum-Menu-item placeholder"
|
||||
|
@ -248,6 +263,12 @@
|
|||
{/if}
|
||||
</ul>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading" class:loading--withAutocomplete={autocomplete}>
|
||||
<ProgressCircle size="S" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if footer}
|
||||
<div class="footer">
|
||||
{footer}
|
||||
|
@ -323,18 +344,19 @@
|
|||
/* Search styles inside popover */
|
||||
.popover-content :global(.spectrum-Search) {
|
||||
margin-top: -1px;
|
||||
margin-left: -1px;
|
||||
width: calc(100% + 2px);
|
||||
width: 100%;
|
||||
}
|
||||
.popover-content :global(.spectrum-Search input) {
|
||||
height: auto;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
padding-top: var(--spectrum-global-dimension-size-100);
|
||||
padding-bottom: var(--spectrum-global-dimension-size-100);
|
||||
}
|
||||
.popover-content :global(.spectrum-Search .spectrum-ClearButton) {
|
||||
right: 1px;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
}
|
||||
.popover-content :global(.spectrum-Search .spectrum-Textfield-icon) {
|
||||
|
@ -359,4 +381,14 @@
|
|||
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: fixed;
|
||||
justify-content: center;
|
||||
right: var(--spacing-s);
|
||||
top: var(--spacing-s);
|
||||
}
|
||||
.loading--withAutocomplete {
|
||||
top: calc(34px + var(--spacing-m));
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -25,6 +25,8 @@
|
|||
export let tag = null
|
||||
export let customPopoverOffsetBelow
|
||||
export let customPopoverMaxHeight
|
||||
export let searchTerm = null
|
||||
export let loading
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
@ -65,6 +67,8 @@
|
|||
<Picker
|
||||
on:click
|
||||
bind:open
|
||||
bind:searchTerm
|
||||
on:loadMore
|
||||
{quiet}
|
||||
{id}
|
||||
{error}
|
||||
|
@ -92,4 +96,5 @@
|
|||
placeholderOption={placeholder === false ? null : placeholder}
|
||||
isOptionSelected={option => option === value}
|
||||
onSelectOption={selectOption}
|
||||
{loading}
|
||||
/>
|
||||
|
|
|
@ -16,8 +16,7 @@
|
|||
export let sort = false
|
||||
export let autoWidth = false
|
||||
export let autocomplete = false
|
||||
export let fetchTerm = null
|
||||
export let useFetch = false
|
||||
export let searchTerm = null
|
||||
export let customPopoverHeight
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
@ -41,8 +40,7 @@
|
|||
{autoWidth}
|
||||
{autocomplete}
|
||||
{customPopoverHeight}
|
||||
bind:fetchTerm
|
||||
{useFetch}
|
||||
bind:searchTerm
|
||||
on:change={onChange}
|
||||
on:click
|
||||
/>
|
||||
|
|
|
@ -14,13 +14,14 @@
|
|||
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
|
||||
import GridRelationshipButton from "components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte"
|
||||
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
|
||||
import GridUsersTableButton from "components/backend/DataTable/modals/grid/GridUsersTableButton.svelte"
|
||||
|
||||
const userSchemaOverrides = {
|
||||
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 },
|
||||
firstName: { displayName: "First name" },
|
||||
lastName: { displayName: "Last name" },
|
||||
email: { displayName: "Email" },
|
||||
roleId: { displayName: "Role" },
|
||||
status: { displayName: "Status" },
|
||||
}
|
||||
|
||||
$: id = $tables.selected?._id
|
||||
|
@ -59,22 +60,22 @@
|
|||
datasource={gridDatasource}
|
||||
canAddRows={!isUsersTable}
|
||||
canDeleteRows={!isUsersTable}
|
||||
canEditRows={!isUsersTable}
|
||||
canEditColumns={!isUsersTable}
|
||||
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
|
||||
showAvatars={false}
|
||||
on:updatedatasource={handleGridTableUpdate}
|
||||
>
|
||||
<svelte:fragment slot="filter">
|
||||
{#if isUsersTable}
|
||||
<GridUsersTableButton />
|
||||
{/if}
|
||||
<GridFilterButton />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="edit-column">
|
||||
<GridEditColumnModal />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="add-column">
|
||||
<GridAddColumnModal />
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="controls">
|
||||
<GridCreateViewButton />
|
||||
{#if !isUsersTable}
|
||||
<GridCreateViewButton />
|
||||
{/if}
|
||||
<GridManageAccessButton />
|
||||
{#if relationshipsEnabled}
|
||||
<GridRelationshipButton />
|
||||
|
@ -84,7 +85,6 @@
|
|||
{:else}
|
||||
<GridImportButton />
|
||||
{/if}
|
||||
|
||||
<GridExportButton />
|
||||
{#if isUsersTable}
|
||||
<GridEditUserModal />
|
||||
|
@ -92,6 +92,12 @@
|
|||
<GridCreateEditRowModal />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="edit-column">
|
||||
<GridEditColumnModal />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="add-column">
|
||||
<GridAddColumnModal />
|
||||
</svelte:fragment>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
<script>
|
||||
import { ActionButton, Popover, Heading, Body, Button } from "@budibase/bbui"
|
||||
import { store } from "builderStore"
|
||||
|
||||
let anchor
|
||||
let open = false
|
||||
|
||||
const openSidePanel = () => {
|
||||
store.update(state => ({
|
||||
...state,
|
||||
builderSidePanel: true,
|
||||
}))
|
||||
open = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={anchor}>
|
||||
<ActionButton on:click={() => (open = true)} icon="Help" quiet>
|
||||
Why can't I edit this table?
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<Popover bind:open {anchor} align="left">
|
||||
<div class="content">
|
||||
<Heading size="XS">The app users table is read only</Heading>
|
||||
<Body size="S">
|
||||
You can continue to view the users that have access to your application.
|
||||
</Body>
|
||||
<Body size="S">
|
||||
Manage and invite more application users using the user side panel in the
|
||||
top right of your screen.
|
||||
</Body>
|
||||
<div class="button">
|
||||
<Button cta on:click={openSidePanel}>Open users panel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
width: 300px;
|
||||
padding: var(--spacing-l);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
.content :global(.spectrum-Heading) {
|
||||
font-weight: 400;
|
||||
}
|
||||
.button {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
|
@ -180,7 +180,7 @@
|
|||
<div class="hierarchy-items-container">
|
||||
<NavItem
|
||||
icon="UserGroup"
|
||||
text="Users"
|
||||
text="App users"
|
||||
selected={$isActive("./table/:tableId") &&
|
||||
$tables.selected?._id === TableNames.USERS}
|
||||
on:click={() => selectTable(TableNames.USERS)}
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
<script>
|
||||
import {
|
||||
ModalContent,
|
||||
Toggle,
|
||||
Input,
|
||||
Layout,
|
||||
Dropzone,
|
||||
notifications,
|
||||
Body,
|
||||
} from "@budibase/bbui"
|
||||
import { API } from "api"
|
||||
import { automationStore, store } from "../../builderStore"
|
||||
|
||||
export let app
|
||||
|
||||
$: disabled = (encrypted && !password) || !file
|
||||
let encrypted = false,
|
||||
password
|
||||
let file
|
||||
|
||||
async function updateApp() {
|
||||
try {
|
||||
let data = new FormData()
|
||||
data.append("appExport", file)
|
||||
if (encrypted) {
|
||||
data.append("encryptionPassword", password.trim())
|
||||
}
|
||||
const appId = app.devId
|
||||
await API.updateAppFromExport(appId, data)
|
||||
const pkg = await API.fetchAppPackage(appId)
|
||||
await store.actions.initialise(pkg)
|
||||
await automationStore.actions.fetch()
|
||||
notifications.success("App updated successfully")
|
||||
} catch (err) {
|
||||
notifications.error(`Failed to update app - ${err.message || err}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
title={`Update ${app.name}`}
|
||||
confirmText="Update"
|
||||
onConfirm={updateApp}
|
||||
bind:disabled
|
||||
>
|
||||
<Body size="S"
|
||||
>Updating an app using an app export will replace all tables, datasources,
|
||||
queries, screens and automations. It is recommended to perform a backup
|
||||
before running this operation.</Body
|
||||
>
|
||||
<Layout noPadding gap="XS">
|
||||
<Dropzone
|
||||
gallery={false}
|
||||
label="App export"
|
||||
on:change={e => {
|
||||
file = e.detail?.[0]
|
||||
}}
|
||||
/>
|
||||
<Toggle text="Encrypted" bind:value={encrypted} />
|
||||
{#if encrypted}
|
||||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
placeholder="Type here..."
|
||||
bind:value={password}
|
||||
/>
|
||||
{/if}
|
||||
</Layout>
|
||||
</ModalContent>
|
|
@ -32,9 +32,9 @@
|
|||
active={$isActive("./embed")}
|
||||
/>
|
||||
<SideNavItem
|
||||
text="Export"
|
||||
url={$url("./export")}
|
||||
active={$isActive("./export")}
|
||||
text="Export/Import"
|
||||
url={$url("./exportImport")}
|
||||
active={$isActive("./exportImport")}
|
||||
/>
|
||||
<SideNavItem
|
||||
text="Name and URL"
|
||||
|
|
|
@ -11,31 +11,39 @@
|
|||
import { apps } from "stores/portal"
|
||||
import { store } from "builderStore"
|
||||
import ExportAppModal from "components/start/ExportAppModal.svelte"
|
||||
import ImportAppModal from "components/start/ImportAppModal.svelte"
|
||||
|
||||
$: filteredApps = $apps.filter(app => app.devId == $store.appId)
|
||||
$: app = filteredApps.length ? filteredApps[0] : {}
|
||||
$: appDeployed = app?.status === AppStatus.DEPLOYED
|
||||
|
||||
let exportModal
|
||||
let exportModal, importModal
|
||||
let exportPublishedVersion = false
|
||||
|
||||
const exportApp = opts => {
|
||||
exportPublishedVersion = !!opts?.published
|
||||
exportModal.show()
|
||||
}
|
||||
|
||||
const importApp = () => {
|
||||
importModal.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={exportModal} padding={false}>
|
||||
<ExportAppModal {app} published={exportPublishedVersion} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={importModal} padding={false}>
|
||||
<ImportAppModal {app} />
|
||||
</Modal>
|
||||
|
||||
<Layout noPadding>
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading>Export your app</Heading>
|
||||
<Body>Export your latest edited or published app</Body>
|
||||
</Layout>
|
||||
<Divider />
|
||||
<div class="export-body">
|
||||
<div class="body">
|
||||
<ActionButton secondary on:click={() => exportApp({ published: false })}>
|
||||
Export latest edited app
|
||||
</ActionButton>
|
||||
|
@ -47,10 +55,20 @@
|
|||
Export latest published app
|
||||
</ActionButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading>Import your app</Heading>
|
||||
<Body>Import an export to update this app</Body>
|
||||
</Layout>
|
||||
<div class="body">
|
||||
<ActionButton secondary on:click={() => importApp()}>
|
||||
Import app
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.export-body {
|
||||
.body {
|
||||
display: flex;
|
||||
gap: var(--spacing-l);
|
||||
}
|
|
@ -101,7 +101,7 @@
|
|||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-light);
|
||||
padding: 0 24px;
|
||||
padding: 0 var(--spacing-l);
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
|
|
@ -86,8 +86,16 @@
|
|||
$: userPage = $userPageInfo.page
|
||||
$: logsPage = $logsPageInfo.page
|
||||
|
||||
let usersObj = {}
|
||||
$: usersObj = {
|
||||
...usersObj,
|
||||
...$users.data?.reduce((accumulator, user) => {
|
||||
accumulator[user._id] = user
|
||||
return accumulator
|
||||
}, {}),
|
||||
}
|
||||
$: sortedUsers = sort(
|
||||
enrich($users.data || [], selectedUsers, "_id"),
|
||||
enrich(Object.values(usersObj), selectedUsers, "_id"),
|
||||
"email"
|
||||
)
|
||||
$: sortedEvents = sort(
|
||||
|
@ -256,8 +264,7 @@
|
|||
<div class="controls">
|
||||
<div class="select">
|
||||
<Multiselect
|
||||
bind:fetchTerm={userSearchTerm}
|
||||
useFetch
|
||||
bind:searchTerm={userSearchTerm}
|
||||
placeholder="All users"
|
||||
label="Users"
|
||||
autocomplete
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<script>
|
||||
import { params, redirect } from "@roxi/routify"
|
||||
import { apps } from "stores/portal"
|
||||
|
||||
$: app = $apps.find(app => app.appId === $params.appId)
|
||||
$: {
|
||||
if (!app) {
|
||||
$redirect("../")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if app}
|
||||
<slot />
|
||||
{/if}
|
|
@ -0,0 +1,79 @@
|
|||
<script>
|
||||
import { params, goto } from "@roxi/routify"
|
||||
import { apps, auth, sideBarCollapsed } from "stores/portal"
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
|
||||
$: app = $apps.find(app => app.appId === $params.appId)
|
||||
$: iframeUrl = getIframeURL(app)
|
||||
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
|
||||
|
||||
const getIframeURL = app => {
|
||||
if (app.status === "published") {
|
||||
return `/app${app.url}`
|
||||
}
|
||||
return `/${app.devId}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
{#if $sideBarCollapsed}
|
||||
<ActionButton
|
||||
quiet
|
||||
icon="Rail"
|
||||
on:click={() => sideBarCollapsed.set(false)}
|
||||
>
|
||||
Menu
|
||||
</ActionButton>
|
||||
{:else}
|
||||
<ActionButton
|
||||
quiet
|
||||
icon="RailRightOpen"
|
||||
on:click={() => sideBarCollapsed.set(true)}
|
||||
>
|
||||
Collapse
|
||||
</ActionButton>
|
||||
{/if}
|
||||
{#if isBuilder}
|
||||
<ActionButton
|
||||
quiet
|
||||
icon="Edit"
|
||||
on:click={() => $goto(`../../app/${app.devId}`)}
|
||||
>
|
||||
Edit
|
||||
</ActionButton>
|
||||
{/if}
|
||||
<ActionButton
|
||||
quiet
|
||||
icon="LinkOut"
|
||||
on:click={() => window.open(iframeUrl, "_blank")}
|
||||
>
|
||||
Fullscreen
|
||||
</ActionButton>
|
||||
</div>
|
||||
<iframe src={iframeUrl} title={app.name} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
padding: 0 var(--spacing-l) var(--spacing-l) var(--spacing-l);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
flex: 0 0 50px;
|
||||
}
|
||||
iframe {
|
||||
flex: 1 1 auto;
|
||||
border-radius: var(--spacing-s);
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,140 @@
|
|||
<script>
|
||||
import { Icon, Body } from "@budibase/bbui"
|
||||
import { apps, sideBarCollapsed } from "stores/portal"
|
||||
import { params, goto } from "@roxi/routify"
|
||||
import { tick } from "svelte"
|
||||
import NavItem from "components/common/NavItem.svelte"
|
||||
|
||||
let searchInput
|
||||
let searchString
|
||||
let searching = false
|
||||
|
||||
$: filteredApps = $apps.filter(app => {
|
||||
return (
|
||||
!searchString ||
|
||||
app.name.toLowerCase().includes(searchString.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const startSearching = async () => {
|
||||
searching = true
|
||||
searchString = ""
|
||||
await tick()
|
||||
searchInput.focus()
|
||||
}
|
||||
|
||||
const stopSearching = () => {
|
||||
searching = false
|
||||
searchString = ""
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="side-bar" class:collapsed={$sideBarCollapsed}>
|
||||
<div class="side-bar-controls">
|
||||
{#if searching}
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
bind:value={searchString}
|
||||
placeholder="Search for apps"
|
||||
/>
|
||||
{:else}
|
||||
<Body size="S">Apps</Body>
|
||||
<Icon name="Search" size="S" hoverable on:click={startSearching} />
|
||||
{/if}
|
||||
<div class="rotational" class:rotated={searching}>
|
||||
<Icon
|
||||
name="Add"
|
||||
hoverable
|
||||
on:click={searching ? stopSearching : () => $goto("./create")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-bar-nav">
|
||||
<NavItem
|
||||
icon="WebPages"
|
||||
text="All apps"
|
||||
on:click={() => $goto("./")}
|
||||
selected={!$params.appId}
|
||||
/>
|
||||
{#each filteredApps as app}
|
||||
<NavItem
|
||||
text={app.name}
|
||||
icon={app.icon?.name || "Apps"}
|
||||
iconColor={app.icon?.color}
|
||||
selected={$params.appId === app.appId}
|
||||
on:click={() => $goto(`./${app.appId}`)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.side-bar {
|
||||
flex: 0 0 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border-right: var(--border-light);
|
||||
background: var(--spectrum-global-color-gray-100);
|
||||
overflow: hidden;
|
||||
transition: margin-left 300ms ease-out;
|
||||
}
|
||||
.side-bar.collapsed {
|
||||
margin-left: -262px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.side-bar {
|
||||
margin-left: -262px;
|
||||
}
|
||||
}
|
||||
|
||||
.side-bar-controls {
|
||||
flex: 0 0 50px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-l);
|
||||
padding: 0 var(--spacing-l);
|
||||
}
|
||||
.side-bar-controls :global(.spectrum-Body),
|
||||
.side-bar-controls input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.side-bar-controls :global(.spectrum-Icon) {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
|
||||
input {
|
||||
outline: none;
|
||||
border: none;
|
||||
max-width: none;
|
||||
flex: 1 1 auto;
|
||||
color: var(--spectrum-global-color-gray-800);
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
transition: border 130ms ease-out;
|
||||
font-family: var(--font-sans);
|
||||
background: inherit;
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
transition: color 130ms ease-out;
|
||||
}
|
||||
input:hover::placeholder {
|
||||
color: var(--spectrum-global-color-gray-800);
|
||||
}
|
||||
|
||||
.side-bar-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
div.rotational {
|
||||
transition: transform 130ms ease-out;
|
||||
}
|
||||
div.rotational.rotated {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
</style>
|
|
@ -11,6 +11,7 @@
|
|||
import { onMount } from "svelte"
|
||||
import { redirect } from "@roxi/routify"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import PortalSideBar from "./_components/PortalSideBar.svelte"
|
||||
|
||||
// Don't block loading if we've already hydrated state
|
||||
let loaded = $apps.length != null
|
||||
|
@ -44,5 +45,18 @@
|
|||
</script>
|
||||
|
||||
{#if loaded}
|
||||
<slot />
|
||||
<div class="page">
|
||||
<PortalSideBar />
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { writable } from "svelte/store"
|
||||
|
||||
export { organisation } from "./organisation"
|
||||
export { users } from "./users"
|
||||
export { admin } from "./admin"
|
||||
|
@ -14,3 +16,5 @@ export { environment } from "./environment"
|
|||
export { menu } from "./menu"
|
||||
export { auditLogs } from "./auditLogs"
|
||||
export { features } from "./features"
|
||||
|
||||
export const sideBarCollapsed = writable(false)
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
<script>
|
||||
import {
|
||||
CoreSelect,
|
||||
CoreMultiselect,
|
||||
Input,
|
||||
ProgressCircle,
|
||||
} from "@budibase/bbui"
|
||||
import { fetchData, Utils } from "@budibase/frontend-core"
|
||||
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
||||
import { fetchData } from "@budibase/frontend-core"
|
||||
import { getContext } from "svelte"
|
||||
import Field from "./Field.svelte"
|
||||
import { FieldTypes } from "../../../constants"
|
||||
|
@ -26,16 +21,8 @@
|
|||
let fieldApi
|
||||
let fieldSchema
|
||||
let tableDefinition
|
||||
let primaryDisplay
|
||||
let options
|
||||
let selectedOptions = []
|
||||
let isOpen = false
|
||||
let hasFilter
|
||||
|
||||
let searchResults
|
||||
let searchString
|
||||
let searching = false
|
||||
let lastSearchId
|
||||
let searchTerm
|
||||
let open
|
||||
|
||||
$: multiselect = fieldSchema?.relationshipType !== "one-to-many"
|
||||
$: linkedTableId = fieldSchema?.tableId
|
||||
|
@ -50,54 +37,74 @@
|
|||
limit: 100,
|
||||
},
|
||||
})
|
||||
$: hasFilter = !!filter?.filter(f => !!f.field)?.length
|
||||
$: fetch.update({ filter })
|
||||
$: {
|
||||
options = searchResults ? searchResults : $fetch.rows
|
||||
const nonMatchingOptions = selectedOptions.filter(
|
||||
option => !options.map(opt => opt._id).includes(option._id)
|
||||
)
|
||||
// Append initially selected options if there is no filter
|
||||
// and hasn't already been appended
|
||||
if (!hasFilter) {
|
||||
options = [...options, ...nonMatchingOptions]
|
||||
}
|
||||
}
|
||||
|
||||
$: tableDefinition = $fetch.definition
|
||||
$: primaryDisplay = tableDefinition?.primaryDisplay || "_id"
|
||||
$: singleValue = flatten(fieldState?.value)?.[0]
|
||||
$: multiValue = flatten(fieldState?.value) ?? []
|
||||
$: selectedValue = multiselect
|
||||
? flatten(fieldState?.value) ?? []
|
||||
: flatten(fieldState?.value)?.[0]
|
||||
$: component = multiselect ? CoreMultiselect : CoreSelect
|
||||
$: expandedDefaultValue = expand(defaultValue)
|
||||
$: debouncedSearch(searchString)
|
||||
$: primaryDisplay = tableDefinition?.primaryDisplay
|
||||
|
||||
let optionsObj = {}
|
||||
let initialValuesProcessed
|
||||
|
||||
$: {
|
||||
if (searching) {
|
||||
isOpen = true
|
||||
if (!initialValuesProcessed && primaryDisplay) {
|
||||
// Persist the initial values as options, allowing them to be present in the dropdown,
|
||||
// even if they are not in the inital fetch results
|
||||
initialValuesProcessed = true
|
||||
optionsObj = fieldState?.value?.reduce((accumulator, value) => {
|
||||
accumulator[value._id] = {
|
||||
_id: value._id,
|
||||
[primaryDisplay]: value.primaryDisplay,
|
||||
}
|
||||
return accumulator
|
||||
}, optionsObj)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the initially selected values
|
||||
// as they may not be within the first 100 records
|
||||
$: enrichedOptions = enrichOptions(optionsObj, $fetch.rows)
|
||||
const enrichOptions = (optionsObj, fetchResults) => {
|
||||
const result = (fetchResults || [])?.reduce((accumulator, row) => {
|
||||
if (!accumulator[row._id]) {
|
||||
accumulator[row._id] = row
|
||||
}
|
||||
return accumulator
|
||||
}, optionsObj)
|
||||
|
||||
return Object.values(result)
|
||||
}
|
||||
$: {
|
||||
if (
|
||||
primaryDisplay !== "_id" &&
|
||||
fieldState?.value?.length &&
|
||||
!selectedOptions?.length
|
||||
) {
|
||||
API.searchTable({
|
||||
paginate: false,
|
||||
tableId: linkedTableId,
|
||||
limit: 100,
|
||||
query: {
|
||||
oneOf: {
|
||||
[`1:${primaryDisplay}`]: fieldState?.value?.map(
|
||||
value => value.primaryDisplay
|
||||
),
|
||||
},
|
||||
},
|
||||
}).then(response => {
|
||||
const value = multiselect ? multiValue : singleValue
|
||||
selectedOptions = response.rows.filter(row => value.includes(row._id))
|
||||
// We don't want to reorder while the dropdown is open, to avoid UX jumps
|
||||
if (!open) {
|
||||
enrichedOptions = enrichedOptions.sort((a, b) => {
|
||||
const selectedValues = flatten(fieldState?.value) || []
|
||||
|
||||
const aIsSelected = selectedValues.find(v => v === a._id)
|
||||
const bIsSelected = selectedValues.find(v => v === b._id)
|
||||
if (aIsSelected && !bIsSelected) {
|
||||
return -1
|
||||
} else if (!aIsSelected && bIsSelected) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return a[primaryDisplay] > b[primaryDisplay]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$: fetchRows(searchTerm, primaryDisplay)
|
||||
|
||||
const fetchRows = (searchTerm, primaryDisplay) => {
|
||||
const allRowsFetched =
|
||||
$fetch.loaded &&
|
||||
!Object.keys($fetch.query?.string || {}).length &&
|
||||
!$fetch.hasNextPage
|
||||
// Don't request until we have the primary display
|
||||
if (!allRowsFetched && primaryDisplay) {
|
||||
fetch.update({
|
||||
query: { string: { [primaryDisplay]: searchTerm } },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -113,7 +120,7 @@
|
|||
}
|
||||
|
||||
const getDisplayName = row => {
|
||||
return row?.[tableDefinition?.primaryDisplay || "_id"] || "-"
|
||||
return row?.[primaryDisplay] || "-"
|
||||
}
|
||||
|
||||
const singleHandler = e => {
|
||||
|
@ -136,66 +143,16 @@
|
|||
|
||||
const handleChange = value => {
|
||||
const changed = fieldApi.setValue(value)
|
||||
selectedOptions = value.map(val => ({
|
||||
_id: val,
|
||||
[primaryDisplay]: options.find(option => option._id === val)[
|
||||
primaryDisplay
|
||||
],
|
||||
}))
|
||||
if (onChange && changed) {
|
||||
onChange({ value })
|
||||
}
|
||||
}
|
||||
|
||||
// Search for rows based on the search string
|
||||
const search = async searchString => {
|
||||
// Reset state if this search is invalid
|
||||
if (!linkedTableId || !searchString) {
|
||||
searchResults = null
|
||||
return
|
||||
const loadMore = () => {
|
||||
if (!$fetch.loading) {
|
||||
fetch.nextPage()
|
||||
}
|
||||
|
||||
// If a filter exists, then do a client side search
|
||||
if (hasFilter) {
|
||||
searchResults = $fetch.rows.filter(option =>
|
||||
option[primaryDisplay].startsWith(searchString)
|
||||
)
|
||||
isOpen = true
|
||||
return
|
||||
}
|
||||
|
||||
// Search for results, using IDs to track invocations and ensure we're
|
||||
// handling the latest update
|
||||
lastSearchId = Math.random()
|
||||
searching = true
|
||||
const thisSearchId = lastSearchId
|
||||
const results = await API.searchTable({
|
||||
paginate: false,
|
||||
tableId: linkedTableId,
|
||||
limit: 100,
|
||||
query: {
|
||||
string: {
|
||||
[`1:${primaryDisplay}`]: searchString || "",
|
||||
},
|
||||
},
|
||||
})
|
||||
searching = false
|
||||
|
||||
// In case searching takes longer than our debounced update, abandon these
|
||||
// results
|
||||
if (thisSearchId !== lastSearchId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Process results
|
||||
searchResults = results.rows?.map(row => ({
|
||||
...row,
|
||||
primaryDisplay: row[primaryDisplay],
|
||||
}))
|
||||
}
|
||||
|
||||
// Debounced version of searching
|
||||
const debouncedSearch = Utils.debounce(search, 250)
|
||||
</script>
|
||||
|
||||
<Field
|
||||
|
@ -210,63 +167,23 @@
|
|||
bind:fieldSchema
|
||||
>
|
||||
{#if fieldState}
|
||||
<div class={autocomplete ? "field-with-search" : ""}>
|
||||
<svelte:component
|
||||
this={component}
|
||||
bind:open={isOpen}
|
||||
{options}
|
||||
autocomplete={false}
|
||||
value={multiselect ? multiValue : singleValue}
|
||||
on:change={multiselect ? multiHandler : singleHandler}
|
||||
id={fieldState.fieldId}
|
||||
disabled={fieldState.disabled}
|
||||
error={fieldState.error}
|
||||
getOptionLabel={getDisplayName}
|
||||
getOptionValue={option => option._id}
|
||||
{placeholder}
|
||||
customPopoverOffsetBelow={autocomplete ? 32 : null}
|
||||
customPopoverMaxHeight={autocomplete ? 240 : null}
|
||||
sort={true}
|
||||
/>
|
||||
{#if autocomplete}
|
||||
<div class="search">
|
||||
<Input
|
||||
autofocus
|
||||
quiet
|
||||
type="text"
|
||||
bind:value={searchString}
|
||||
placeholder={primaryDisplay ? `Search by ${primaryDisplay}` : null}
|
||||
/>
|
||||
{#if searching}
|
||||
<div>
|
||||
<ProgressCircle size="S" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<svelte:component
|
||||
this={component}
|
||||
options={enrichedOptions}
|
||||
{autocomplete}
|
||||
value={selectedValue}
|
||||
on:change={multiselect ? multiHandler : singleHandler}
|
||||
on:loadMore={loadMore}
|
||||
id={fieldState.fieldId}
|
||||
disabled={fieldState.disabled}
|
||||
error={fieldState.error}
|
||||
getOptionLabel={getDisplayName}
|
||||
getOptionValue={option => option._id}
|
||||
{placeholder}
|
||||
bind:searchTerm
|
||||
loading={$fetch.loading}
|
||||
bind:open
|
||||
customPopoverMaxHeight={400}
|
||||
/>
|
||||
{/if}
|
||||
</Field>
|
||||
|
||||
<style>
|
||||
.search {
|
||||
flex: 0 0 calc(var(--default-row-height) - 1px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 4px var(--cell-padding);
|
||||
width: calc(100% - 2 * var(--cell-padding));
|
||||
}
|
||||
.search :global(.spectrum-Textfield) {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.search :global(.spectrum-Textfield-input) {
|
||||
font-size: 13px;
|
||||
}
|
||||
.search :global(.spectrum-Form-item) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.field-with-search {
|
||||
min-height: 80px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { sdk } from "@budibase/shared-core"
|
||||
|
||||
export const buildAppEndpoints = API => ({
|
||||
/**
|
||||
* Fetches screen definition for an app.
|
||||
|
@ -81,6 +83,22 @@ export const buildAppEndpoints = API => ({
|
|||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an application using an export - the body
|
||||
* should be of type FormData, with a "file" and a "password" if encrypted.
|
||||
* @param appId The ID of the app to update - this will always be
|
||||
* converted to development ID.
|
||||
* @param body a FormData body with a file and password.
|
||||
*/
|
||||
updateAppFromExport: async (appId, body) => {
|
||||
const devId = sdk.applications.getDevAppID(appId)
|
||||
return await API.post({
|
||||
url: `/api/applications/${devId}/import`,
|
||||
body,
|
||||
json: false,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Imports an export of all apps.
|
||||
* @param apps the FormData containing the apps to import
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 3c51e0938e2226038f4456bb8c96d857310b8d0c
|
||||
Subproject commit 30385682141e5ba9d98de7d71d5be1672109cd15
|
|
@ -9,6 +9,7 @@ CREATE TABLE Persons (
|
|||
Address varchar(255),
|
||||
City varchar(255) DEFAULT 'Belfast',
|
||||
Age INTEGER DEFAULT 20 NOT NULL,
|
||||
Year INTEGER,
|
||||
Type person_job
|
||||
);
|
||||
CREATE TABLE Tasks (
|
||||
|
@ -49,9 +50,10 @@ CREATE TABLE CompositeTable (
|
|||
Name varchar(255),
|
||||
PRIMARY KEY (KeyPartOne, KeyPartTwo)
|
||||
);
|
||||
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast', 'qa');
|
||||
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('John', 'Smith', '64 Updown Road', 'Dublin', 'programmer');
|
||||
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Age) VALUES ('Foo', 'Bar', 'Foo Street', 'Bartown', 'support', 0);
|
||||
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Year) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast', 'qa', 1999);
|
||||
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Year) VALUES ('John', 'Smith', '64 Updown Road', 'Dublin', 'programmer', 1996);
|
||||
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Age, Year) VALUES ('Foo', 'Bar', 'Foo Street', 'Bartown', 'support', 0, 1993);
|
||||
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('Jonny', 'Muffin', 'Muffin Street', 'Cork', 'support');
|
||||
INSERT INTO Tasks (ExecutorID, QaID, TaskName, Completed) VALUES (1, 2, 'assembling', TRUE);
|
||||
INSERT INTO Tasks (ExecutorID, QaID, TaskName, Completed) VALUES (2, 1, 'processing', FALSE);
|
||||
INSERT INTO Products (ProductName) VALUES ('Computers');
|
||||
|
|
|
@ -613,6 +613,23 @@
|
|||
"data"
|
||||
]
|
||||
},
|
||||
"appExport": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"encryptPassword": {
|
||||
"description": "An optional password used to encrypt the export.",
|
||||
"type": "string"
|
||||
},
|
||||
"excludeRows": {
|
||||
"description": "Set whether the internal table rows should be excluded from the export.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"encryptPassword",
|
||||
"excludeRows"
|
||||
]
|
||||
},
|
||||
"row": {
|
||||
"description": "The row to be created/updated, based on the table schema.",
|
||||
"type": "object",
|
||||
|
@ -2163,6 +2180,87 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/applications/{appId}/import": {
|
||||
"post": {
|
||||
"operationId": "appImport",
|
||||
"summary": "Import an app to an existing app 🔒",
|
||||
"description": "This endpoint is only available on a business or enterprise license.",
|
||||
"tags": [
|
||||
"applications"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/appIdUrl"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"encryptedPassword": {
|
||||
"description": "Password for the export if it is encrypted.",
|
||||
"type": "string"
|
||||
},
|
||||
"appExport": {
|
||||
"description": "The app export to import.",
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appExport"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Application has been updated."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/applications/{appId}/export": {
|
||||
"post": {
|
||||
"operationId": "appExport",
|
||||
"summary": "Export an app 🔒",
|
||||
"description": "This endpoint is only available on a business or enterprise license.",
|
||||
"tags": [
|
||||
"applications"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/appIdUrl"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/appExport"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A gzip tarball containing the app export, encrypted if password provided.",
|
||||
"content": {
|
||||
"application/gzip": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary",
|
||||
"example": "Tarball containing database and object store contents..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/applications/search": {
|
||||
"post": {
|
||||
"operationId": "appSearch",
|
||||
|
|
|
@ -587,6 +587,19 @@ components:
|
|||
- appUrl
|
||||
required:
|
||||
- data
|
||||
appExport:
|
||||
type: object
|
||||
properties:
|
||||
encryptPassword:
|
||||
description: An optional password used to encrypt the export.
|
||||
type: string
|
||||
excludeRows:
|
||||
description: Set whether the internal table rows should be excluded from the
|
||||
export.
|
||||
type: boolean
|
||||
required:
|
||||
- encryptPassword
|
||||
- excludeRows
|
||||
row:
|
||||
description: The row to be created/updated, based on the table schema.
|
||||
type: object
|
||||
|
@ -1763,6 +1776,57 @@ paths:
|
|||
examples:
|
||||
deployment:
|
||||
$ref: "#/components/examples/deploymentOutput"
|
||||
"/applications/{appId}/import":
|
||||
post:
|
||||
operationId: appImport
|
||||
summary: Import an app to an existing app 🔒
|
||||
description: This endpoint is only available on a business or enterprise license.
|
||||
tags:
|
||||
- applications
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/appIdUrl"
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
encryptedPassword:
|
||||
description: Password for the export if it is encrypted.
|
||||
type: string
|
||||
appExport:
|
||||
description: The app export to import.
|
||||
type: string
|
||||
format: binary
|
||||
required:
|
||||
- appExport
|
||||
responses:
|
||||
"204":
|
||||
description: Application has been updated.
|
||||
"/applications/{appId}/export":
|
||||
post:
|
||||
operationId: appExport
|
||||
summary: Export an app 🔒
|
||||
description: This endpoint is only available on a business or enterprise license.
|
||||
tags:
|
||||
- applications
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/appIdUrl"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/appExport"
|
||||
responses:
|
||||
"200":
|
||||
description: A gzip tarball containing the app export, encrypted if password
|
||||
provided.
|
||||
content:
|
||||
application/gzip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
example: Tarball containing database and object store contents...
|
||||
/applications/search:
|
||||
post:
|
||||
operationId: appSearch
|
||||
|
|
|
@ -134,4 +134,15 @@ export default new Resource()
|
|||
deploymentOutput: object({
|
||||
data: deploymentOutputSchema,
|
||||
}),
|
||||
appExport: object({
|
||||
encryptPassword: {
|
||||
description: "An optional password used to encrypt the export.",
|
||||
type: "string",
|
||||
},
|
||||
excludeRows: {
|
||||
description:
|
||||
"Set whether the internal table rows should be excluded from the export.",
|
||||
type: "boolean",
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
|
|
@ -39,9 +39,8 @@ import {
|
|||
} from "../../db/defaultData/datasource_bb_default"
|
||||
import { removeAppFromUserRoles } from "../../utilities/workerRequests"
|
||||
import { stringToReadStream } from "../../utilities"
|
||||
import { doesUserHaveLock, getLocksById } from "../../utilities/redis"
|
||||
import { doesUserHaveLock } from "../../utilities/redis"
|
||||
import { cleanupAutomations } from "../../automations/utils"
|
||||
import { checkAppMetadata } from "../../automations/logging"
|
||||
import { getUniqueRows } from "../../utilities/usageQuota/rows"
|
||||
import { groups, licensing, quotas } from "@budibase/pro"
|
||||
import {
|
||||
|
@ -51,7 +50,6 @@ import {
|
|||
PlanType,
|
||||
Screen,
|
||||
UserCtx,
|
||||
ContextUser,
|
||||
} from "@budibase/types"
|
||||
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
|
||||
import sdk from "../../sdk"
|
||||
|
@ -575,6 +573,28 @@ export async function sync(ctx: UserCtx) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function importToApp(ctx: UserCtx) {
|
||||
const { appId } = ctx.params
|
||||
const appExport = ctx.request.files?.appExport
|
||||
const password = ctx.request.body.encryptionPassword as string
|
||||
if (!appExport) {
|
||||
ctx.throw(400, "Must supply app export to import")
|
||||
}
|
||||
if (Array.isArray(appExport)) {
|
||||
ctx.throw(400, "Must only supply one app export")
|
||||
}
|
||||
const fileAttributes = { type: appExport.type!, path: appExport.path! }
|
||||
try {
|
||||
await sdk.applications.updateWithExport(appId, fileAttributes, password)
|
||||
} catch (err: any) {
|
||||
ctx.throw(
|
||||
500,
|
||||
`Unable to perform update, please retry - ${err?.message || err}`
|
||||
)
|
||||
}
|
||||
ctx.body = { message: "app updated" }
|
||||
}
|
||||
|
||||
export async function updateAppPackage(appPackage: any, appId: any) {
|
||||
return context.doInAppContext(appId, async () => {
|
||||
const db = context.getAppDB()
|
||||
|
|
|
@ -2,9 +2,11 @@ import { db as dbCore, context } from "@budibase/backend-core"
|
|||
import { search as stringSearch, addRev } from "./utils"
|
||||
import * as controller from "../application"
|
||||
import * as deployController from "../deploy"
|
||||
import * as backupController from "../backup"
|
||||
import { Application } from "../../../definitions/common"
|
||||
import { UserCtx } from "@budibase/types"
|
||||
import { Next } from "koa"
|
||||
import { sdk as proSdk } from "@budibase/pro"
|
||||
|
||||
function fixAppID(app: Application, params: any) {
|
||||
if (!params) {
|
||||
|
@ -80,6 +82,8 @@ export async function destroy(ctx: UserCtx, next: Next) {
|
|||
export async function unpublish(ctx: UserCtx, next: Next) {
|
||||
await context.doInAppContext(ctx.params.appId, async () => {
|
||||
await controller.unpublish(ctx)
|
||||
ctx.body = undefined
|
||||
ctx.status = 204
|
||||
await next()
|
||||
})
|
||||
}
|
||||
|
@ -91,12 +95,22 @@ export async function publish(ctx: UserCtx, next: Next) {
|
|||
})
|
||||
}
|
||||
|
||||
// get licensed endpoints from pro
|
||||
export const importToApp = proSdk.publicApi.applications.buildImportFn(
|
||||
controller.importToApp
|
||||
)
|
||||
export const exportApp = proSdk.publicApi.applications.buildExportFn(
|
||||
backupController.exportAppDump
|
||||
)
|
||||
|
||||
export default {
|
||||
create,
|
||||
update,
|
||||
read,
|
||||
destroy,
|
||||
search,
|
||||
publish,
|
||||
unpublish,
|
||||
publish,
|
||||
importToApp,
|
||||
exportApp,
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import * as deploymentController from "../controllers/deploy"
|
|||
import authorized from "../../middleware/authorized"
|
||||
import { permissions } from "@budibase/backend-core"
|
||||
import { applicationValidator } from "./utils/validators"
|
||||
import { importToApp } from "../controllers/application"
|
||||
|
||||
const router: Router = new Router()
|
||||
|
||||
|
@ -58,5 +59,10 @@ router
|
|||
authorized(permissions.GLOBAL_BUILDER),
|
||||
controller.destroy
|
||||
)
|
||||
.post(
|
||||
"/api/applications/:appId/import",
|
||||
authorized(permissions.BUILDER),
|
||||
controller.importToApp
|
||||
)
|
||||
|
||||
export default router
|
||||
|
|
|
@ -137,6 +137,70 @@ write.push(
|
|||
new Endpoint("post", "/applications/:appId/publish", controller.publish)
|
||||
)
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /applications/{appId}/import:
|
||||
* post:
|
||||
* operationId: appImport
|
||||
* summary: Import an app to an existing app 🔒
|
||||
* description: This endpoint is only available on a business or enterprise license.
|
||||
* tags:
|
||||
* - applications
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/appIdUrl'
|
||||
* requestBody:
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* encryptedPassword:
|
||||
* description: Password for the export if it is encrypted.
|
||||
* type: string
|
||||
* appExport:
|
||||
* description: The app export to import.
|
||||
* type: string
|
||||
* format: binary
|
||||
* required:
|
||||
* - appExport
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Application has been updated.
|
||||
*/
|
||||
write.push(
|
||||
new Endpoint("post", "/applications/:appId/import", controller.importToApp)
|
||||
)
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /applications/{appId}/export:
|
||||
* post:
|
||||
* operationId: appExport
|
||||
* summary: Export an app 🔒
|
||||
* description: This endpoint is only available on a business or enterprise license.
|
||||
* tags:
|
||||
* - applications
|
||||
* parameters:
|
||||
* - $ref: '#/components/parameters/appIdUrl'
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/appExport'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A gzip tarball containing the app export, encrypted if password provided.
|
||||
* content:
|
||||
* application/gzip:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
* example: Tarball containing database and object store contents...
|
||||
*/
|
||||
read.push(
|
||||
new Endpoint("post", "/applications/:appId/export", controller.exportApp)
|
||||
)
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /applications/{appId}:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Ctx } from "@budibase/types"
|
||||
import mapping from "../../../controllers/public/mapping"
|
||||
|
||||
enum Resources {
|
||||
|
@ -9,11 +10,19 @@ enum Resources {
|
|||
SEARCH = "search",
|
||||
}
|
||||
|
||||
function isArrayResponse(ctx: any) {
|
||||
function isAttachment(ctx: Ctx) {
|
||||
return ctx.body?.path && ctx.body?.flags && ctx.body?.mode
|
||||
}
|
||||
|
||||
function isArrayResponse(ctx: Ctx) {
|
||||
return ctx.url.endsWith(Resources.SEARCH) || Array.isArray(ctx.body)
|
||||
}
|
||||
|
||||
function processApplications(ctx: any) {
|
||||
function noResponse(ctx: Ctx) {
|
||||
return !Array.isArray(ctx.body) && Object.keys(ctx.body).length === 0
|
||||
}
|
||||
|
||||
function processApplications(ctx: Ctx) {
|
||||
if (isArrayResponse(ctx)) {
|
||||
return mapping.mapApplications(ctx)
|
||||
} else {
|
||||
|
@ -21,7 +30,7 @@ function processApplications(ctx: any) {
|
|||
}
|
||||
}
|
||||
|
||||
function processTables(ctx: any) {
|
||||
function processTables(ctx: Ctx) {
|
||||
if (isArrayResponse(ctx)) {
|
||||
return mapping.mapTables(ctx)
|
||||
} else {
|
||||
|
@ -29,7 +38,7 @@ function processTables(ctx: any) {
|
|||
}
|
||||
}
|
||||
|
||||
function processRows(ctx: any) {
|
||||
function processRows(ctx: Ctx) {
|
||||
if (isArrayResponse(ctx)) {
|
||||
return mapping.mapRowSearch(ctx)
|
||||
} else {
|
||||
|
@ -37,7 +46,7 @@ function processRows(ctx: any) {
|
|||
}
|
||||
}
|
||||
|
||||
function processUsers(ctx: any) {
|
||||
function processUsers(ctx: Ctx) {
|
||||
if (isArrayResponse(ctx)) {
|
||||
return mapping.mapUsers(ctx)
|
||||
} else {
|
||||
|
@ -45,7 +54,7 @@ function processUsers(ctx: any) {
|
|||
}
|
||||
}
|
||||
|
||||
function processQueries(ctx: any) {
|
||||
function processQueries(ctx: Ctx) {
|
||||
if (isArrayResponse(ctx)) {
|
||||
return mapping.mapQueries(ctx)
|
||||
} else {
|
||||
|
@ -53,8 +62,8 @@ function processQueries(ctx: any) {
|
|||
}
|
||||
}
|
||||
|
||||
export default async (ctx: any, next: any) => {
|
||||
if (!ctx.body) {
|
||||
export default async (ctx: Ctx, next: any) => {
|
||||
if (!ctx.body || noResponse(ctx) || isAttachment(ctx)) {
|
||||
return await next()
|
||||
}
|
||||
let urlParts = ctx.url.split("/")
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import * as setup from "../../tests/utilities"
|
||||
import {
|
||||
generateMakeRequest,
|
||||
generateMakeRequestWithFormData,
|
||||
MakeRequestResponse,
|
||||
MakeRequestWithFormDataResponse,
|
||||
} from "./utils"
|
||||
import { User } from "@budibase/types"
|
||||
import { join } from "path"
|
||||
import { mocks } from "@budibase/backend-core/tests"
|
||||
|
||||
const PASSWORD = "testtest"
|
||||
const NO_LICENSE_MSG = "Endpoint unavailable, license required."
|
||||
|
||||
let config = setup.getConfig()
|
||||
let apiKey: string,
|
||||
globalUser: User,
|
||||
makeRequest: MakeRequestResponse,
|
||||
makeRequestFormData: MakeRequestWithFormDataResponse
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
globalUser = await config.globalUser()
|
||||
apiKey = await config.generateApiKey(globalUser._id)
|
||||
makeRequest = generateMakeRequest(apiKey)
|
||||
makeRequestFormData = generateMakeRequestWithFormData(apiKey)
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
describe("check export/import", () => {
|
||||
async function runExport() {
|
||||
return await makeRequest("post", `/applications/${config.appId}/export`, {
|
||||
encryptionPassword: PASSWORD,
|
||||
excludeRows: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
const pathToExport = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"tests",
|
||||
"assets",
|
||||
"export.tar.gz"
|
||||
)
|
||||
return await makeRequestFormData(
|
||||
"post",
|
||||
`/applications/${config.appId}/import`,
|
||||
{
|
||||
encryptionPassword: PASSWORD,
|
||||
appExport: { path: pathToExport },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
it("check licensing for export", async () => {
|
||||
const res = await runExport()
|
||||
expect(res.status).toBe(403)
|
||||
expect(res.body.message).toBe(NO_LICENSE_MSG)
|
||||
})
|
||||
|
||||
it("check licensing for import", async () => {
|
||||
const res = await runImport()
|
||||
expect(res.status).toBe(403)
|
||||
expect(res.body.message).toBe(NO_LICENSE_MSG)
|
||||
})
|
||||
|
||||
it("should be able to export app", async () => {
|
||||
mocks.licenses.useExpandedPublicApi()
|
||||
const res = await runExport()
|
||||
expect(res.headers["content-disposition"]).toMatch(
|
||||
/attachment; filename=".*-export-.*\.tar.gz"/g
|
||||
)
|
||||
expect(res.body instanceof Buffer).toBe(true)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it("should be able to import app", async () => {
|
||||
mocks.licenses.useExpandedPublicApi()
|
||||
const res = await runImport()
|
||||
expect(Object.keys(res.body).length).toBe(0)
|
||||
// check screens imported correctly
|
||||
const screens = await config.api.screen.list()
|
||||
expect(screens.length).toBe(2)
|
||||
expect(screens[0].routing.route).toBe("/derp")
|
||||
expect(screens[1].routing.route).toBe("/blank")
|
||||
expect(res.status).toBe(204)
|
||||
})
|
||||
})
|
|
@ -92,7 +92,7 @@ describe("no user role update in free", () => {
|
|||
describe("no user role update in business", () => {
|
||||
beforeAll(() => {
|
||||
updateMock()
|
||||
mocks.licenses.usePublicApiUserRoles()
|
||||
mocks.licenses.useExpandedPublicApi()
|
||||
})
|
||||
|
||||
it("should allow 'roles' to be updated", async () => {
|
||||
|
@ -105,7 +105,7 @@ describe("no user role update in business", () => {
|
|||
})
|
||||
|
||||
it("should allow 'admin' to be updated", async () => {
|
||||
mocks.licenses.usePublicApiUserRoles()
|
||||
mocks.licenses.useExpandedPublicApi()
|
||||
const res = await makeRequest("post", "/users", {
|
||||
...base(),
|
||||
admin: { global: true },
|
||||
|
@ -115,7 +115,7 @@ describe("no user role update in business", () => {
|
|||
})
|
||||
|
||||
it("should allow 'builder' to be updated", async () => {
|
||||
mocks.licenses.usePublicApiUserRoles()
|
||||
mocks.licenses.useExpandedPublicApi()
|
||||
const res = await makeRequest("post", "/users", {
|
||||
...base(),
|
||||
builder: { global: true },
|
||||
|
|
|
@ -11,6 +11,32 @@ export type MakeRequestResponse = (
|
|||
intAppId?: string
|
||||
) => Promise<supertest.Response>
|
||||
|
||||
export type MakeRequestWithFormDataResponse = (
|
||||
method: HttpMethod,
|
||||
endpoint: string,
|
||||
fields: Record<string, string | { path: string }>,
|
||||
intAppId?: string
|
||||
) => Promise<supertest.Response>
|
||||
|
||||
function base(
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
intAppId: string | null,
|
||||
isInternal: boolean
|
||||
) {
|
||||
const extraHeaders: any = {
|
||||
"x-budibase-api-key": apiKey,
|
||||
}
|
||||
if (intAppId) {
|
||||
extraHeaders["x-budibase-app-id"] = intAppId
|
||||
}
|
||||
|
||||
const url = isInternal
|
||||
? endpoint
|
||||
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
|
||||
return { headers: extraHeaders, url }
|
||||
}
|
||||
|
||||
export function generateMakeRequest(
|
||||
apiKey: string,
|
||||
isInternal = false
|
||||
|
@ -23,18 +49,8 @@ export function generateMakeRequest(
|
|||
body?: any,
|
||||
intAppId: string | null = config.getAppId()
|
||||
) => {
|
||||
const extraHeaders: any = {
|
||||
"x-budibase-api-key": apiKey,
|
||||
}
|
||||
if (intAppId) {
|
||||
extraHeaders["x-budibase-app-id"] = intAppId
|
||||
}
|
||||
|
||||
const url = isInternal
|
||||
? endpoint
|
||||
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
|
||||
|
||||
const req = request[method](url).set(config.defaultHeaders(extraHeaders))
|
||||
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
|
||||
const req = request[method](url).set(config.defaultHeaders(headers))
|
||||
if (body) {
|
||||
req.send(body)
|
||||
}
|
||||
|
@ -43,3 +59,30 @@ export function generateMakeRequest(
|
|||
return res
|
||||
}
|
||||
}
|
||||
|
||||
export function generateMakeRequestWithFormData(
|
||||
apiKey: string,
|
||||
isInternal = false
|
||||
): MakeRequestWithFormDataResponse {
|
||||
const request = setup.getRequest()!
|
||||
const config = setup.getConfig()!
|
||||
return async (
|
||||
method: HttpMethod,
|
||||
endpoint: string,
|
||||
fields: Record<string, string | { path: string }>,
|
||||
intAppId: string | null = config.getAppId()
|
||||
) => {
|
||||
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
|
||||
const req = request[method](url).set(config.defaultHeaders(headers))
|
||||
for (let [field, value] of Object.entries(fields)) {
|
||||
if (typeof value === "string") {
|
||||
req.field(field, value)
|
||||
} else {
|
||||
req.attach(field, value.path)
|
||||
}
|
||||
}
|
||||
const res = await req
|
||||
expect(res.body).toBeDefined()
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import * as setup from "./utilities"
|
||||
import path from "path"
|
||||
|
||||
jest.setTimeout(15000)
|
||||
const PASSWORD = "testtest"
|
||||
|
||||
describe("/applications/:appId/import", () => {
|
||||
let request = setup.getRequest()
|
||||
let config = setup.getConfig()
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
it("should be able to perform import", async () => {
|
||||
const appId = config.getAppId()
|
||||
const res = await request
|
||||
.post(`/api/applications/${appId}/import`)
|
||||
.field("encryptionPassword", PASSWORD)
|
||||
.attach("appExport", path.join(__dirname, "assets", "export.tar.gz"))
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.message).toBe("app updated")
|
||||
const screens = await config.api.screen.list()
|
||||
expect(screens.length).toBe(2)
|
||||
expect(screens[0].routing.route).toBe("/derp")
|
||||
expect(screens[1].routing.route).toBe("/blank")
|
||||
})
|
||||
})
|
Binary file not shown.
|
@ -18,6 +18,14 @@ export interface paths {
|
|||
"/applications/{appId}/publish": {
|
||||
post: operations["appPublish"];
|
||||
};
|
||||
"/applications/{appId}/import": {
|
||||
/** This endpoint is only available on a business or enterprise license. */
|
||||
post: operations["appImport"];
|
||||
};
|
||||
"/applications/{appId}/export": {
|
||||
/** This endpoint is only available on a business or enterprise license. */
|
||||
post: operations["appExport"];
|
||||
};
|
||||
"/applications/search": {
|
||||
/** Based on application properties (currently only name) search for applications. */
|
||||
post: operations["appSearch"];
|
||||
|
@ -158,6 +166,12 @@ export interface components {
|
|||
appUrl: string;
|
||||
};
|
||||
};
|
||||
appExport: {
|
||||
/** @description An optional password used to encrypt the export. */
|
||||
encryptPassword: string;
|
||||
/** @description Set whether the internal table rows should be excluded from the export. */
|
||||
excludeRows: boolean;
|
||||
};
|
||||
/** @description The row to be created/updated, based on the table schema. */
|
||||
row: { [key: string]: unknown };
|
||||
searchOutput: {
|
||||
|
@ -889,6 +903,54 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
/** This endpoint is only available on a business or enterprise license. */
|
||||
appImport: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** The ID of the app which this request is targeting. */
|
||||
appId: components["parameters"]["appIdUrl"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** Application has been updated. */
|
||||
204: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/** @description Password for the export if it is encrypted. */
|
||||
encryptedPassword?: string;
|
||||
/**
|
||||
* Format: binary
|
||||
* @description The app export to import.
|
||||
*/
|
||||
appExport: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/** This endpoint is only available on a business or enterprise license. */
|
||||
appExport: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** The ID of the app which this request is targeting. */
|
||||
appId: components["parameters"]["appIdUrl"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** A gzip tarball containing the app export, encrypted if password provided. */
|
||||
200: {
|
||||
content: {
|
||||
"application/gzip": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["appExport"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** Based on application properties (currently only name) search for applications. */
|
||||
appSearch: {
|
||||
responses: {
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
import { db as dbCore } from "@budibase/backend-core"
|
||||
import {
|
||||
DocumentTypesToImport,
|
||||
Document,
|
||||
Database,
|
||||
RowValue,
|
||||
} from "@budibase/types"
|
||||
import backups from "../backups"
|
||||
|
||||
export type FileAttributes = {
|
||||
type: string
|
||||
path: string
|
||||
}
|
||||
|
||||
function mergeUpdateAndDeleteDocuments(
|
||||
updateDocs: Document[],
|
||||
deleteDocs: Document[]
|
||||
) {
|
||||
// compress the documents to create and to delete (if same ID, then just update the rev)
|
||||
const finalToDelete = []
|
||||
for (let deleteDoc of deleteDocs) {
|
||||
const found = updateDocs.find(doc => doc._id === deleteDoc._id)
|
||||
if (found) {
|
||||
found._rev = deleteDoc._rev
|
||||
} else {
|
||||
finalToDelete.push(deleteDoc)
|
||||
}
|
||||
}
|
||||
return [...updateDocs, ...finalToDelete]
|
||||
}
|
||||
|
||||
async function removeImportableDocuments(db: Database) {
|
||||
// get the references to the documents, not the whole document
|
||||
const docPromises = []
|
||||
for (let docType of DocumentTypesToImport) {
|
||||
docPromises.push(db.allDocs(dbCore.getDocParams(docType)))
|
||||
}
|
||||
let documentRefs: { _id: string; _rev: string }[] = []
|
||||
for (let response of await Promise.all(docPromises)) {
|
||||
documentRefs = documentRefs.concat(
|
||||
response.rows.map(row => ({
|
||||
_id: row.id,
|
||||
_rev: (row.value as RowValue).rev,
|
||||
}))
|
||||
)
|
||||
}
|
||||
// add deletion key
|
||||
return documentRefs.map(ref => ({ _deleted: true, ...ref }))
|
||||
}
|
||||
|
||||
async function getImportableDocuments(db: Database) {
|
||||
// get the whole document
|
||||
const docPromises = []
|
||||
for (let docType of DocumentTypesToImport) {
|
||||
docPromises.push(
|
||||
db.allDocs(dbCore.getDocParams(docType, null, { include_docs: true }))
|
||||
)
|
||||
}
|
||||
// map the responses to the document itself
|
||||
let documents: Document[] = []
|
||||
for (let response of await Promise.all(docPromises)) {
|
||||
documents = documents.concat(response.rows.map(row => row.doc))
|
||||
}
|
||||
// remove the _rev, stops it being written
|
||||
documents.forEach(doc => {
|
||||
delete doc._rev
|
||||
})
|
||||
return documents
|
||||
}
|
||||
|
||||
export async function updateWithExport(
|
||||
appId: string,
|
||||
file: FileAttributes,
|
||||
password?: string
|
||||
) {
|
||||
const devId = dbCore.getDevAppID(appId)
|
||||
const tempAppName = `temp_${devId}`
|
||||
const tempDb = dbCore.getDB(tempAppName)
|
||||
const appDb = dbCore.getDB(devId)
|
||||
try {
|
||||
const template = {
|
||||
file: {
|
||||
type: file.type!,
|
||||
path: file.path!,
|
||||
password,
|
||||
},
|
||||
}
|
||||
// get a temporary version of the import
|
||||
// don't need obj store, the existing app already has everything we need
|
||||
await backups.importApp(devId, tempDb, template, {
|
||||
importObjStoreContents: false,
|
||||
})
|
||||
// get the documents to copy
|
||||
const toUpdate = await getImportableDocuments(tempDb)
|
||||
// clear out the old documents
|
||||
const toDelete = await removeImportableDocuments(appDb)
|
||||
// now bulk update documents - add new ones, delete old ones and update common ones
|
||||
await appDb.bulkDocs(mergeUpdateAndDeleteDocuments(toUpdate, toDelete))
|
||||
} finally {
|
||||
await tempDb.destroy()
|
||||
}
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
import * as sync from "./sync"
|
||||
import * as utils from "./utils"
|
||||
import * as applications from "./applications"
|
||||
import * as imports from "./import"
|
||||
|
||||
export default {
|
||||
...sync,
|
||||
...utils,
|
||||
...applications,
|
||||
...imports,
|
||||
}
|
||||
|
|
|
@ -8,11 +8,7 @@ import {
|
|||
TABLE_ROW_PREFIX,
|
||||
USER_METDATA_PREFIX,
|
||||
} from "../../../db/utils"
|
||||
import {
|
||||
DB_EXPORT_FILE,
|
||||
GLOBAL_DB_EXPORT_FILE,
|
||||
STATIC_APP_FILES,
|
||||
} from "./constants"
|
||||
import { DB_EXPORT_FILE, STATIC_APP_FILES } from "./constants"
|
||||
import fs from "fs"
|
||||
import { join } from "path"
|
||||
import env from "../../../environment"
|
||||
|
|
|
@ -151,7 +151,8 @@ export function getListOfAppsInMulti(tmpPath: string) {
|
|||
export async function importApp(
|
||||
appId: string,
|
||||
db: Database,
|
||||
template: TemplateType
|
||||
template: TemplateType,
|
||||
opts: { importObjStoreContents: boolean } = { importObjStoreContents: true }
|
||||
) {
|
||||
let prodAppId = dbCore.getProdAppID(appId)
|
||||
let dbStream: any
|
||||
|
@ -165,7 +166,7 @@ export async function importApp(
|
|||
}
|
||||
const contents = fs.readdirSync(tmpPath)
|
||||
// have to handle object import
|
||||
if (contents.length) {
|
||||
if (contents.length && opts.importObjStoreContents) {
|
||||
let promises = []
|
||||
let excludedFiles = [GLOBAL_DB_EXPORT_FILE, DB_EXPORT_FILE]
|
||||
for (let filename of contents) {
|
||||
|
|
|
@ -53,7 +53,6 @@ import {
|
|||
View,
|
||||
FieldType,
|
||||
RelationshipType,
|
||||
ViewV2,
|
||||
CreateViewRequest,
|
||||
} from "@budibase/types"
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import { TableAPI } from "./table"
|
|||
import { ViewV2API } from "./viewV2"
|
||||
import { DatasourceAPI } from "./datasource"
|
||||
import { LegacyViewAPI } from "./legacyView"
|
||||
import { ScreenAPI } from "./screen"
|
||||
|
||||
export default class API {
|
||||
table: TableAPI
|
||||
|
@ -13,6 +14,7 @@ export default class API {
|
|||
row: RowAPI
|
||||
permission: PermissionAPI
|
||||
datasource: DatasourceAPI
|
||||
screen: ScreenAPI
|
||||
|
||||
constructor(config: TestConfiguration) {
|
||||
this.table = new TableAPI(config)
|
||||
|
@ -21,5 +23,6 @@ export default class API {
|
|||
this.row = new RowAPI(config)
|
||||
this.permission = new PermissionAPI(config)
|
||||
this.datasource = new DatasourceAPI(config)
|
||||
this.screen = new ScreenAPI(config)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import TestConfiguration from "../TestConfiguration"
|
||||
import { Screen } from "@budibase/types"
|
||||
import { TestAPI } from "./base"
|
||||
|
||||
export class ScreenAPI extends TestAPI {
|
||||
constructor(config: TestConfiguration) {
|
||||
super(config)
|
||||
}
|
||||
|
||||
list = async (): Promise<Screen[]> => {
|
||||
const res = await this.request
|
||||
.get(`/api/screens`)
|
||||
.set(this.config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
return res.body as Screen[]
|
||||
}
|
||||
}
|
|
@ -39,6 +39,25 @@ export enum DocumentType {
|
|||
AUDIT_LOG = "al",
|
||||
}
|
||||
|
||||
// these are the core documents that make up the data, design
|
||||
// and automation sections of an app. This excludes any internal
|
||||
// rows as we shouldn't import data.
|
||||
export const DocumentTypesToImport: DocumentType[] = [
|
||||
DocumentType.ROLE,
|
||||
DocumentType.DATASOURCE,
|
||||
DocumentType.DATASOURCE_PLUS,
|
||||
DocumentType.TABLE,
|
||||
DocumentType.AUTOMATION,
|
||||
DocumentType.WEBHOOK,
|
||||
DocumentType.SCREEN,
|
||||
DocumentType.QUERY,
|
||||
DocumentType.METADATA,
|
||||
DocumentType.MEM_VIEW,
|
||||
// Deprecated but still copied
|
||||
DocumentType.INSTANCE,
|
||||
DocumentType.LAYOUT,
|
||||
]
|
||||
|
||||
// these documents don't really exist, they are part of other
|
||||
// documents or enriched into existence as part of get requests
|
||||
export enum VirtualDocumentType {
|
||||
|
|
|
@ -11,7 +11,7 @@ export enum Feature {
|
|||
SYNC_AUTOMATIONS = "syncAutomations",
|
||||
APP_BUILDERS = "appBuilders",
|
||||
OFFLINE = "offline",
|
||||
USER_ROLE_PUBLIC_API = "userRolePublicApi",
|
||||
EXPANDED_PUBLIC_API = "expandedPublicApi",
|
||||
VIEW_PERMISSIONS = "viewPermissions",
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue