mirror of https://github.com/Budibase/budibase.git
committed by
GitHub
110 changed files with 3822 additions and 751 deletions
@ -0,0 +1,46 @@ |
|||
<script> |
|||
import { createEventDispatcher } from "svelte" |
|||
import { ActionButton, Modal, ModalContent } from "@budibase/bbui" |
|||
import FilterDrawer from "components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte" |
|||
|
|||
export let schema |
|||
export let filters |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
let modal |
|||
let tempValue = filters || [] |
|||
|
|||
$: schemaFields = Object.values(schema || {}) |
|||
</script> |
|||
|
|||
<ActionButton |
|||
icon="Filter" |
|||
size="S" |
|||
quiet |
|||
on:click={modal.show} |
|||
active={tempValue?.length > 0} |
|||
> |
|||
Filter |
|||
</ActionButton> |
|||
<Modal bind:this={modal}> |
|||
<ModalContent |
|||
title="Filter" |
|||
confirmText="Save" |
|||
size="XL" |
|||
onConfirm={() => dispatch("change", tempValue)} |
|||
> |
|||
<div class="wrapper"> |
|||
<FilterDrawer |
|||
allowBindings={false} |
|||
bind:filters={tempValue} |
|||
{schemaFields} |
|||
/> |
|||
</div> |
|||
</ModalContent> |
|||
</Modal> |
|||
|
|||
<style> |
|||
.wrapper :global(.main) { |
|||
padding: 0; |
|||
} |
|||
</style> |
|||
@ -1,74 +1,160 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
import { datasources } from "stores/backend" |
|||
import { notifications } from "@budibase/bbui" |
|||
import { Input, Label, ModalContent, Modal, Context } from "@budibase/bbui" |
|||
import TableIntegrationMenu from "../TableIntegrationMenu/index.svelte" |
|||
import { ModalContent, Modal, Body, Layout, Detail } from "@budibase/bbui" |
|||
import { onMount } from "svelte" |
|||
import ICONS from "../icons" |
|||
import api from "builderStore/api" |
|||
import { IntegrationNames } from "constants" |
|||
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte" |
|||
import analytics, { Events } from "analytics" |
|||
import { getContext } from "svelte" |
|||
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte" |
|||
|
|||
const modalContext = getContext(Context.Modal) |
|||
export let modal |
|||
let integrations = [] |
|||
let integration = {} |
|||
let internalTableModal |
|||
let externalDatasourceModal |
|||
|
|||
let tableModal |
|||
let name |
|||
let error = "" |
|||
let integration |
|||
const INTERNAL = "BUDIBASE" |
|||
|
|||
$: checkOpenModal(integration && integration.type === "BUDIBASE") |
|||
onMount(() => { |
|||
fetchIntegrations() |
|||
}) |
|||
|
|||
function checkValid(evt) { |
|||
const datasourceName = evt.target.value |
|||
if ( |
|||
$datasources?.list.some(datasource => datasource.name === datasourceName) |
|||
) { |
|||
error = `Datasource with name ${datasourceName} already exists. Please choose another name.` |
|||
return |
|||
function selectIntegration(integrationType) { |
|||
const selected = integrations[integrationType] |
|||
|
|||
// build the schema |
|||
const config = {} |
|||
for (let key of Object.keys(selected.datasource)) { |
|||
config[key] = selected.datasource[key].default |
|||
} |
|||
integration = { |
|||
type: integrationType, |
|||
plus: selected.plus, |
|||
config, |
|||
schema: selected.datasource, |
|||
} |
|||
error = "" |
|||
} |
|||
|
|||
function checkOpenModal(isInternal) { |
|||
if (isInternal) { |
|||
tableModal.show() |
|||
function chooseNextModal() { |
|||
if (integration.type === INTERNAL) { |
|||
externalDatasourceModal.hide() |
|||
internalTableModal.show() |
|||
} else { |
|||
externalDatasourceModal.show() |
|||
} |
|||
} |
|||
|
|||
async function saveDatasource() { |
|||
const { type, plus, ...config } = integration |
|||
|
|||
// Create datasource |
|||
const response = await datasources.save({ |
|||
name, |
|||
source: type, |
|||
config, |
|||
plus, |
|||
}) |
|||
notifications.success(`Datasource ${name} created successfully.`) |
|||
analytics.captureEvent(Events.DATASOURCE.CREATED, { name, type }) |
|||
|
|||
// Navigate to new datasource |
|||
$goto(`./datasource/${response._id}`) |
|||
async function fetchIntegrations() { |
|||
const response = await api.get("/api/integrations") |
|||
const json = await response.json() |
|||
integrations = { |
|||
[INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" }, |
|||
...json, |
|||
} |
|||
return json |
|||
} |
|||
</script> |
|||
|
|||
<Modal bind:this={tableModal} on:hide={modalContext.hide}> |
|||
<CreateTableModal bind:name /> |
|||
<Modal bind:this={internalTableModal}> |
|||
<CreateTableModal /> |
|||
</Modal> |
|||
|
|||
<Modal bind:this={externalDatasourceModal}> |
|||
<DatasourceConfigModal {integration} /> |
|||
</Modal> |
|||
<ModalContent |
|||
title="Create Datasource" |
|||
size="L" |
|||
confirmText="Create" |
|||
onConfirm={saveDatasource} |
|||
disabled={error || !name || !integration?.type} |
|||
> |
|||
<Input |
|||
data-cy="datasource-name-input" |
|||
label="Datasource Name" |
|||
on:input={checkValid} |
|||
bind:value={name} |
|||
{error} |
|||
/> |
|||
<Label>Datasource Type</Label> |
|||
<TableIntegrationMenu bind:integration /> |
|||
</ModalContent> |
|||
|
|||
<Modal bind:this={modal}> |
|||
<ModalContent |
|||
disabled={!Object.keys(integration).length} |
|||
title="Data" |
|||
confirmText="Continue" |
|||
showCancelButton={false} |
|||
size="M" |
|||
onConfirm={() => { |
|||
chooseNextModal() |
|||
}} |
|||
> |
|||
<Layout noPadding> |
|||
<Body size="XS" |
|||
>All apps need data. You can connect to a data source below, or add data |
|||
to your app using Budibase's built-in database. |
|||
</Body> |
|||
<div |
|||
class:selected={integration.type === INTERNAL} |
|||
on:click={() => selectIntegration(INTERNAL)} |
|||
class="item hoverable" |
|||
> |
|||
<div class="item-body"> |
|||
<svelte:component this={ICONS.BUDIBASE} height="18" width="18" /> |
|||
<span class="icon-spacing"> <Body size="S">Budibase DB</Body></span> |
|||
</div> |
|||
</div> |
|||
</Layout> |
|||
|
|||
<Layout gap="XS" noPadding> |
|||
<div class="title-spacing"> |
|||
<Detail size="S">Connect to data source</Detail> |
|||
</div> |
|||
<div class="item-list"> |
|||
{#each Object.entries(integrations).filter(([key]) => key !== INTERNAL) as [integrationType, schema]} |
|||
<div |
|||
class:selected={integration.type === integrationType} |
|||
on:click={() => selectIntegration(integrationType)} |
|||
class="item hoverable" |
|||
> |
|||
<div class="item-body"> |
|||
<svelte:component |
|||
this={ICONS[integrationType]} |
|||
height="18" |
|||
width="18" |
|||
/> |
|||
|
|||
<span class="icon-spacing"> |
|||
<Body size="S" |
|||
>{schema.name || IntegrationNames[integrationType]}</Body |
|||
></span |
|||
> |
|||
</div> |
|||
</div> |
|||
{/each} |
|||
</div> |
|||
</Layout> |
|||
</ModalContent> |
|||
</Modal> |
|||
|
|||
<style> |
|||
.icon-spacing { |
|||
margin-left: var(--spacing-m); |
|||
} |
|||
.item-body { |
|||
display: flex; |
|||
margin-left: var(--spacing-m); |
|||
} |
|||
.item-list { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); |
|||
grid-gap: var(--spectrum-alias-grid-baseline); |
|||
} |
|||
|
|||
.item { |
|||
cursor: pointer; |
|||
display: grid; |
|||
grid-gap: var(--spectrum-alias-grid-margin-xsmall); |
|||
padding: var(--spectrum-alias-item-padding-s); |
|||
background: var(--spectrum-alias-background-color-secondary); |
|||
transition: 0.3s all; |
|||
border: solid var(--spectrum-alias-border-color); |
|||
border-radius: 5px; |
|||
box-sizing: border-box; |
|||
border-width: 2px; |
|||
} |
|||
|
|||
.selected { |
|||
background: var(--spectrum-alias-background-color-tertiary); |
|||
} |
|||
|
|||
.item:hover, |
|||
.selected { |
|||
background: var(--spectrum-alias-background-color-tertiary); |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,72 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
import { ModalContent, notifications, Body, Layout } from "@budibase/bbui" |
|||
import analytics, { Events } from "analytics" |
|||
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte" |
|||
import { datasources } from "stores/backend" |
|||
import { IntegrationNames } from "constants" |
|||
|
|||
export let integration |
|||
|
|||
function prepareData() { |
|||
let datasource = {} |
|||
let existingTypeCount = $datasources.list.filter( |
|||
ds => ds.source == integration.type |
|||
).length |
|||
|
|||
let baseName = IntegrationNames[integration.type] |
|||
let name = |
|||
existingTypeCount == 0 ? baseName : `${baseName}-${existingTypeCount + 1}` |
|||
|
|||
datasource.type = "datasource" |
|||
datasource.source = integration.type |
|||
datasource.config = integration.config |
|||
datasource.name = name |
|||
datasource.plus = integration.plus |
|||
|
|||
return datasource |
|||
} |
|||
async function saveDatasource() { |
|||
const datasource = prepareData() |
|||
try { |
|||
// Create datasource |
|||
const resp = await datasources.save(datasource, datasource.plus) |
|||
|
|||
await datasources.select(resp._id) |
|||
$goto(`./datasource/${resp._id}`) |
|||
notifications.success(`Datasource updated successfully.`) |
|||
analytics.captureEvent(Events.DATASOURCE.CREATED, { |
|||
name: resp.name, |
|||
source: resp.source, |
|||
}) |
|||
return true |
|||
} catch (err) { |
|||
notifications.error(`Error saving datasource: ${err}`) |
|||
return false |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<ModalContent |
|||
title={`Connect to ${IntegrationNames[integration.type]}`} |
|||
onConfirm={() => saveDatasource()} |
|||
confirmText={integration.plus |
|||
? "Fetch tables from database" |
|||
: "Save and continue to query"} |
|||
cancelText="Back" |
|||
size="M" |
|||
> |
|||
<Layout noPadding> |
|||
<Body size="XS" |
|||
>Connect your database to Budibase using the config below. |
|||
</Body> |
|||
</Layout> |
|||
|
|||
<IntegrationConfigForm |
|||
schema={integration.schema} |
|||
bind:integration={integration.config} |
|||
/> |
|||
</ModalContent> |
|||
|
|||
<style> |
|||
</style> |
|||
@ -0,0 +1,97 @@ |
|||
/** |
|||
* Operator options for lucene queries |
|||
*/ |
|||
export const OperatorOptions = { |
|||
Equals: { |
|||
value: "equal", |
|||
label: "Equals", |
|||
}, |
|||
NotEquals: { |
|||
value: "notEqual", |
|||
label: "Not equals", |
|||
}, |
|||
Empty: { |
|||
value: "empty", |
|||
label: "Is empty", |
|||
}, |
|||
NotEmpty: { |
|||
value: "notEmpty", |
|||
label: "Is not empty", |
|||
}, |
|||
StartsWith: { |
|||
value: "string", |
|||
label: "Starts with", |
|||
}, |
|||
Like: { |
|||
value: "fuzzy", |
|||
label: "Like", |
|||
}, |
|||
MoreThan: { |
|||
value: "rangeLow", |
|||
label: "More than", |
|||
}, |
|||
LessThan: { |
|||
value: "rangeHigh", |
|||
label: "Less than", |
|||
}, |
|||
Contains: { |
|||
value: "equal", |
|||
label: "Contains", |
|||
}, |
|||
NotContains: { |
|||
value: "notEqual", |
|||
label: "Does Not Contain", |
|||
}, |
|||
} |
|||
|
|||
/** |
|||
* Returns the valid operator options for a certain data type |
|||
* @param type the data type |
|||
*/ |
|||
export const getValidOperatorsForType = type => { |
|||
const Op = OperatorOptions |
|||
if (type === "string") { |
|||
return [ |
|||
Op.Equals, |
|||
Op.NotEquals, |
|||
Op.StartsWith, |
|||
Op.Like, |
|||
Op.Empty, |
|||
Op.NotEmpty, |
|||
] |
|||
} else if (type === "number") { |
|||
return [ |
|||
Op.Equals, |
|||
Op.NotEquals, |
|||
Op.MoreThan, |
|||
Op.LessThan, |
|||
Op.Empty, |
|||
Op.NotEmpty, |
|||
] |
|||
} else if (type === "options") { |
|||
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty] |
|||
} else if (type === "array") { |
|||
return [Op.Contains, Op.NotContains, Op.Empty, Op.NotEmpty] |
|||
} else if (type === "boolean") { |
|||
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty] |
|||
} else if (type === "longform") { |
|||
return [ |
|||
Op.Equals, |
|||
Op.NotEquals, |
|||
Op.StartsWith, |
|||
Op.Like, |
|||
Op.Empty, |
|||
Op.NotEmpty, |
|||
] |
|||
} else if (type === "datetime") { |
|||
return [ |
|||
Op.Equals, |
|||
Op.NotEquals, |
|||
Op.MoreThan, |
|||
Op.LessThan, |
|||
Op.Empty, |
|||
Op.NotEmpty, |
|||
] |
|||
} |
|||
return [] |
|||
} |
|||
@ -0,0 +1,206 @@ |
|||
// Do not use any aliased imports in common files, as these will be bundled
|
|||
// by multiple bundlers which may not be able to resolve them
|
|||
import { writable, derived, get } from "svelte/store" |
|||
import * as API from "../builderStore/api" |
|||
import { buildLuceneQuery } from "./lucene" |
|||
|
|||
const defaultOptions = { |
|||
tableId: null, |
|||
filters: null, |
|||
limit: 10, |
|||
sortColumn: null, |
|||
sortOrder: "ascending", |
|||
paginate: true, |
|||
schema: null, |
|||
} |
|||
|
|||
export const fetchTableData = opts => { |
|||
// Save option set so we can override it later rather than relying on params
|
|||
let options = { |
|||
...defaultOptions, |
|||
...opts, |
|||
} |
|||
|
|||
// Local non-observable state
|
|||
let query |
|||
let sortType |
|||
let lastBookmark |
|||
|
|||
// Local observable state
|
|||
const store = writable({ |
|||
rows: [], |
|||
schema: null, |
|||
loading: false, |
|||
loaded: false, |
|||
bookmarks: [], |
|||
pageNumber: 0, |
|||
}) |
|||
|
|||
// Derive certain properties to return
|
|||
const derivedStore = derived(store, $store => { |
|||
return { |
|||
...$store, |
|||
hasNextPage: $store.bookmarks[$store.pageNumber + 1] != null, |
|||
hasPrevPage: $store.pageNumber > 0, |
|||
} |
|||
}) |
|||
|
|||
const fetchPage = async bookmark => { |
|||
lastBookmark = bookmark |
|||
const { tableId, limit, sortColumn, sortOrder, paginate } = options |
|||
store.update($store => ({ ...$store, loading: true })) |
|||
const res = await API.post(`/api/${options.tableId}/search`, { |
|||
tableId, |
|||
query, |
|||
limit, |
|||
sort: sortColumn, |
|||
sortOrder: sortOrder?.toLowerCase() ?? "ascending", |
|||
sortType, |
|||
paginate, |
|||
bookmark, |
|||
}) |
|||
store.update($store => ({ ...$store, loading: false, loaded: true })) |
|||
return await res.json() |
|||
} |
|||
|
|||
// Fetches a fresh set of results from the server
|
|||
const fetchData = async () => { |
|||
const { tableId, schema, sortColumn, filters } = options |
|||
|
|||
// Ensure table ID exists
|
|||
if (!tableId) { |
|||
return |
|||
} |
|||
|
|||
// Get and enrich schema.
|
|||
// Ensure there are "name" properties for all fields and that field schema
|
|||
// are objects
|
|||
let enrichedSchema = schema |
|||
if (!enrichedSchema) { |
|||
const definition = await API.get(`/api/tables/${tableId}`) |
|||
enrichedSchema = definition?.schema ?? null |
|||
} |
|||
if (enrichedSchema) { |
|||
Object.entries(schema).forEach(([fieldName, fieldSchema]) => { |
|||
if (typeof fieldSchema === "string") { |
|||
enrichedSchema[fieldName] = { |
|||
type: fieldSchema, |
|||
name: fieldName, |
|||
} |
|||
} else { |
|||
enrichedSchema[fieldName] = { |
|||
...fieldSchema, |
|||
name: fieldName, |
|||
} |
|||
} |
|||
}) |
|||
|
|||
// Save fixed schema so we can provide it later
|
|||
options.schema = enrichedSchema |
|||
} |
|||
|
|||
// Ensure schema exists
|
|||
if (!schema) { |
|||
return |
|||
} |
|||
store.update($store => ({ ...$store, schema })) |
|||
|
|||
// Work out what sort type to use
|
|||
if (!sortColumn || !schema[sortColumn]) { |
|||
sortType = "string" |
|||
} |
|||
const type = schema?.[sortColumn]?.type |
|||
sortType = type === "number" ? "number" : "string" |
|||
|
|||
// Build the lucene query
|
|||
query = buildLuceneQuery(filters) |
|||
|
|||
// Actually fetch data
|
|||
const page = await fetchPage() |
|||
store.update($store => ({ |
|||
...$store, |
|||
loading: false, |
|||
loaded: true, |
|||
pageNumber: 0, |
|||
rows: page.rows, |
|||
bookmarks: page.hasNextPage ? [null, page.bookmark] : [null], |
|||
})) |
|||
} |
|||
|
|||
// Fetches the next page of data
|
|||
const nextPage = async () => { |
|||
const state = get(derivedStore) |
|||
if (state.loading || !options.paginate || !state.hasNextPage) { |
|||
return |
|||
} |
|||
|
|||
// Fetch next page
|
|||
const page = await fetchPage(state.bookmarks[state.pageNumber + 1]) |
|||
|
|||
// Update state
|
|||
store.update($store => { |
|||
let { bookmarks, pageNumber } = $store |
|||
if (page.hasNextPage) { |
|||
bookmarks[pageNumber + 2] = page.bookmark |
|||
} |
|||
return { |
|||
...$store, |
|||
pageNumber: pageNumber + 1, |
|||
rows: page.rows, |
|||
bookmarks, |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// Fetches the previous page of data
|
|||
const prevPage = async () => { |
|||
const state = get(derivedStore) |
|||
if (state.loading || !options.paginate || !state.hasPrevPage) { |
|||
return |
|||
} |
|||
|
|||
// Fetch previous page
|
|||
const page = await fetchPage(state.bookmarks[state.pageNumber - 1]) |
|||
|
|||
// Update state
|
|||
store.update($store => { |
|||
return { |
|||
...$store, |
|||
pageNumber: $store.pageNumber - 1, |
|||
rows: page.rows, |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// Resets the data set and updates options
|
|||
const update = async newOptions => { |
|||
if (newOptions) { |
|||
options = { |
|||
...options, |
|||
...newOptions, |
|||
} |
|||
} |
|||
await fetchData() |
|||
} |
|||
|
|||
// Loads the same page again
|
|||
const refresh = async () => { |
|||
if (get(store).loading) { |
|||
return |
|||
} |
|||
const page = await fetchPage(lastBookmark) |
|||
store.update($store => ({ ...$store, rows: page.rows })) |
|||
} |
|||
|
|||
// Initially fetch data but don't bother waiting for the result
|
|||
fetchData() |
|||
|
|||
// Return our derived store which will be updated over time
|
|||
return { |
|||
subscribe: derivedStore.subscribe, |
|||
nextPage, |
|||
prevPage, |
|||
update, |
|||
refresh, |
|||
} |
|||
} |
|||
@ -1,90 +1,179 @@ |
|||
export const OperatorOptions = { |
|||
Equals: { |
|||
value: "equal", |
|||
label: "Equals", |
|||
}, |
|||
NotEquals: { |
|||
value: "notEqual", |
|||
label: "Not equals", |
|||
}, |
|||
Empty: { |
|||
value: "empty", |
|||
label: "Is empty", |
|||
}, |
|||
NotEmpty: { |
|||
value: "notEmpty", |
|||
label: "Is not empty", |
|||
}, |
|||
StartsWith: { |
|||
value: "string", |
|||
label: "Starts with", |
|||
}, |
|||
Like: { |
|||
value: "fuzzy", |
|||
label: "Like", |
|||
}, |
|||
MoreThan: { |
|||
value: "rangeLow", |
|||
label: "More than", |
|||
}, |
|||
LessThan: { |
|||
value: "rangeHigh", |
|||
label: "Less than", |
|||
}, |
|||
Contains: { |
|||
value: "equal", |
|||
label: "Contains", |
|||
}, |
|||
NotContains: { |
|||
value: "notEqual", |
|||
label: "Does Not Contain", |
|||
}, |
|||
/** |
|||
* Builds a lucene JSON query from the filter structure generated in the builder |
|||
* @param filter the builder filter structure |
|||
*/ |
|||
export const buildLuceneQuery = filter => { |
|||
let query = { |
|||
string: {}, |
|||
fuzzy: {}, |
|||
range: {}, |
|||
equal: {}, |
|||
notEqual: {}, |
|||
empty: {}, |
|||
notEmpty: {}, |
|||
contains: {}, |
|||
notContains: {}, |
|||
} |
|||
if (Array.isArray(filter)) { |
|||
filter.forEach(expression => { |
|||
let { operator, field, type, value } = expression |
|||
// Parse all values into correct types
|
|||
if (type === "datetime" && value) { |
|||
value = new Date(value).toISOString() |
|||
} |
|||
if (type === "number") { |
|||
value = parseFloat(value) |
|||
} |
|||
if (type === "boolean") { |
|||
value = `${value}`?.toLowerCase() === "true" |
|||
} |
|||
if (operator.startsWith("range")) { |
|||
if (!query.range[field]) { |
|||
query.range[field] = { |
|||
low: |
|||
type === "number" |
|||
? Number.MIN_SAFE_INTEGER |
|||
: "0000-00-00T00:00:00.000Z", |
|||
high: |
|||
type === "number" |
|||
? Number.MAX_SAFE_INTEGER |
|||
: "9999-00-00T00:00:00.000Z", |
|||
} |
|||
} |
|||
if (operator === "rangeLow" && value != null && value !== "") { |
|||
query.range[field].low = value |
|||
} else if (operator === "rangeHigh" && value != null && value !== "") { |
|||
query.range[field].high = value |
|||
} |
|||
} else if (query[operator]) { |
|||
if (type === "boolean") { |
|||
// Transform boolean filters to cope with null.
|
|||
// "equals false" needs to be "not equals true"
|
|||
// "not equals false" needs to be "equals true"
|
|||
if (operator === "equal" && value === false) { |
|||
query.notEqual[field] = true |
|||
} else if (operator === "notEqual" && value === false) { |
|||
query.equal[field] = true |
|||
} else { |
|||
query[operator][field] = value |
|||
} |
|||
} else { |
|||
query[operator][field] = value |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
return query |
|||
} |
|||
|
|||
/** |
|||
* Performs a client-side lucene search on an array of data |
|||
* @param docs the data |
|||
* @param query the JSON lucene query |
|||
*/ |
|||
export const luceneQuery = (docs, query) => { |
|||
if (!query) { |
|||
return docs |
|||
} |
|||
|
|||
// Iterates over a set of filters and evaluates a fail function against a doc
|
|||
const match = (type, failFn) => doc => { |
|||
const filters = Object.entries(query[type] || {}) |
|||
for (let i = 0; i < filters.length; i++) { |
|||
if (failFn(filters[i][0], filters[i][1], doc)) { |
|||
return false |
|||
} |
|||
} |
|||
return true |
|||
} |
|||
|
|||
// Process a string match (fails if the value does not start with the string)
|
|||
const stringMatch = match("string", (key, value, doc) => { |
|||
return !doc[key] || !doc[key].startsWith(value) |
|||
}) |
|||
|
|||
// Process a fuzzy match (treat the same as starts with when running locally)
|
|||
const fuzzyMatch = match("fuzzy", (key, value, doc) => { |
|||
return !doc[key] || !doc[key].startsWith(value) |
|||
}) |
|||
|
|||
// Process a range match
|
|||
const rangeMatch = match("range", (key, value, doc) => { |
|||
return !doc[key] || doc[key] < value.low || doc[key] > value.high |
|||
}) |
|||
|
|||
// Process an equal match (fails if the value is different)
|
|||
const equalMatch = match("equal", (key, value, doc) => { |
|||
return value != null && value !== "" && doc[key] !== value |
|||
}) |
|||
|
|||
// Process a not-equal match (fails if the value is the same)
|
|||
const notEqualMatch = match("notEqual", (key, value, doc) => { |
|||
return value != null && value !== "" && doc[key] === value |
|||
}) |
|||
|
|||
// Process an empty match (fails if the value is not empty)
|
|||
const emptyMatch = match("empty", (key, value, doc) => { |
|||
return doc[key] != null && doc[key] !== "" |
|||
}) |
|||
|
|||
// Process a not-empty match (fails is the value is empty)
|
|||
const notEmptyMatch = match("notEmpty", (key, value, doc) => { |
|||
return doc[key] == null || doc[key] === "" |
|||
}) |
|||
|
|||
// Match a document against all criteria
|
|||
const docMatch = doc => { |
|||
return ( |
|||
stringMatch(doc) && |
|||
fuzzyMatch(doc) && |
|||
rangeMatch(doc) && |
|||
equalMatch(doc) && |
|||
notEqualMatch(doc) && |
|||
emptyMatch(doc) && |
|||
notEmptyMatch(doc) |
|||
) |
|||
} |
|||
|
|||
// Process all docs
|
|||
return docs.filter(docMatch) |
|||
} |
|||
|
|||
/** |
|||
* Performs a client-side sort from the equivalent server-side lucene sort |
|||
* parameters. |
|||
* @param docs the data |
|||
* @param sort the sort column |
|||
* @param sortOrder the sort order ("ascending" or "descending") |
|||
* @param sortType the type of sort ("string" or "number") |
|||
*/ |
|||
export const luceneSort = (docs, sort, sortOrder, sortType = "string") => { |
|||
if (!sort || !sortOrder || !sortType) { |
|||
return docs |
|||
} |
|||
const parse = sortType === "string" ? x => `${x}` : x => parseFloat(x) |
|||
return docs.slice().sort((a, b) => { |
|||
const colA = parse(a[sort]) |
|||
const colB = parse(b[sort]) |
|||
if (sortOrder === "Descending") { |
|||
return colA > colB ? -1 : 1 |
|||
} else { |
|||
return colA > colB ? 1 : -1 |
|||
} |
|||
}) |
|||
} |
|||
|
|||
export const getValidOperatorsForType = type => { |
|||
const Op = OperatorOptions |
|||
if (type === "string") { |
|||
return [ |
|||
Op.Equals, |
|||
Op.NotEquals, |
|||
Op.StartsWith, |
|||
Op.Like, |
|||
Op.Empty, |
|||
Op.NotEmpty, |
|||
] |
|||
} else if (type === "number") { |
|||
return [ |
|||
Op.Equals, |
|||
Op.NotEquals, |
|||
Op.MoreThan, |
|||
Op.LessThan, |
|||
Op.Empty, |
|||
Op.NotEmpty, |
|||
] |
|||
} else if (type === "options") { |
|||
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty] |
|||
} else if (type === "array") { |
|||
return [Op.Contains, Op.NotContains, Op.Empty, Op.NotEmpty] |
|||
} else if (type === "boolean") { |
|||
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty] |
|||
} else if (type === "longform") { |
|||
return [ |
|||
Op.Equals, |
|||
Op.NotEquals, |
|||
Op.StartsWith, |
|||
Op.Like, |
|||
Op.Empty, |
|||
Op.NotEmpty, |
|||
] |
|||
} else if (type === "datetime") { |
|||
return [ |
|||
Op.Equals, |
|||
Op.NotEquals, |
|||
Op.MoreThan, |
|||
Op.LessThan, |
|||
Op.Empty, |
|||
Op.NotEmpty, |
|||
] |
|||
/** |
|||
* Limits the specified docs to the specified number of rows from the equivalent |
|||
* server-side lucene limit parameters. |
|||
* @param docs the data |
|||
* @param limit the number of docs to limit to |
|||
*/ |
|||
export const luceneLimit = (docs, limit) => { |
|||
const numLimit = parseFloat(limit) |
|||
if (isNaN(numLimit)) { |
|||
return docs |
|||
} |
|||
return [] |
|||
return docs.slice(0, numLimit) |
|||
} |
|||
|
|||
@ -0,0 +1,16 @@ |
|||
export const suppressWarnings = warnings => { |
|||
if (!warnings?.length) { |
|||
return |
|||
} |
|||
const regex = new RegExp(warnings.map(x => `(${x})`).join("|"), "gi") |
|||
const warn = console.warn |
|||
console.warn = (...params) => { |
|||
const msg = params[0] |
|||
if (msg && typeof msg === "string") { |
|||
if (msg.match(regex)) { |
|||
return |
|||
} |
|||
} |
|||
warn(...params) |
|||
} |
|||
} |
|||
@ -0,0 +1,50 @@ |
|||
<script> |
|||
import { notifications, ModalContent, Dropzone, Body } from "@budibase/bbui" |
|||
import { post } from "builderStore/api" |
|||
|
|||
let submitting = false |
|||
|
|||
$: value = { file: null } |
|||
|
|||
async function importApps() { |
|||
submitting = true |
|||
|
|||
try { |
|||
// Create form data to create app |
|||
let data = new FormData() |
|||
data.append("importFile", value.file) |
|||
|
|||
// Create App |
|||
const importResp = await post("/api/cloud/import", data, {}) |
|||
const importJson = await importResp.json() |
|||
if (!importResp.ok) { |
|||
throw new Error(importJson.message) |
|||
} |
|||
// now reload to get to login |
|||
window.location.reload() |
|||
} catch (error) { |
|||
notifications.error(error) |
|||
submitting = false |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<ModalContent |
|||
title="Import apps" |
|||
confirmText="Import apps" |
|||
onConfirm={importApps} |
|||
disabled={!value.file} |
|||
> |
|||
<Body |
|||
>Please upload the file that was exported from your Cloud environment to get |
|||
started</Body |
|||
> |
|||
<Dropzone |
|||
gallery={false} |
|||
label="File to import" |
|||
value={[value.file]} |
|||
on:change={e => { |
|||
value.file = e.detail?.[0] |
|||
}} |
|||
/> |
|||
</ModalContent> |
|||
@ -1 +1,13 @@ |
|||
<script> |
|||
import { params } from "@roxi/routify" |
|||
import { queries } from "stores/backend" |
|||
|
|||
if ($params.query) { |
|||
const query = $queries.list.find(q => q._id === $params.query) |
|||
if (query) { |
|||
queries.select(query) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<slot /> |
|||
|
|||
@ -1,14 +1 @@ |
|||
<script> |
|||
import { datasources } from "stores/backend" |
|||
import { goto, leftover } from "@roxi/routify" |
|||
import { onMount } from "svelte" |
|||
|
|||
onMount(async () => { |
|||
// navigate to first datasource in list, if not already selected |
|||
if (!$leftover && $datasources.list.length > 0 && !$datasources.selected) { |
|||
$goto(`./${$datasources.list[0]._id}`) |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<slot /> |
|||
|
|||
@ -1,13 +0,0 @@ |
|||
<script> |
|||
import { params } from "@roxi/routify" |
|||
import { tables } from "stores/backend" |
|||
|
|||
if ($params.selectedTable) { |
|||
const table = $tables.list.find(m => m._id === $params.selectedTable) |
|||
if (table) { |
|||
tables.select(table) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<slot /> |
|||
@ -1,16 +0,0 @@ |
|||
<script> |
|||
import TableDataTable from "components/backend/DataTable/DataTable.svelte" |
|||
import { tables, database } from "stores/backend" |
|||
</script> |
|||
|
|||
{#if $database?._id && $tables?.selected?.name} |
|||
<TableDataTable /> |
|||
{:else}<i>Create your first table to start building</i>{/if} |
|||
|
|||
<style> |
|||
i { |
|||
font-size: var(--font-size-m); |
|||
color: var(--grey-5); |
|||
margin-top: 2px; |
|||
} |
|||
</style> |
|||
@ -1,10 +0,0 @@ |
|||
<script> |
|||
import { params } from "@roxi/routify" |
|||
import RelationshipDataTable from "components/backend/DataTable/RelationshipDataTable.svelte" |
|||
</script> |
|||
|
|||
<RelationshipDataTable |
|||
tableId={$params.selectedTable} |
|||
rowId={$params.selectedRow} |
|||
fieldName={decodeURI($params.selectedField)} |
|||
/> |
|||
@ -1,6 +0,0 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
$goto("../../") |
|||
</script> |
|||
|
|||
<!-- routify:options index=false --> |
|||
@ -1,6 +0,0 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
$goto("../") |
|||
</script> |
|||
|
|||
<!-- routify:options index=false --> |
|||
@ -1,19 +0,0 @@ |
|||
<script> |
|||
import { tables } from "stores/backend" |
|||
import { goto, leftover } from "@roxi/routify" |
|||
import { onMount } from "svelte" |
|||
|
|||
onMount(async () => { |
|||
// navigate to first table in list, if not already selected |
|||
// and this is the final url (i.e. no selectedTable) |
|||
if ( |
|||
!$leftover && |
|||
$tables.list.length > 0 |
|||
// (!$tables.selected || !$tables.selected._id) |
|||
) { |
|||
$goto(`./${$tables.list[0]._id}`) |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<slot /> |
|||
@ -1,21 +0,0 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
import { onMount } from "svelte" |
|||
import { tables } from "stores/backend" |
|||
|
|||
onMount(async () => { |
|||
$tables.list.length > 0 && $goto(`./${$tables.list[0]._id}`) |
|||
}) |
|||
</script> |
|||
|
|||
{#if $tables.list.length === 0} |
|||
<i>Create your first table to start building</i> |
|||
{:else}<i>Select a table to edit</i>{/if} |
|||
|
|||
<style> |
|||
i { |
|||
font-size: var(--font-size-m); |
|||
color: var(--grey-5); |
|||
margin-top: 2px; |
|||
} |
|||
</style> |
|||
@ -1,6 +1,22 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
$goto("./table") |
|||
import { onMount } from "svelte" |
|||
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte" |
|||
import { datasources } from "stores/backend" |
|||
|
|||
let modal |
|||
$: setupComplete = |
|||
$datasources.list.find(x => (x._id = "bb_internal")).entities.length > 1 || |
|||
$datasources.list.length > 1 |
|||
|
|||
onMount(() => { |
|||
if (!setupComplete) { |
|||
modal.show() |
|||
} else { |
|||
$goto("./table") |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<CreateDatasourceModal bind:modal /> |
|||
<!-- routify:options index=false --> |
|||
|
|||
@ -0,0 +1,240 @@ |
|||
<script context="module"> |
|||
export const Sides = { |
|||
Top: "Top", |
|||
Right: "Right", |
|||
Bottom: "Bottom", |
|||
Left: "Left", |
|||
} |
|||
</script> |
|||
|
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { get } from "svelte/store" |
|||
import IndicatorSet from "./IndicatorSet.svelte" |
|||
import DNDPositionIndicator from "./DNDPositionIndicator.svelte" |
|||
import { builderStore } from "stores" |
|||
|
|||
let dragInfo |
|||
let dropInfo |
|||
|
|||
const getEdges = (bounds, mousePoint) => { |
|||
const { width, height, top, left } = bounds |
|||
return { |
|||
[Sides.Top]: [mousePoint[0], top], |
|||
[Sides.Right]: [left + width, mousePoint[1]], |
|||
[Sides.Bottom]: [mousePoint[0], top + height], |
|||
[Sides.Left]: [left, mousePoint[1]], |
|||
} |
|||
} |
|||
|
|||
const calculatePointDelta = (point1, point2) => { |
|||
const deltaX = Math.abs(point1[0] - point2[0]) |
|||
const deltaY = Math.abs(point1[1] - point2[1]) |
|||
return Math.sqrt(deltaX * deltaX + deltaY * deltaY) |
|||
} |
|||
|
|||
const getDOMNodeForComponent = component => { |
|||
const parent = component.closest(".component") |
|||
const children = Array.from(parent.childNodes) |
|||
return children?.find(node => node?.nodeType === 1) |
|||
} |
|||
|
|||
// Callback when initially starting a drag on a draggable component |
|||
const onDragStart = e => { |
|||
const parent = e.target.closest(".component") |
|||
if (!parent?.classList.contains("draggable")) { |
|||
return |
|||
} |
|||
|
|||
// Update state |
|||
dragInfo = { |
|||
target: parent.dataset.id, |
|||
parent: parent.dataset.parent, |
|||
} |
|||
builderStore.actions.selectComponent(dragInfo.target) |
|||
builderStore.actions.setDragging(true) |
|||
|
|||
// Highlight being dragged by setting opacity |
|||
const child = getDOMNodeForComponent(e.target) |
|||
if (child) { |
|||
child.style.opacity = "0.5" |
|||
} |
|||
} |
|||
|
|||
// Callback when drag stops (whether dropped or not) |
|||
const onDragEnd = e => { |
|||
// Reset opacity style |
|||
if (dragInfo) { |
|||
const child = getDOMNodeForComponent(e.target) |
|||
if (child) { |
|||
child.style.opacity = "" |
|||
} |
|||
} |
|||
|
|||
// Reset state and styles |
|||
dragInfo = null |
|||
dropInfo = null |
|||
builderStore.actions.setDragging(false) |
|||
} |
|||
|
|||
// Callback when on top of a component |
|||
const onDragOver = e => { |
|||
// Skip if we aren't validly dragging currently |
|||
if (!dragInfo || !dropInfo) { |
|||
return |
|||
} |
|||
|
|||
e.preventDefault() |
|||
const { droppableInside, bounds } = dropInfo |
|||
const { top, left, height, width } = bounds |
|||
const mouseY = e.clientY |
|||
const mouseX = e.clientX |
|||
const snapFactor = droppableInside ? 0.33 : 0.5 |
|||
const snapLimitV = Math.min(40, height * snapFactor) |
|||
const snapLimitH = Math.min(40, width * snapFactor) |
|||
|
|||
// Determine all sies we are within snap range of |
|||
let sides = [] |
|||
if (mouseY <= top + snapLimitV) { |
|||
sides.push(Sides.Top) |
|||
} else if (mouseY >= top + height - snapLimitV) { |
|||
sides.push(Sides.Bottom) |
|||
} |
|||
if (mouseX < left + snapLimitH) { |
|||
sides.push(Sides.Left) |
|||
} else if (mouseX > left + width - snapLimitH) { |
|||
sides.push(Sides.Right) |
|||
} |
|||
|
|||
// When no edges match, drop inside if possible |
|||
if (!sides.length) { |
|||
dropInfo.mode = droppableInside ? "inside" : null |
|||
dropInfo.side = null |
|||
return |
|||
} |
|||
|
|||
// When one edge matches, use that edge |
|||
if (sides.length === 1) { |
|||
dropInfo.side = sides[0] |
|||
if ([Sides.Top, Sides.Left].includes(sides[0])) { |
|||
dropInfo.mode = "above" |
|||
} else { |
|||
dropInfo.mode = "below" |
|||
} |
|||
return |
|||
} |
|||
|
|||
// When 2 edges match, work out which is closer |
|||
const mousePoint = [mouseX, mouseY] |
|||
const edges = getEdges(bounds, mousePoint) |
|||
const edge1 = edges[sides[0]] |
|||
const delta1 = calculatePointDelta(mousePoint, edge1) |
|||
const edge2 = edges[sides[1]] |
|||
const delta2 = calculatePointDelta(mousePoint, edge2) |
|||
const edge = delta1 < delta2 ? sides[0] : sides[1] |
|||
dropInfo.side = edge |
|||
if ([Sides.Top, Sides.Left].includes(edge)) { |
|||
dropInfo.mode = "above" |
|||
} else { |
|||
dropInfo.mode = "below" |
|||
} |
|||
} |
|||
|
|||
// Callback when entering a potential drop target |
|||
const onDragEnter = e => { |
|||
// Skip if we aren't validly dragging currently |
|||
if (!dragInfo) { |
|||
return |
|||
} |
|||
|
|||
const element = e.target.closest(".component") |
|||
if ( |
|||
element && |
|||
element.classList.contains("droppable") && |
|||
element.dataset.id !== dragInfo.target |
|||
) { |
|||
// Do nothing if this is the same target |
|||
if (element.dataset.id === dropInfo?.target) { |
|||
return |
|||
} |
|||
|
|||
// Ensure the dragging flag is always set. |
|||
// There's a bit of a race condition between the app reinitialisation |
|||
// after selecting the DND component and setting this the first time |
|||
if (!get(builderStore).isDragging) { |
|||
builderStore.actions.setDragging(true) |
|||
} |
|||
|
|||
// Store target ID |
|||
const target = element.dataset.id |
|||
|
|||
// Precompute and store some info to avoid recalculating everything in |
|||
// dragOver |
|||
const child = getDOMNodeForComponent(e.target) |
|||
const bounds = child.getBoundingClientRect() |
|||
dropInfo = { |
|||
target, |
|||
name: element.dataset.name, |
|||
droppableInside: element.classList.contains("empty"), |
|||
bounds, |
|||
} |
|||
} else { |
|||
dropInfo = null |
|||
} |
|||
} |
|||
|
|||
// Callback when leaving a potential drop target. |
|||
// Since we don't style our targets, we don't need to unset anything. |
|||
const onDragLeave = () => {} |
|||
|
|||
// Callback when dropping a drag on top of some component |
|||
const onDrop = e => { |
|||
e.preventDefault() |
|||
if (dropInfo?.mode) { |
|||
builderStore.actions.moveComponent( |
|||
dragInfo.target, |
|||
dropInfo.target, |
|||
dropInfo.mode |
|||
) |
|||
} |
|||
} |
|||
|
|||
onMount(() => { |
|||
// Events fired on the draggable target |
|||
document.addEventListener("dragstart", onDragStart, false) |
|||
document.addEventListener("dragend", onDragEnd, false) |
|||
|
|||
// Events fired on the drop targets |
|||
document.addEventListener("dragover", onDragOver, false) |
|||
document.addEventListener("dragenter", onDragEnter, false) |
|||
document.addEventListener("dragleave", onDragLeave, false) |
|||
document.addEventListener("drop", onDrop, false) |
|||
|
|||
return () => { |
|||
// Events fired on the draggable target |
|||
document.removeEventListener("dragstart", onDragStart, false) |
|||
document.removeEventListener("dragend", onDragEnd, false) |
|||
|
|||
// Events fired on the drop targets |
|||
document.removeEventListener("dragover", onDragOver, false) |
|||
document.removeEventListener("dragenter", onDragEnter, false) |
|||
document.removeEventListener("dragleave", onDragLeave, false) |
|||
document.removeEventListener("drop", onDrop, false) |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<IndicatorSet |
|||
componentId={dropInfo?.mode === "inside" ? dropInfo.target : null} |
|||
color="var(--spectrum-global-color-static-green-500)" |
|||
zIndex="930" |
|||
transition |
|||
prefix="Inside" |
|||
/> |
|||
|
|||
<DNDPositionIndicator |
|||
{dropInfo} |
|||
color="var(--spectrum-global-color-static-green-500)" |
|||
zIndex="940" |
|||
transition |
|||
/> |
|||
@ -0,0 +1,54 @@ |
|||
<script> |
|||
import Indicator from "./Indicator.svelte" |
|||
import { Sides } from "./DNDHandler.svelte" |
|||
|
|||
export let dropInfo |
|||
export let zIndex |
|||
export let color |
|||
export let transition |
|||
|
|||
$: dimensions = getDimensions(dropInfo) |
|||
$: prefix = dropInfo?.mode === "above" ? "Before" : "After" |
|||
$: text = `${prefix} ${dropInfo?.name}` |
|||
$: renderKey = `${dropInfo?.target}-${dropInfo?.side}` |
|||
|
|||
const getDimensions = info => { |
|||
const { bounds, side } = info ?? {} |
|||
if (!bounds || !side) { |
|||
return null |
|||
} |
|||
const { left, top, width, height } = bounds |
|||
if (side === Sides.Top || side === Sides.Bottom) { |
|||
return { |
|||
top: side === Sides.Top ? top - 4 : top + height, |
|||
left: left - 2, |
|||
width: width + 4, |
|||
height: 0, |
|||
} |
|||
} else { |
|||
return { |
|||
top: top - 2, |
|||
left: side === Sides.Left ? left - 4 : left + width, |
|||
width: 0, |
|||
height: height + 4, |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
{#key renderKey} |
|||
{#if dimensions && dropInfo?.mode !== "inside"} |
|||
<Indicator |
|||
left={Math.round(dimensions.left)} |
|||
top={Math.round(dimensions.top)} |
|||
width={dimensions.width} |
|||
height={dimensions.height} |
|||
{text} |
|||
{zIndex} |
|||
{color} |
|||
{transition} |
|||
alignRight={dropInfo?.side === Sides.Right} |
|||
line |
|||
/> |
|||
{/if} |
|||
{/key} |
|||
@ -1,179 +0,0 @@ |
|||
/** |
|||
* Builds a lucene JSON query from the filter structure generated in the builder |
|||
* @param filter the builder filter structure |
|||
*/ |
|||
export const buildLuceneQuery = filter => { |
|||
let query = { |
|||
string: {}, |
|||
fuzzy: {}, |
|||
range: {}, |
|||
equal: {}, |
|||
notEqual: {}, |
|||
empty: {}, |
|||
notEmpty: {}, |
|||
contains: {}, |
|||
notContains: {}, |
|||
} |
|||
if (Array.isArray(filter)) { |
|||
filter.forEach(expression => { |
|||
let { operator, field, type, value } = expression |
|||
// Parse all values into correct types
|
|||
if (type === "datetime" && value) { |
|||
value = new Date(value).toISOString() |
|||
} |
|||
if (type === "number") { |
|||
value = parseFloat(value) |
|||
} |
|||
if (type === "boolean") { |
|||
value = `${value}`?.toLowerCase() === "true" |
|||
} |
|||
if (operator.startsWith("range")) { |
|||
if (!query.range[field]) { |
|||
query.range[field] = { |
|||
low: |
|||
type === "number" |
|||
? Number.MIN_SAFE_INTEGER |
|||
: "0000-00-00T00:00:00.000Z", |
|||
high: |
|||
type === "number" |
|||
? Number.MAX_SAFE_INTEGER |
|||
: "9999-00-00T00:00:00.000Z", |
|||
} |
|||
} |
|||
if (operator === "rangeLow" && value != null && value !== "") { |
|||
query.range[field].low = value |
|||
} else if (operator === "rangeHigh" && value != null && value !== "") { |
|||
query.range[field].high = value |
|||
} |
|||
} else if (query[operator]) { |
|||
if (type === "boolean") { |
|||
// Transform boolean filters to cope with null.
|
|||
// "equals false" needs to be "not equals true"
|
|||
// "not equals false" needs to be "equals true"
|
|||
if (operator === "equal" && value === false) { |
|||
query.notEqual[field] = true |
|||
} else if (operator === "notEqual" && value === false) { |
|||
query.equal[field] = true |
|||
} else { |
|||
query[operator][field] = value |
|||
} |
|||
} else { |
|||
query[operator][field] = value |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
return query |
|||
} |
|||
|
|||
/** |
|||
* Performs a client-side lucene search on an array of data |
|||
* @param docs the data |
|||
* @param query the JSON lucene query |
|||
*/ |
|||
export const luceneQuery = (docs, query) => { |
|||
if (!query) { |
|||
return docs |
|||
} |
|||
|
|||
// Iterates over a set of filters and evaluates a fail function against a doc
|
|||
const match = (type, failFn) => doc => { |
|||
const filters = Object.entries(query[type] || {}) |
|||
for (let i = 0; i < filters.length; i++) { |
|||
if (failFn(filters[i][0], filters[i][1], doc)) { |
|||
return false |
|||
} |
|||
} |
|||
return true |
|||
} |
|||
|
|||
// Process a string match (fails if the value does not start with the string)
|
|||
const stringMatch = match("string", (key, value, doc) => { |
|||
return !doc[key] || !doc[key].startsWith(value) |
|||
}) |
|||
|
|||
// Process a fuzzy match (treat the same as starts with when running locally)
|
|||
const fuzzyMatch = match("fuzzy", (key, value, doc) => { |
|||
return !doc[key] || !doc[key].startsWith(value) |
|||
}) |
|||
|
|||
// Process a range match
|
|||
const rangeMatch = match("range", (key, value, doc) => { |
|||
return !doc[key] || doc[key] < value.low || doc[key] > value.high |
|||
}) |
|||
|
|||
// Process an equal match (fails if the value is different)
|
|||
const equalMatch = match("equal", (key, value, doc) => { |
|||
return value != null && value !== "" && doc[key] !== value |
|||
}) |
|||
|
|||
// Process a not-equal match (fails if the value is the same)
|
|||
const notEqualMatch = match("notEqual", (key, value, doc) => { |
|||
return value != null && value !== "" && doc[key] === value |
|||
}) |
|||
|
|||
// Process an empty match (fails if the value is not empty)
|
|||
const emptyMatch = match("empty", (key, value, doc) => { |
|||
return doc[key] != null && doc[key] !== "" |
|||
}) |
|||
|
|||
// Process a not-empty match (fails is the value is empty)
|
|||
const notEmptyMatch = match("notEmpty", (key, value, doc) => { |
|||
return doc[key] == null || doc[key] === "" |
|||
}) |
|||
|
|||
// Match a document against all criteria
|
|||
const docMatch = doc => { |
|||
return ( |
|||
stringMatch(doc) && |
|||
fuzzyMatch(doc) && |
|||
rangeMatch(doc) && |
|||
equalMatch(doc) && |
|||
notEqualMatch(doc) && |
|||
emptyMatch(doc) && |
|||
notEmptyMatch(doc) |
|||
) |
|||
} |
|||
|
|||
// Process all docs
|
|||
return docs.filter(docMatch) |
|||
} |
|||
|
|||
/** |
|||
* Performs a client-side sort from the equivalent server-side lucene sort |
|||
* parameters. |
|||
* @param docs the data |
|||
* @param sort the sort column |
|||
* @param sortOrder the sort order ("ascending" or "descending") |
|||
* @param sortType the type of sort ("string" or "number") |
|||
*/ |
|||
export const luceneSort = (docs, sort, sortOrder, sortType = "string") => { |
|||
if (!sort || !sortOrder || !sortType) { |
|||
return docs |
|||
} |
|||
const parse = sortType === "string" ? x => `${x}` : x => parseFloat(x) |
|||
return docs.slice().sort((a, b) => { |
|||
const colA = parse(a[sort]) |
|||
const colB = parse(b[sort]) |
|||
if (sortOrder === "Descending") { |
|||
return colA > colB ? -1 : 1 |
|||
} else { |
|||
return colA > colB ? 1 : -1 |
|||
} |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Limits the specified docs to the specified number of rows from the equivalent |
|||
* server-side lucene limit parameters. |
|||
* @param docs the data |
|||
* @param limit the number of docs to limit to |
|||
*/ |
|||
export const luceneLimit = (docs, limit) => { |
|||
const numLimit = parseFloat(limit) |
|||
if (isNaN(numLimit)) { |
|||
return docs |
|||
} |
|||
return docs.slice(0, numLimit) |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,92 @@ |
|||
const env = require("../../environment") |
|||
const { getAllApps } = require("@budibase/auth/db") |
|||
const CouchDB = require("../../db") |
|||
const { |
|||
exportDB, |
|||
sendTempFile, |
|||
readFileSync, |
|||
} = require("../../utilities/fileSystem") |
|||
const { stringToReadStream } = require("../../utilities") |
|||
const { getGlobalDBName, getGlobalDB } = require("@budibase/auth/tenancy") |
|||
const { create } = require("./application") |
|||
const { getDocParams, DocumentTypes, isDevAppID } = require("../../db/utils") |
|||
|
|||
async function createApp(appName, appImport) { |
|||
const ctx = { |
|||
request: { |
|||
body: { |
|||
templateString: appImport, |
|||
name: appName, |
|||
}, |
|||
}, |
|||
} |
|||
return create(ctx) |
|||
} |
|||
|
|||
exports.exportApps = async ctx => { |
|||
if (env.SELF_HOSTED || !env.MULTI_TENANCY) { |
|||
ctx.throw(400, "Exporting only allowed in multi-tenant cloud environments.") |
|||
} |
|||
const apps = await getAllApps(CouchDB, { all: true }) |
|||
const globalDBString = await exportDB(getGlobalDBName()) |
|||
let allDBs = { |
|||
global: globalDBString, |
|||
} |
|||
for (let app of apps) { |
|||
// only export the dev apps as they will be the latest, the user can republish the apps
|
|||
// in their self hosted environment
|
|||
if (isDevAppID(app._id)) { |
|||
allDBs[app.name] = await exportDB(app._id) |
|||
} |
|||
} |
|||
const filename = `cloud-export-${new Date().getTime()}.txt` |
|||
ctx.attachment(filename) |
|||
ctx.body = sendTempFile(JSON.stringify(allDBs)) |
|||
} |
|||
|
|||
async function getAllDocType(db, docType) { |
|||
const response = await db.allDocs( |
|||
getDocParams(docType, null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
return response.rows.map(row => row.doc) |
|||
} |
|||
|
|||
exports.importApps = async ctx => { |
|||
if (!env.SELF_HOSTED || env.MULTI_TENANCY) { |
|||
ctx.throw(400, "Importing only allowed in self hosted environments.") |
|||
} |
|||
const apps = await getAllApps(CouchDB, { all: true }) |
|||
if ( |
|||
apps.length !== 0 || |
|||
!ctx.request.files || |
|||
!ctx.request.files.importFile |
|||
) { |
|||
ctx.throw( |
|||
400, |
|||
"Import file is required and environment must be fresh to import apps." |
|||
) |
|||
} |
|||
const importFile = ctx.request.files.importFile |
|||
const importString = readFileSync(importFile.path) |
|||
const dbs = JSON.parse(importString) |
|||
const globalDbImport = dbs.global |
|||
// remove from the list of apps
|
|||
delete dbs.global |
|||
const globalDb = getGlobalDB() |
|||
// load the global db first
|
|||
await globalDb.load(stringToReadStream(globalDbImport)) |
|||
for (let [appName, appImport] of Object.entries(dbs)) { |
|||
await createApp(appName, appImport) |
|||
} |
|||
// once apps are created clean up the global db
|
|||
let users = await getAllDocType(globalDb, DocumentTypes.USER) |
|||
for (let user of users) { |
|||
delete user.tenantId |
|||
} |
|||
await globalDb.bulkDocs(users) |
|||
ctx.body = { |
|||
message: "Apps successfully imported.", |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
const Router = require("@koa/router") |
|||
const controller = require("../controllers/cloud") |
|||
const authorized = require("../../middleware/authorized") |
|||
const { BUILDER } = require("@budibase/auth/permissions") |
|||
|
|||
const router = Router() |
|||
|
|||
router |
|||
.get("/api/cloud/export", authorized(BUILDER), controller.exportApps) |
|||
// has to be public, only run if apps don't exist
|
|||
.post("/api/cloud/import", controller.importApps) |
|||
|
|||
module.exports = router |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue