Merge branch 'budi-7010-encrypt-app-exports' into budi-7010/frontend-encrypt-app-exports

This commit is contained in:
Adria Navarro 2023-06-14 11:20:31 +01:00
commit 8e87e3cfc2
27 changed files with 515 additions and 130 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.7.7-alpha.2", "version": "2.7.7-alpha.4",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/backend-core", "packages/backend-core",

View File

@ -8,6 +8,8 @@
export let disabled = false export let disabled = false
export let error = null export let error = null
export let validate = null export let validate = null
export let indeterminate = false
export let compact = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -21,11 +23,19 @@
} }
</script> </script>
<FancyField {error} {value} {validate} {disabled} clickable on:click={onChange}> <FancyField
{error}
{value}
{validate}
{disabled}
{compact}
clickable
on:click={onChange}
>
<span> <span>
<Checkbox {disabled} {value} /> <Checkbox {disabled} {value} {indeterminate} />
</span> </span>
<div class="text"> <div class="text" class:compact>
{#if text} {#if text}
{text} {text}
{/if} {/if}
@ -47,6 +57,10 @@
line-clamp: 2; line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.text.compact {
font-size: 13px;
line-height: 15px;
}
.text > :global(*) { .text > :global(*) {
font-size: inherit !important; font-size: inherit !important;
} }

View File

@ -0,0 +1,68 @@
<script>
import FancyCheckbox from "./FancyCheckbox.svelte"
import FancyForm from "./FancyForm.svelte"
import { createEventDispatcher } from "svelte"
export let options = []
export let selected = []
export let showSelectAll = true
export let selectAllText = "Select all"
let selectedBooleans = reset()
const dispatch = createEventDispatcher()
$: updateSelected(selectedBooleans)
$: dispatch("change", selected)
$: allSelected = selected?.length === options.length
$: noneSelected = !selected?.length
function reset() {
return Array(options.length).fill(true)
}
function updateSelected(selectedArr) {
const array = []
for (let [i, isSelected] of Object.entries(selectedArr)) {
if (isSelected) {
array.push(options[i])
}
}
selected = array
}
function toggleSelectAll() {
if (allSelected === true) {
selectedBooleans = []
} else {
selectedBooleans = reset()
}
}
</script>
{#if options && Array.isArray(options)}
<div class="checkbox-group" class:has-select-all={showSelectAll}>
<FancyForm on:change>
{#if showSelectAll}
<FancyCheckbox
bind:value={allSelected}
on:change={toggleSelectAll}
text={selectAllText}
indeterminate={!allSelected && !noneSelected}
compact
/>
{/if}
{#each options as option, i}
<FancyCheckbox bind:value={selectedBooleans[i]} text={option} compact />
{/each}
</FancyForm>
</div>
{/if}
<style>
.checkbox-group.has-select-all :global(.fancy-field:first-of-type) {
background: var(--spectrum-global-color-gray-100);
}
.checkbox-group.has-select-all :global(.fancy-field:first-of-type:hover) {
background: var(--spectrum-global-color-gray-200);
}
</style>

View File

@ -11,6 +11,7 @@
export let value export let value
export let ref export let ref
export let autoHeight export let autoHeight
export let compact = false
const formContext = getContext("fancy-form") const formContext = getContext("fancy-form")
const id = Math.random() const id = Math.random()
@ -42,6 +43,7 @@
class:disabled class:disabled
class:focused class:focused
class:clickable class:clickable
class:compact
class:auto-height={autoHeight} class:auto-height={autoHeight}
> >
<div class="content" on:click> <div class="content" on:click>
@ -61,7 +63,6 @@
<style> <style>
.fancy-field { .fancy-field {
max-width: 400px;
background: var(--spectrum-global-color-gray-75); background: var(--spectrum-global-color-gray-75);
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px; border-radius: 4px;
@ -69,6 +70,12 @@
transition: border-color 130ms ease-out, background 130ms ease-out, transition: border-color 130ms ease-out, background 130ms ease-out,
background 130ms ease-out; background 130ms ease-out;
color: var(--spectrum-global-color-gray-800); color: var(--spectrum-global-color-gray-800);
--padding: 16px;
--height: 64px;
}
.fancy-field.compact {
--padding: 8px;
--height: 36px;
} }
.fancy-field:hover { .fancy-field:hover {
border-color: var(--spectrum-global-color-gray-400); border-color: var(--spectrum-global-color-gray-400);
@ -91,8 +98,8 @@
} }
.content { .content {
position: relative; position: relative;
height: 64px; height: var(--height);
padding: 0 16px; padding: 0 var(--padding);
} }
.fancy-field.auto-height .content { .fancy-field.auto-height .content {
height: auto; height: auto;
@ -103,7 +110,7 @@
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: 16px; gap: var(--padding);
} }
.field { .field {
flex: 1 1 auto; flex: 1 1 auto;

View File

@ -4,4 +4,5 @@ export { default as FancySelect } from "./FancySelect.svelte"
export { default as FancyButton } from "./FancyButton.svelte" export { default as FancyButton } from "./FancyButton.svelte"
export { default as FancyForm } from "./FancyForm.svelte" export { default as FancyForm } from "./FancyForm.svelte"
export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte" export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte"
export { default as FancyCheckboxGroup } from "./FancyCheckboxGroup.svelte"
export { default as ErrorMessage } from "./ErrorMessage.svelte" export { default as ErrorMessage } from "./ErrorMessage.svelte"

View File

@ -9,6 +9,7 @@
export let text = null export let text = null
export let disabled = false export let disabled = false
export let size export let size
export let indeterminate = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = event => { const onChange = event => {
@ -22,6 +23,7 @@
class="spectrum-Checkbox spectrum-Checkbox--emphasized {sizeClass}" class="spectrum-Checkbox spectrum-Checkbox--emphasized {sizeClass}"
class:is-invalid={!!error} class:is-invalid={!!error}
class:checked={value} class:checked={value}
class:is-indeterminate={indeterminate}
> >
<input <input
checked={value} checked={value}

View File

@ -23,10 +23,11 @@ function prepareData(config) {
return datasource return datasource
} }
export async function saveDatasource(config, skipFetch = false) { export async function saveDatasource(config, { skipFetch, tablesFilter } = {}) {
const datasource = prepareData(config) const datasource = prepareData(config)
// Create datasource // Create datasource
const resp = await datasources.save(datasource, !skipFetch && datasource.plus) const fetchSchema = !skipFetch && datasource.plus
const resp = await datasources.save(datasource, { fetchSchema, tablesFilter })
// update the tables incase datasource plus // update the tables incase datasource plus
await tables.fetch() await tables.fetch()
@ -41,6 +42,13 @@ export async function createRestDatasource(integration) {
export async function validateDatasourceConfig(config) { export async function validateDatasourceConfig(config) {
const datasource = prepareData(config) const datasource = prepareData(config)
const resp = await API.validateDatasource(datasource) return await API.validateDatasource(datasource)
return resp }
export async function getDatasourceInfo(config) {
let datasource = config
if (!config._id) {
datasource = prepareData(config)
}
return await API.fetchInfoForDatasource(datasource)
} }

View File

@ -8,7 +8,7 @@
notifications, notifications,
Modal, Modal,
Table, Table,
Toggle, FancyCheckboxGroup,
} from "@budibase/bbui" } from "@budibase/bbui"
import { datasources, integrations, tables } from "stores/backend" import { datasources, integrations, tables } from "stores/backend"
import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte" import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte"
@ -16,7 +16,7 @@
import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte" import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ValuesList from "components/common/ValuesList.svelte" import { getDatasourceInfo } from "builderStore/datasource"
export let datasource export let datasource
export let save export let save
@ -34,7 +34,7 @@
let selectedFromRelationship, selectedToRelationship let selectedFromRelationship, selectedToRelationship
let confirmDialog let confirmDialog
let specificTables = null let specificTables = null
let requireSpecificTables = false let tableList
$: integration = datasource && $integrations[datasource.source] $: integration = datasource && $integrations[datasource.source]
$: plusTables = datasource?.plus $: plusTables = datasource?.plus
@ -153,30 +153,28 @@
warning={false} warning={false}
title="Confirm table fetch" title="Confirm table fetch"
> >
<Toggle
bind:value={requireSpecificTables}
on:change={e => {
requireSpecificTables = e.detail
specificTables = null
}}
thin
text="Fetch listed tables only (one per line)"
/>
{#if requireSpecificTables}
<ValuesList label="" bind:values={specificTables} />
{/if}
<br />
<Body> <Body>
If you have fetched tables from this database before, this action may If you have fetched tables from this database before, this action may
overwrite any changes you made after your initial fetch. overwrite any changes you made after your initial fetch.
</Body> </Body>
<br />
<div class="table-checkboxes">
<FancyCheckboxGroup options={tableList} bind:selected={specificTables} />
</div>
</ConfirmDialog> </ConfirmDialog>
<Divider /> <Divider />
<div class="query-header"> <div class="query-header">
<Heading size="S">Tables</Heading> <Heading size="S">Tables</Heading>
<div class="table-buttons"> <div class="table-buttons">
<Button secondary on:click={() => confirmDialog.show()}> <Button
secondary
on:click={async () => {
const info = await getDatasourceInfo(datasource)
tableList = info.tableNames
confirmDialog.show()
}}
>
Fetch tables Fetch tables
</Button> </Button>
<Button cta icon="Add" on:click={createNewTable}>New table</Button> <Button cta icon="Add" on:click={createNewTable}>New table</Button>
@ -246,4 +244,8 @@
display: flex; display: flex;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.table-checkboxes {
width: 100%;
}
</style> </style>

View File

@ -44,6 +44,9 @@ export default ICONS
export function getIcon(integrationType, schema) { export function getIcon(integrationType, schema) {
const integrationList = get(integrations) const integrationList = get(integrations)
if (!integrationList) {
return
}
if (integrationList[integrationType]?.iconUrl) { if (integrationList[integrationType]?.iconUrl) {
return { url: integrationList[integrationType].iconUrl } return { url: integrationList[integrationType].iconUrl }
} else if (schema?.custom || !ICONS[integrationType]) { } else if (schema?.custom || !ICONS[integrationType]) {

View File

@ -1,12 +1,19 @@
<script> <script>
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { ModalContent, notifications, Body, Layout } from "@budibase/bbui" import {
ModalContent,
notifications,
Body,
Layout,
FancyCheckboxGroup,
} from "@budibase/bbui"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte" import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import { IntegrationNames } from "constants/backend" import { IntegrationNames } from "constants/backend"
import cloneDeep from "lodash/cloneDeepWith" import cloneDeep from "lodash/cloneDeepWith"
import { import {
saveDatasource as save, saveDatasource as save,
validateDatasourceConfig, validateDatasourceConfig,
getDatasourceInfo,
} from "builderStore/datasource" } from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types" import { DatasourceFeature } from "@budibase/types"
@ -15,11 +22,24 @@
// kill the reference so the input isn't saved // kill the reference so the input isn't saved
let datasource = cloneDeep(integration) let datasource = cloneDeep(integration)
let isValid = false let isValid = false
let fetchTableStep = false
let selectedTables = []
let tableList = []
$: name = $: name =
IntegrationNames[datasource.type] || datasource.name || datasource.type IntegrationNames[datasource?.type] || datasource?.name || datasource?.type
$: datasourcePlus = datasource?.plus
$: title = fetchTableStep ? "Fetch your tables" : `Connect to ${name}`
$: confirmText = fetchTableStep
? "Continue"
: datasourcePlus
? "Connect"
: "Save and continue to query"
async function validateConfig() { async function validateConfig() {
if (!integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
return true
}
const displayError = message => const displayError = message =>
notifications.error(message ?? "Error validating datasource") notifications.error(message ?? "Error validating datasource")
@ -37,45 +57,79 @@
} }
async function saveDatasource() { async function saveDatasource() {
if (integration.features[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
return false
}
}
try { try {
if (!datasource.name) { if (!datasource.name) {
datasource.name = name datasource.name = name
} }
const resp = await save(datasource) const opts = {}
if (datasourcePlus && selectedTables) {
opts.tablesFilter = selectedTables
}
const resp = await save(datasource, opts)
$goto(`./datasource/${resp._id}`) $goto(`./datasource/${resp._id}`)
notifications.success(`Datasource created successfully.`) notifications.success("Datasource created successfully.")
} catch (err) { } catch (err) {
notifications.error(err?.message ?? "Error saving datasource") notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing // prevent the modal from closing
return false return false
} }
} }
async function nextStep() {
let connected = true
if (datasourcePlus) {
connected = await validateConfig()
}
if (!connected) {
return false
}
if (datasourcePlus && !fetchTableStep) {
notifications.success("Connected to datasource successfully.")
const info = await getDatasourceInfo(datasource)
tableList = info.tableNames
fetchTableStep = true
return false
} else {
await saveDatasource()
return true
}
}
</script> </script>
<ModalContent <ModalContent
title={`Connect to ${name}`} {title}
onConfirm={() => saveDatasource()} onConfirm={() => nextStep()}
confirmText={datasource.plus ? "Connect" : "Save and continue to query"} {confirmText}
cancelText="Back" cancelText={fetchTableStep ? "Cancel" : "Back"}
showSecondaryButton={datasource.plus} showSecondaryButton={datasourcePlus}
size="L" size="L"
disabled={!isValid} disabled={!isValid}
> >
<Layout noPadding> <Layout noPadding>
<Body size="XS" <Body size="XS">
>Connect your database to Budibase using the config below. {#if !fetchTableStep}
Connect your database to Budibase using the config below
{:else}
Choose what tables you want to sync with Budibase
{/if}
</Body> </Body>
</Layout> </Layout>
{#if !fetchTableStep}
<IntegrationConfigForm <IntegrationConfigForm
schema={datasource.schema} schema={datasource?.schema}
bind:datasource bind:datasource
creating={true} creating={true}
on:valid={e => (isValid = e.detail)} on:valid={e => (isValid = e.detail)}
/> />
{:else}
<div class="table-checkboxes">
<FancyCheckboxGroup options={tableList} bind:selected={selectedTables} />
</div>
{/if}
</ModalContent> </ModalContent>
<style>
.table-checkboxes {
width: 100%;
}
</style>

View File

@ -1,22 +1,27 @@
<script> <script>
import { import {
ModalContent,
Body, Body,
FancyCheckboxGroup,
InlineAlert,
Layout, Layout,
Link, Link,
ModalContent,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { IntegrationNames, IntegrationTypes } from "constants/backend" import { IntegrationNames, IntegrationTypes } from "constants/backend"
import GoogleButton from "../_components/GoogleButton.svelte" import GoogleButton from "../_components/GoogleButton.svelte"
import { organisation } from "stores/portal" import { organisation } from "stores/portal"
import { onMount } from "svelte" import { onDestroy, onMount } from "svelte"
import { validateDatasourceConfig } from "builderStore/datasource" import {
getDatasourceInfo,
saveDatasource,
validateDatasourceConfig,
} from "builderStore/datasource"
import cloneDeep from "lodash/cloneDeepWith" import cloneDeep from "lodash/cloneDeepWith"
import IntegrationConfigForm from "../TableIntegrationMenu/IntegrationConfigForm.svelte" import IntegrationConfigForm from "../TableIntegrationMenu/IntegrationConfigForm.svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { saveDatasource } from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types" import { DatasourceFeature } from "@budibase/types"
import { API } from "api"
export let integration export let integration
export let continueSetupId = false export let continueSetupId = false
@ -24,16 +29,20 @@
let datasource = cloneDeep(integration) let datasource = cloneDeep(integration)
datasource.config.continueSetupId = continueSetupId datasource.config.continueSetupId = continueSetupId
let { schema } = datasource
$: isGoogleConfigured = !!$organisation.googleDatasourceConfigured $: isGoogleConfigured = !!$organisation.googleDatasourceConfigured
onMount(async () => { onMount(async () => {
await organisation.init() await organisation.init()
}) })
const integrationName = IntegrationNames[IntegrationTypes.GOOGLE_SHEETS] const integrationName = IntegrationNames[IntegrationTypes.GOOGLE_SHEETS]
export const GoogleDatasouceConfigStep = { export const GoogleDatasouceConfigStep = {
AUTH: "Auth", AUTH: "auth",
SET_URL: "Set_url", SET_URL: "set_url",
SET_SHEETS: "set_sheets",
} }
let step = continueSetupId let step = continueSetupId
@ -42,12 +51,21 @@
let isValid = false let isValid = false
const modalConfig = { let allSheets
[GoogleDatasouceConfigStep.AUTH]: {}, let selectedSheets
let setSheetsErrorTitle, setSheetsErrorMessage
$: modalConfig = {
[GoogleDatasouceConfigStep.AUTH]: {
title: `Connect to ${integrationName}`,
},
[GoogleDatasouceConfigStep.SET_URL]: { [GoogleDatasouceConfigStep.SET_URL]: {
title: `Connect your spreadsheet`,
confirmButtonText: "Connect", confirmButtonText: "Connect",
onConfirm: async () => { onConfirm: async () => {
if (integration.features[DatasourceFeature.CONNECTION_CHECKING]) { const checkConnection =
integration.features[DatasourceFeature.CONNECTION_CHECKING]
if (checkConnection) {
const resp = await validateDatasourceConfig(datasource) const resp = await validateDatasourceConfig(datasource)
if (!resp.connected) { if (!resp.connected) {
notifications.error(`Unable to connect - ${resp.error}`) notifications.error(`Unable to connect - ${resp.error}`)
@ -56,21 +74,81 @@
} }
try { try {
const resp = await saveDatasource(datasource) datasource = await saveDatasource(datasource, {
$goto(`./datasource/${resp._id}`) tablesFilter: selectedSheets,
notifications.success(`Datasource created successfully.`) skipFetch: true,
})
} catch (err) { } catch (err) {
notifications.error(err?.message ?? "Error saving datasource") notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing // prevent the modal from closing
return false return false
} }
if (!integration.features[DatasourceFeature.FETCH_TABLE_NAMES]) {
notifications.success(`Datasource created successfully.`)
return
}
const info = await getDatasourceInfo(datasource)
allSheets = info.tableNames
step = GoogleDatasouceConfigStep.SET_SHEETS
notifications.success(
checkConnection
? "Connection Successful"
: `Datasource created successfully.`
)
// prevent the modal from closing
return false
},
},
[GoogleDatasouceConfigStep.SET_SHEETS]: {
title: `Choose your sheets`,
confirmButtonText: selectedSheets?.length
? "Fetch sheets"
: "Continue without fetching",
onConfirm: async () => {
try {
if (selectedSheets.length) {
await API.buildDatasourceSchema({
datasourceId: datasource._id,
tablesFilter: selectedSheets,
})
}
return
} catch (err) {
const message = err?.message ?? "Error fetching the sheets"
// Handling message with format: Error title - error description
const indexSeparator = message.indexOf(" - ")
if (indexSeparator >= 0) {
setSheetsErrorTitle = message.substr(0, indexSeparator)
setSheetsErrorMessage =
message[indexSeparator + 3].toUpperCase() +
message.substr(indexSeparator + 4)
} else {
setSheetsErrorTitle = null
setSheetsErrorMessage = message
}
// prevent the modal from closing
return false
}
}, },
}, },
} }
// This will handle the user closing the modal pressing outside the modal
onDestroy(() => {
if (step === GoogleDatasouceConfigStep.SET_SHEETS) {
$goto(`./datasource/${datasource._id}`)
}
})
</script> </script>
<ModalContent <ModalContent
title={`Connect to ${integrationName}`} title={modalConfig[step].title}
cancelText="Cancel" cancelText="Cancel"
size="L" size="L"
confirmText={modalConfig[step].confirmButtonText} confirmText={modalConfig[step].confirmButtonText}
@ -100,11 +178,30 @@
<Body size="S">Add the URL of the sheet you want to connect.</Body> <Body size="S">Add the URL of the sheet you want to connect.</Body>
<IntegrationConfigForm <IntegrationConfigForm
schema={datasource.schema} {schema}
bind:datasource bind:datasource
creating={true} creating={true}
on:valid={e => (isValid = e.detail)} on:valid={e => (isValid = e.detail)}
/> />
</Layout> </Layout>
{/if} {/if}
{#if step === GoogleDatasouceConfigStep.SET_SHEETS}
<Layout noPadding no>
<Body size="S">Select which spreadsheets you want to connect.</Body>
<FancyCheckboxGroup
options={allSheets}
bind:selected={selectedSheets}
selectAllText="Select all sheets"
/>
{#if setSheetsErrorTitle || setSheetsErrorMessage}
<InlineAlert
type="error"
header={setSheetsErrorTitle}
message={setSheetsErrorMessage}
/>
{/if}
</Layout>
{/if}
</ModalContent> </ModalContent>

View File

@ -5,6 +5,7 @@
Body, Body,
InlineAlert, InlineAlert,
Input, Input,
notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
@ -50,14 +51,49 @@
}, },
} }
const exportApp = password => { const exportApp = async () => {
const id = published ? app.prodId : app.devId const id = published ? app.prodId : app.devId
const appName = encodeURIComponent(app.name) const url = `/api/backups/export?appId=${id}`
let url = `/api/backups/export?appId=${id}&appname=${appName}&excludeRows=${!includeInternalTablesRows}` await downloadFile(url, { excludeRows, encryptPassword: password })
if (password) { }
url += `&encryptPassword=${password}`
export async function downloadFile(url, body) {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (response.ok) {
const contentDisposition = response.headers.get("Content-Disposition")
const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition
)
const filename = matches[1].replace(/['"]/g, "")
const url = URL.createObjectURL(await response.blob())
const link = document.createElement("a")
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
} else {
notifications.error("Error exporting the app.")
}
} catch (error) {
let message = "Error downloading the exported app"
if (error.message) {
message += `: ${error.message}`
}
notifications.error("Error downloading the exported app", message)
} }
window.location = url
} }
</script> </script>

View File

@ -57,7 +57,10 @@ export function createDatasourcesStore() {
return updateDatasource(response) return updateDatasource(response)
} }
const save = async (body, fetchSchema = false) => { const save = async (body, { fetchSchema, tablesFilter } = {}) => {
if (fetchSchema == null) {
fetchSchema = false
}
let response let response
if (body._id) { if (body._id) {
response = await API.updateDatasource(body) response = await API.updateDatasource(body)
@ -65,6 +68,7 @@ export function createDatasourcesStore() {
response = await API.createDatasource({ response = await API.createDatasource({
datasource: body, datasource: body,
fetchSchema, fetchSchema,
tablesFilter,
}) })
} }
return updateDatasource(response) return updateDatasource(response)

View File

@ -26,13 +26,16 @@ export const buildDatasourceEndpoints = API => ({
* Creates a datasource * Creates a datasource
* @param datasource the datasource to create * @param datasource the datasource to create
* @param fetchSchema whether to fetch the schema or not * @param fetchSchema whether to fetch the schema or not
* @param tablesFilter a list of tables to actually fetch rather than simply
* all that are accessible.
*/ */
createDatasource: async ({ datasource, fetchSchema }) => { createDatasource: async ({ datasource, fetchSchema, tablesFilter }) => {
return await API.post({ return await API.post({
url: "/api/datasources", url: "/api/datasources",
body: { body: {
datasource, datasource,
fetchSchema, fetchSchema,
tablesFilter,
}, },
}) })
}, },
@ -69,4 +72,15 @@ export const buildDatasourceEndpoints = API => ({
body: { datasource }, body: { datasource },
}) })
}, },
/**
* Fetch table names available within the datasource, for filtering out undesired tables
* @param datasource the datasource configuration to use for fetching tables
*/
fetchInfoForDatasource: async datasource => {
return await API.post({
url: `/api/datasources/info`,
body: { datasource },
})
},
}) })

View File

@ -1,17 +1,25 @@
import sdk from "../../sdk" import sdk from "../../sdk"
import { events, context } from "@budibase/backend-core" import { events, context, db } from "@budibase/backend-core"
import { DocumentType } from "../../db/utils" import { DocumentType } from "../../db/utils"
import { isQsTrue } from "../../utilities" import { Ctx } from "@budibase/types"
interface ExportAppDumpRequest {
excludeRows: boolean
encryptPassword?: string
}
export async function exportAppDump(ctx: Ctx<ExportAppDumpRequest>) {
const { appId } = ctx.query as any
const { excludeRows, encryptPassword } = ctx.request.body
const [app] = await db.getAppsByIDs([appId])
const appName = app.name
export async function exportAppDump(ctx: any) {
let { appId, excludeRows = false, encryptPassword } = ctx.query
// remove the 120 second limit for the request // remove the 120 second limit for the request
ctx.req.setTimeout(0) ctx.req.setTimeout(0)
const appName = decodeURI(ctx.query.appname)
excludeRows = isQsTrue(excludeRows) const extension = encryptPassword ? "enc.tar.gz" : "tar.gz"
const backupIdentifier = `${appName}-export-${new Date().getTime()}${ const backupIdentifier = `${appName}-export-${new Date().getTime()}.${extension}`
encryptPassword ? "-enc" : ""
}.tar.gz`
ctx.attachment(backupIdentifier) ctx.attachment(backupIdentifier)
ctx.body = await sdk.backups.streamExportApp({ ctx.body = await sdk.backups.streamExportApp({
appId, appId,

View File

@ -103,6 +103,22 @@ async function buildSchemaHelper(datasource: Datasource) {
return { tables: connector.tables, error } return { tables: connector.tables, error }
} }
async function buildFilteredSchema(datasource: Datasource, filter?: string[]) {
let { tables, error } = await buildSchemaHelper(datasource)
let finalTables = tables
if (filter) {
finalTables = {}
for (let key in tables) {
if (
filter.some((filter: any) => filter.toLowerCase() === key.toLowerCase())
) {
finalTables[key] = tables[key]
}
}
}
return { tables: finalTables, error }
}
export async function fetch(ctx: UserCtx) { export async function fetch(ctx: UserCtx) {
// Get internal tables // Get internal tables
const db = context.getAppDB() const db = context.getAppDB()
@ -174,43 +190,28 @@ export async function information(
} }
const tableNames = await connector.getTableNames() const tableNames = await connector.getTableNames()
ctx.body = { ctx.body = {
tableNames, tableNames: tableNames.sort(),
} }
} }
export async function buildSchemaFromDb(ctx: UserCtx) { export async function buildSchemaFromDb(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const datasource = await sdk.datasources.get(ctx.params.datasourceId)
const tablesFilter = ctx.request.body.tablesFilter const tablesFilter = ctx.request.body.tablesFilter
const datasource = await sdk.datasources.get(ctx.params.datasourceId)
let { tables, error } = await buildSchemaHelper(datasource) const { tables, error } = await buildFilteredSchema(datasource, tablesFilter)
if (tablesFilter) {
if (!datasource.entities) {
datasource.entities = {}
}
for (let key in tables) {
if (
tablesFilter.some(
(filter: any) => filter.toLowerCase() === key.toLowerCase()
)
) {
datasource.entities[key] = tables[key]
}
}
} else {
datasource.entities = tables datasource.entities = tables
}
setDefaultDisplayColumns(datasource) setDefaultDisplayColumns(datasource)
const dbResp = await db.put(datasource) const dbResp = await db.put(datasource)
datasource._rev = dbResp.rev datasource._rev = dbResp.rev
const cleanedDatasource = await sdk.datasources.removeSecretSingle(datasource) const cleanedDatasource = await sdk.datasources.removeSecretSingle(datasource)
const response: any = { datasource: cleanedDatasource } const res: any = { datasource: cleanedDatasource }
if (error) { if (error) {
response.error = error res.error = error
} }
ctx.body = response ctx.body = res
} }
/** /**
@ -320,6 +321,7 @@ export async function save(
const db = context.getAppDB() const db = context.getAppDB()
const plus = ctx.request.body.datasource.plus const plus = ctx.request.body.datasource.plus
const fetchSchema = ctx.request.body.fetchSchema const fetchSchema = ctx.request.body.fetchSchema
const tablesFilter = ctx.request.body.tablesFilter
const datasource = { const datasource = {
_id: generateDatasourceID({ plus }), _id: generateDatasourceID({ plus }),
@ -329,7 +331,10 @@ export async function save(
let schemaError = null let schemaError = null
if (fetchSchema) { if (fetchSchema) {
const { tables, error } = await buildSchemaHelper(datasource) const { tables, error } = await buildFilteredSchema(
datasource,
tablesFilter
)
schemaError = error schemaError = error
datasource.entities = tables datasource.entities = tables
setDefaultDisplayColumns(datasource) setDefaultDisplayColumns(datasource)

View File

@ -5,7 +5,7 @@ import { permissions } from "@budibase/backend-core"
const router: Router = new Router() const router: Router = new Router()
router.get( router.post(
"/api/backups/export", "/api/backups/export",
authorized(permissions.BUILDER), authorized(permissions.BUILDER),
controller.exportAppDump controller.exportAppDump

View File

@ -26,6 +26,10 @@ export default function process(updateCb?: UpdateCallback) {
// if something not found - no changes to perform // if something not found - no changes to perform
if (err?.status === 404) { if (err?.status === 404) {
return return
}
// The user has already been sync in another process
else if (err?.status === 409) {
return
} else { } else {
logging.logAlert("Failed to perform user/group app sync", err) logging.logAlert("Failed to perform user/group app sync", err)
} }

View File

@ -21,7 +21,7 @@ import { buildExternalTableId, finaliseExternalTables } from "./utils"
import { GoogleSpreadsheet, GoogleSpreadsheetRow } from "google-spreadsheet" import { GoogleSpreadsheet, GoogleSpreadsheetRow } from "google-spreadsheet"
import fetch from "node-fetch" import fetch from "node-fetch"
import { cache, configs, context, HTTPError } from "@budibase/backend-core" import { cache, configs, context, HTTPError } from "@budibase/backend-core"
import { dataFilters } from "@budibase/shared-core" import { dataFilters, utils } from "@budibase/shared-core"
import { GOOGLE_SHEETS_PRIMARY_KEY } from "../constants" import { GOOGLE_SHEETS_PRIMARY_KEY } from "../constants"
import sdk from "../sdk" import sdk from "../sdk"
@ -150,7 +150,6 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async testConnection(): Promise<ConnectionInfo> { async testConnection(): Promise<ConnectionInfo> {
try { try {
await setupCreationAuth(this.config)
await this.connect() await this.connect()
return { connected: true } return { connected: true }
} catch (e: any) { } catch (e: any) {
@ -211,6 +210,8 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async connect() { async connect() {
try { try {
await setupCreationAuth(this.config)
// Initialise oAuth client // Initialise oAuth client
let googleConfig = await configs.getGoogleDatasourceConfig() let googleConfig = await configs.getGoogleDatasourceConfig()
if (!googleConfig) { if (!googleConfig) {
@ -273,16 +274,14 @@ class GoogleSheetsIntegration implements DatasourcePlus {
} }
async buildSchema(datasourceId: string, entities: Record<string, Table>) { async buildSchema(datasourceId: string, entities: Record<string, Table>) {
// not fully configured yet
if (!this.config.auth) {
return
}
await this.connect() await this.connect()
const sheets = this.client.sheetsByIndex const sheets = this.client.sheetsByIndex
const tables: Record<string, Table> = {} const tables: Record<string, Table> = {}
for (let sheet of sheets) { await utils.parallelForeach(
sheets,
async sheet => {
// must fetch rows to determine schema // must fetch rows to determine schema
await sheet.getRows() await sheet.getRows({ limit: 0, offset: 0 })
const id = buildExternalTableId(datasourceId, sheet.title) const id = buildExternalTableId(datasourceId, sheet.title)
tables[sheet.title] = this.getTableSchema( tables[sheet.title] = this.getTableSchema(
@ -290,7 +289,9 @@ class GoogleSheetsIntegration implements DatasourcePlus {
sheet.headerValues, sheet.headerValues,
id id
) )
} },
10
)
const final = finaliseExternalTables(tables, entities) const final = finaliseExternalTables(tables, entities)
this.tables = final.tables this.tables = final.tables
this.schemaErrors = final.errors this.schemaErrors = final.errors

View File

@ -322,7 +322,8 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
await this.openConnection() await this.openConnection()
const columnsResponse: { rows: PostgresColumn[] } = const columnsResponse: { rows: PostgresColumn[] } =
await this.client.query(this.COLUMNS_SQL) await this.client.query(this.COLUMNS_SQL)
return columnsResponse.rows.map(row => row.table_name) const names = columnsResponse.rows.map(row => row.table_name)
return [...new Set(names)]
} finally { } finally {
await this.closeConnection() await this.closeConnection()
} }

View File

@ -187,7 +187,7 @@ export async function streamExportApp({
}: { }: {
appId: string appId: string
excludeRows: boolean excludeRows: boolean
encryptPassword: string encryptPassword?: string
}) { }) {
const tmpPath = await exportApp(appId, { const tmpPath = await exportApp(appId, {
excludeRows, excludeRows,

View File

@ -164,5 +164,6 @@ export function mergeConfigs(update: Datasource, old: Datasource) {
delete update.config[key] delete update.config[key]
} }
} }
return update return update
} }

View File

@ -4,3 +4,42 @@ export function unreachable(
) { ) {
throw new Error(message) throw new Error(message)
} }
export async function parallelForeach<T>(
items: T[],
task: (item: T) => Promise<void>,
maxConcurrency: number
): Promise<void> {
const promises: Promise<void>[] = []
let index = 0
const processItem = async (item: T) => {
try {
await task(item)
} finally {
processNext()
}
}
const processNext = () => {
if (index >= items.length) {
// No more items to process
return
}
const item = items[index]
index++
const promise = processItem(item)
promises.push(promise)
if (promises.length >= maxConcurrency) {
Promise.race(promises).then(processNext)
} else {
processNext()
}
}
processNext()
await Promise.all(promises)
}

View File

@ -12,6 +12,7 @@ export interface UpdateDatasourceResponse {
export interface CreateDatasourceRequest { export interface CreateDatasourceRequest {
datasource: Datasource datasource: Datasource
fetchSchema?: boolean fetchSchema?: boolean
tablesFilter: string[]
} }
export interface VerifyDatasourceRequest { export interface VerifyDatasourceRequest {

View File

@ -15,6 +15,12 @@ async function generateReport() {
return JSON.parse(report) return JSON.parse(report)
} }
const env = process.argv.slice(2)[0]
if (!env) {
throw new Error("environment argument is required")
}
async function discordResultsNotification(report) { async function discordResultsNotification(report) {
const { const {
numTotalTestSuites, numTotalTestSuites,
@ -39,8 +45,8 @@ async function discordResultsNotification(report) {
content: `**Nightly Tests Status**: ${OUTCOME}`, content: `**Nightly Tests Status**: ${OUTCOME}`,
embeds: [ embeds: [
{ {
title: "Budi QA Bot", title: `Budi QA Bot - ${env}`,
description: `Nightly Tests`, description: `API Integration Tests`,
url: GITHUB_ACTIONS_RUN_URL, url: GITHUB_ACTIONS_RUN_URL,
color: OUTCOME === "success" ? 3066993 : 15548997, color: OUTCOME === "success" ? 3066993 : 15548997,
timestamp: new Date(), timestamp: new Date(),

View File

@ -60,8 +60,16 @@ export default class AccountAPI {
} }
async delete(accountID: string) { async delete(accountID: string) {
const [response, json] = await this.client.del(`/api/accounts/${accountID}`) const [response, json] = await this.client.del(
expect(response).toHaveStatusCode(200) `/api/accounts/${accountID}`,
{
internal: true,
}
)
// can't use expect here due to use in global teardown
if (response.status !== 204) {
throw new Error(`Could not delete accountId=${accountID}`)
}
return response return response
} }
} }

View File

@ -10,6 +10,7 @@ const API_OPTS: APIRequestOpts = { doExpect: false }
async function deleteAccount() { async function deleteAccount() {
// @ts-ignore // @ts-ignore
const accountID = global.qa.accountId const accountID = global.qa.accountId
// can't run 'expect' blocks in teardown
await accountsApi.accounts.delete(accountID) await accountsApi.accounts.delete(accountID)
} }