forked from tsai/budibase
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> |
<script> |
||||
import { goto } from "@roxi/routify" |
import { ModalContent, Modal, Body, Layout, Detail } from "@budibase/bbui" |
||||
import { datasources } from "stores/backend" |
import { onMount } from "svelte" |
||||
import { notifications } from "@budibase/bbui" |
import ICONS from "../icons" |
||||
import { Input, Label, ModalContent, Modal, Context } from "@budibase/bbui" |
import api from "builderStore/api" |
||||
import TableIntegrationMenu from "../TableIntegrationMenu/index.svelte" |
import { IntegrationNames } from "constants" |
||||
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte" |
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte" |
||||
import analytics, { Events } from "analytics" |
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte" |
||||
import { getContext } from "svelte" |
|
||||
|
|
||||
const modalContext = getContext(Context.Modal) |
export let modal |
||||
|
let integrations = [] |
||||
|
let integration = {} |
||||
|
let internalTableModal |
||||
|
let externalDatasourceModal |
||||
|
|
||||
let tableModal |
const INTERNAL = "BUDIBASE" |
||||
let name |
|
||||
let error = "" |
|
||||
let integration |
|
||||
|
|
||||
$: checkOpenModal(integration && integration.type === "BUDIBASE") |
onMount(() => { |
||||
|
fetchIntegrations() |
||||
|
}) |
||||
|
|
||||
function checkValid(evt) { |
function selectIntegration(integrationType) { |
||||
const datasourceName = evt.target.value |
const selected = integrations[integrationType] |
||||
if ( |
|
||||
$datasources?.list.some(datasource => datasource.name === datasourceName) |
// build the schema |
||||
) { |
const config = {} |
||||
error = `Datasource with name ${datasourceName} already exists. Please choose another name.` |
for (let key of Object.keys(selected.datasource)) { |
||||
return |
config[key] = selected.datasource[key].default |
||||
|
} |
||||
|
integration = { |
||||
|
type: integrationType, |
||||
|
plus: selected.plus, |
||||
|
config, |
||||
|
schema: selected.datasource, |
||||
} |
} |
||||
error = "" |
|
||||
} |
} |
||||
|
|
||||
function checkOpenModal(isInternal) { |
function chooseNextModal() { |
||||
if (isInternal) { |
if (integration.type === INTERNAL) { |
||||
tableModal.show() |
externalDatasourceModal.hide() |
||||
|
internalTableModal.show() |
||||
|
} else { |
||||
|
externalDatasourceModal.show() |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
async function saveDatasource() { |
async function fetchIntegrations() { |
||||
const { type, plus, ...config } = integration |
const response = await api.get("/api/integrations") |
||||
|
const json = await response.json() |
||||
// Create datasource |
integrations = { |
||||
const response = await datasources.save({ |
[INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" }, |
||||
name, |
...json, |
||||
source: type, |
} |
||||
config, |
return json |
||||
plus, |
|
||||
}) |
|
||||
notifications.success(`Datasource ${name} created successfully.`) |
|
||||
analytics.captureEvent(Events.DATASOURCE.CREATED, { name, type }) |
|
||||
|
|
||||
// Navigate to new datasource |
|
||||
$goto(`./datasource/${response._id}`) |
|
||||
} |
} |
||||
</script> |
</script> |
||||
|
|
||||
<Modal bind:this={tableModal} on:hide={modalContext.hide}> |
<Modal bind:this={internalTableModal}> |
||||
<CreateTableModal bind:name /> |
<CreateTableModal /> |
||||
|
</Modal> |
||||
|
|
||||
|
<Modal bind:this={externalDatasourceModal}> |
||||
|
<DatasourceConfigModal {integration} /> |
||||
</Modal> |
</Modal> |
||||
<ModalContent |
|
||||
title="Create Datasource" |
<Modal bind:this={modal}> |
||||
size="L" |
<ModalContent |
||||
confirmText="Create" |
disabled={!Object.keys(integration).length} |
||||
onConfirm={saveDatasource} |
title="Data" |
||||
disabled={error || !name || !integration?.type} |
confirmText="Continue" |
||||
> |
showCancelButton={false} |
||||
<Input |
size="M" |
||||
data-cy="datasource-name-input" |
onConfirm={() => { |
||||
label="Datasource Name" |
chooseNextModal() |
||||
on:input={checkValid} |
}} |
||||
bind:value={name} |
> |
||||
{error} |
<Layout noPadding> |
||||
/> |
<Body size="XS" |
||||
<Label>Datasource Type</Label> |
>All apps need data. You can connect to a data source below, or add data |
||||
<TableIntegrationMenu bind:integration /> |
to your app using Budibase's built-in database. |
||||
</ModalContent> |
</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: { |
* Builds a lucene JSON query from the filter structure generated in the builder |
||||
value: "equal", |
* @param filter the builder filter structure |
||||
label: "Equals", |
*/ |
||||
}, |
export const buildLuceneQuery = filter => { |
||||
NotEquals: { |
let query = { |
||||
value: "notEqual", |
string: {}, |
||||
label: "Not equals", |
fuzzy: {}, |
||||
}, |
range: {}, |
||||
Empty: { |
equal: {}, |
||||
value: "empty", |
notEqual: {}, |
||||
label: "Is empty", |
empty: {}, |
||||
}, |
notEmpty: {}, |
||||
NotEmpty: { |
contains: {}, |
||||
value: "notEmpty", |
notContains: {}, |
||||
label: "Is not empty", |
} |
||||
}, |
if (Array.isArray(filter)) { |
||||
StartsWith: { |
filter.forEach(expression => { |
||||
value: "string", |
let { operator, field, type, value } = expression |
||||
label: "Starts with", |
// Parse all values into correct types
|
||||
}, |
if (type === "datetime" && value) { |
||||
Like: { |
value = new Date(value).toISOString() |
||||
value: "fuzzy", |
} |
||||
label: "Like", |
if (type === "number") { |
||||
}, |
value = parseFloat(value) |
||||
MoreThan: { |
} |
||||
value: "rangeLow", |
if (type === "boolean") { |
||||
label: "More than", |
value = `${value}`?.toLowerCase() === "true" |
||||
}, |
} |
||||
LessThan: { |
if (operator.startsWith("range")) { |
||||
value: "rangeHigh", |
if (!query.range[field]) { |
||||
label: "Less than", |
query.range[field] = { |
||||
}, |
low: |
||||
Contains: { |
type === "number" |
||||
value: "equal", |
? Number.MIN_SAFE_INTEGER |
||||
label: "Contains", |
: "0000-00-00T00:00:00.000Z", |
||||
}, |
high: |
||||
NotContains: { |
type === "number" |
||||
value: "notEqual", |
? Number.MAX_SAFE_INTEGER |
||||
label: "Does Not Contain", |
: "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 |
* Limits the specified docs to the specified number of rows from the equivalent |
||||
if (type === "string") { |
* server-side lucene limit parameters. |
||||
return [ |
* @param docs the data |
||||
Op.Equals, |
* @param limit the number of docs to limit to |
||||
Op.NotEquals, |
*/ |
||||
Op.StartsWith, |
export const luceneLimit = (docs, limit) => { |
||||
Op.Like, |
const numLimit = parseFloat(limit) |
||||
Op.Empty, |
if (isNaN(numLimit)) { |
||||
Op.NotEmpty, |
return docs |
||||
] |
|
||||
} 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 [] |
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 /> |
<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 /> |
<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> |
<script> |
||||
import { goto } from "@roxi/routify" |
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> |
</script> |
||||
|
|
||||
|
<CreateDatasourceModal bind:modal /> |
||||
<!-- routify:options index=false --> |
<!-- 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