forked from tsai/budibase
committed by
GitHub
214 changed files with 3631 additions and 2091 deletions
@ -0,0 +1 @@ |
|||
module.exports = require("./src/cloud/accounts") |
|||
@ -0,0 +1 @@ |
|||
module.exports = require("./src/tenancy/deprovision") |
|||
@ -1,5 +1,6 @@ |
|||
const env = require("../src/environment") |
|||
|
|||
env._set("SELF_HOSTED", "1") |
|||
env._set("NODE_ENV", "jest") |
|||
env._set("JWT_SECRET", "test-jwtsecret") |
|||
env._set("LOG_LEVEL", "silent") |
|||
|
|||
@ -0,0 +1,22 @@ |
|||
const API = require("./api") |
|||
const env = require("../environment") |
|||
|
|||
const api = new API(env.ACCOUNT_PORTAL_URL) |
|||
|
|||
// TODO: Authorization
|
|||
|
|||
exports.getAccount = async email => { |
|||
const payload = { |
|||
email, |
|||
} |
|||
const response = await api.post(`/api/accounts/search`, { |
|||
body: payload, |
|||
}) |
|||
const json = await response.json() |
|||
|
|||
if (response.status !== 200) { |
|||
throw Error(`Error getting account by email ${email}`, json) |
|||
} |
|||
|
|||
return json[0] |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
const fetch = require("node-fetch") |
|||
class API { |
|||
constructor(host) { |
|||
this.host = host |
|||
} |
|||
|
|||
apiCall = |
|||
method => |
|||
async (url = "", options = {}) => { |
|||
if (!options.headers) { |
|||
options.headers = {} |
|||
} |
|||
|
|||
if (!options.headers["Content-Type"]) { |
|||
options.headers = { |
|||
"Content-Type": "application/json", |
|||
Accept: "application/json", |
|||
...options.headers, |
|||
} |
|||
} |
|||
|
|||
let json = options.headers["Content-Type"] === "application/json" |
|||
|
|||
const requestOptions = { |
|||
method: method, |
|||
body: json ? JSON.stringify(options.body) : options.body, |
|||
headers: options.headers, |
|||
// TODO: See if this is necessary
|
|||
credentials: "include", |
|||
} |
|||
|
|||
const resp = await fetch(`${this.host}${url}`, requestOptions) |
|||
|
|||
return resp |
|||
} |
|||
|
|||
post = this.apiCall("POST") |
|||
get = this.apiCall("GET") |
|||
patch = this.apiCall("PATCH") |
|||
del = this.apiCall("DELETE") |
|||
put = this.apiCall("PUT") |
|||
} |
|||
|
|||
module.exports = API |
|||
@ -0,0 +1,97 @@ |
|||
const { getGlobalUserParams, getAllApps } = require("../db/utils") |
|||
const { getDB, getCouch } = require("../db") |
|||
const { getGlobalDB } = require("./tenancy") |
|||
const { StaticDatabases } = require("../db/constants") |
|||
|
|||
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants |
|||
const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name |
|||
|
|||
const removeTenantFromInfoDB = async tenantId => { |
|||
try { |
|||
const infoDb = getDB(PLATFORM_INFO_DB) |
|||
let tenants = await infoDb.get(TENANT_DOC) |
|||
tenants.tenantIds = tenants.tenantIds.filter(id => id !== tenantId) |
|||
|
|||
await infoDb.put(tenants) |
|||
} catch (err) { |
|||
console.error(`Error removing tenant ${tenantId} from info db`, err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
exports.removeUserFromInfoDB = async dbUser => { |
|||
const infoDb = getDB(PLATFORM_INFO_DB) |
|||
const keys = [dbUser._id, dbUser.email] |
|||
const userDocs = await infoDb.allDocs({ |
|||
keys, |
|||
include_docs: true, |
|||
}) |
|||
const toDelete = userDocs.rows.map(row => { |
|||
return { |
|||
...row.doc, |
|||
_deleted: true, |
|||
} |
|||
}) |
|||
await infoDb.bulkDocs(toDelete) |
|||
} |
|||
|
|||
const removeUsersFromInfoDB = async tenantId => { |
|||
try { |
|||
const globalDb = getGlobalDB(tenantId) |
|||
const infoDb = getDB(PLATFORM_INFO_DB) |
|||
const allUsers = await globalDb.allDocs( |
|||
getGlobalUserParams(null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
const allEmails = allUsers.rows.map(row => row.doc.email) |
|||
// get the id docs
|
|||
let keys = allUsers.rows.map(row => row.id) |
|||
// and the email docs
|
|||
keys = keys.concat(allEmails) |
|||
// retrieve the docs and delete them
|
|||
const userDocs = await infoDb.allDocs({ |
|||
keys, |
|||
include_docs: true, |
|||
}) |
|||
const toDelete = userDocs.rows.map(row => { |
|||
return { |
|||
...row.doc, |
|||
_deleted: true, |
|||
} |
|||
}) |
|||
await infoDb.bulkDocs(toDelete) |
|||
} catch (err) { |
|||
console.error(`Error removing tenant ${tenantId} users from info db`, err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
const removeGlobalDB = async tenantId => { |
|||
try { |
|||
const globalDb = getGlobalDB(tenantId) |
|||
await globalDb.destroy() |
|||
} catch (err) { |
|||
console.error(`Error removing tenant ${tenantId} users from info db`, err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
const removeTenantApps = async tenantId => { |
|||
try { |
|||
const apps = await getAllApps(getCouch(), { all: true }) |
|||
const destroyPromises = apps.map(app => getDB(app.appId).destroy()) |
|||
await Promise.allSettled(destroyPromises) |
|||
} catch (err) { |
|||
console.error(`Error removing tenant ${tenantId} apps`, err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
// can't live in tenancy package due to circular dependency on db/utils
|
|||
exports.deleteTenant = async tenantId => { |
|||
await removeTenantFromInfoDB(tenantId) |
|||
await removeUsersFromInfoDB(tenantId) |
|||
await removeGlobalDB(tenantId) |
|||
await removeTenantApps(tenantId) |
|||
} |
|||
@ -1,139 +0,0 @@ |
|||
import * as Sentry from "@sentry/browser" |
|||
import posthog from "posthog-js" |
|||
import api from "builderStore/api" |
|||
|
|||
let analyticsEnabled |
|||
const posthogConfigured = process.env.POSTHOG_TOKEN && process.env.POSTHOG_URL |
|||
const sentryConfigured = process.env.SENTRY_DSN |
|||
|
|||
const FEEDBACK_SUBMITTED_KEY = "budibase:feedback_submitted" |
|||
const APP_FIRST_STARTED_KEY = "budibase:first_run" |
|||
const feedbackHours = 12 |
|||
|
|||
async function activate() { |
|||
if (analyticsEnabled === undefined) { |
|||
// only the server knows the true NODE_ENV
|
|||
// this was an issue as NODE_ENV = 'cypress' on the server,
|
|||
// but 'production' on the client
|
|||
const response = await api.get("/api/analytics") |
|||
analyticsEnabled = (await response.json()).enabled === true |
|||
} |
|||
if (!analyticsEnabled) return |
|||
if (sentryConfigured) Sentry.init({ dsn: process.env.SENTRY_DSN }) |
|||
if (posthogConfigured) { |
|||
posthog.init(process.env.POSTHOG_TOKEN, { |
|||
autocapture: false, |
|||
capture_pageview: false, |
|||
api_host: process.env.POSTHOG_URL, |
|||
}) |
|||
posthog.set_config({ persistence: "cookie" }) |
|||
} |
|||
} |
|||
|
|||
function identify(id) { |
|||
if (!analyticsEnabled || !id) return |
|||
if (posthogConfigured) posthog.identify(id) |
|||
if (sentryConfigured) |
|||
Sentry.configureScope(scope => { |
|||
scope.setUser({ id: id }) |
|||
}) |
|||
} |
|||
|
|||
async function identifyByApiKey(apiKey) { |
|||
if (!analyticsEnabled) return true |
|||
try { |
|||
const response = await fetch( |
|||
`https://03gaine137.execute-api.eu-west-1.amazonaws.com/prod/account/id?api_key=${apiKey.trim()}` |
|||
) |
|||
if (response.status === 200) { |
|||
const id = await response.json() |
|||
|
|||
await api.put("/api/keys/userId", { value: id }) |
|||
identify(id) |
|||
return true |
|||
} |
|||
|
|||
return false |
|||
} catch (error) { |
|||
console.log(error) |
|||
} |
|||
} |
|||
|
|||
function captureException(err) { |
|||
if (!analyticsEnabled) return |
|||
Sentry.captureException(err) |
|||
captureEvent("Error", { error: err.message ? err.message : err }) |
|||
} |
|||
|
|||
function captureEvent(eventName, props = {}) { |
|||
if (!analyticsEnabled || !process.env.POSTHOG_TOKEN) return |
|||
props.sourceApp = "builder" |
|||
posthog.capture(eventName, props) |
|||
} |
|||
|
|||
if (!localStorage.getItem(APP_FIRST_STARTED_KEY)) { |
|||
localStorage.setItem(APP_FIRST_STARTED_KEY, Date.now()) |
|||
} |
|||
|
|||
const isFeedbackTimeElapsed = sinceDateStr => { |
|||
const sinceDate = parseFloat(sinceDateStr) |
|||
const feedbackMilliseconds = feedbackHours * 60 * 60 * 1000 |
|||
return Date.now() > sinceDate + feedbackMilliseconds |
|||
} |
|||
|
|||
function submitFeedback(values) { |
|||
if (!analyticsEnabled || !process.env.POSTHOG_TOKEN) return |
|||
localStorage.setItem(FEEDBACK_SUBMITTED_KEY, Date.now()) |
|||
|
|||
const prefixedValues = Object.entries(values).reduce((obj, [key, value]) => { |
|||
obj[`feedback_${key}`] = value |
|||
return obj |
|||
}, {}) |
|||
|
|||
posthog.capture("Feedback Submitted", prefixedValues) |
|||
} |
|||
|
|||
function requestFeedbackOnDeploy() { |
|||
if (!analyticsEnabled || !process.env.POSTHOG_TOKEN) return false |
|||
const lastSubmittedStr = localStorage.getItem(FEEDBACK_SUBMITTED_KEY) |
|||
if (!lastSubmittedStr) return true |
|||
return isFeedbackTimeElapsed(lastSubmittedStr) |
|||
} |
|||
|
|||
function highlightFeedbackIcon() { |
|||
if (!analyticsEnabled || !process.env.POSTHOG_TOKEN) return false |
|||
const lastSubmittedStr = localStorage.getItem(FEEDBACK_SUBMITTED_KEY) |
|||
if (lastSubmittedStr) return isFeedbackTimeElapsed(lastSubmittedStr) |
|||
const firstRunStr = localStorage.getItem(APP_FIRST_STARTED_KEY) |
|||
if (!firstRunStr) return false |
|||
return isFeedbackTimeElapsed(firstRunStr) |
|||
} |
|||
|
|||
// Opt In/Out
|
|||
const ifAnalyticsEnabled = func => () => { |
|||
if (analyticsEnabled && process.env.POSTHOG_TOKEN) { |
|||
return func() |
|||
} |
|||
} |
|||
const disabled = () => posthog.has_opted_out_capturing() |
|||
const optIn = () => posthog.opt_in_capturing() |
|||
const optOut = () => posthog.opt_out_capturing() |
|||
|
|||
export default { |
|||
activate, |
|||
identify, |
|||
identifyByApiKey, |
|||
captureException, |
|||
captureEvent, |
|||
requestFeedbackOnDeploy, |
|||
submitFeedback, |
|||
highlightFeedbackIcon, |
|||
disabled: () => { |
|||
if (analyticsEnabled == null) { |
|||
return true |
|||
} |
|||
return ifAnalyticsEnabled(disabled) |
|||
}, |
|||
optIn: ifAnalyticsEnabled(optIn), |
|||
optOut: ifAnalyticsEnabled(optOut), |
|||
} |
|||
@ -0,0 +1,94 @@ |
|||
export default class IntercomClient { |
|||
constructor(token) { |
|||
this.token = token |
|||
} |
|||
|
|||
/** |
|||
* Instantiate intercom using their provided script. |
|||
*/ |
|||
init() { |
|||
if (!this.token) return |
|||
|
|||
const token = this.token |
|||
|
|||
var w = window |
|||
var ic = w.Intercom |
|||
if (typeof ic === "function") { |
|||
ic("reattach_activator") |
|||
ic("update", w.intercomSettings) |
|||
} else { |
|||
var d = document |
|||
var i = function () { |
|||
i.c(arguments) |
|||
} |
|||
i.q = [] |
|||
i.c = function (args) { |
|||
i.q.push(args) |
|||
} |
|||
w.Intercom = i |
|||
var l = function () { |
|||
var s = d.createElement("script") |
|||
s.type = "text/javascript" |
|||
s.async = true |
|||
s.src = "https://widget.intercom.io/widget/" + token |
|||
var x = d.getElementsByTagName("script")[0] |
|||
x.parentNode.insertBefore(s, x) |
|||
} |
|||
if (document.readyState === "complete") { |
|||
l() |
|||
} else if (w.attachEvent) { |
|||
w.attachEvent("onload", l) |
|||
} else { |
|||
w.addEventListener("load", l, false) |
|||
} |
|||
|
|||
this.initialised = true |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Show the intercom chat bubble. |
|||
* @param {Object} user - user to identify |
|||
* @returns Intercom global object |
|||
*/ |
|||
show(user = {}) { |
|||
if (!this.initialised) return |
|||
|
|||
return window.Intercom("boot", { |
|||
app_id: this.token, |
|||
...user, |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Update intercom user details and messages. |
|||
* @returns Intercom global object |
|||
*/ |
|||
update() { |
|||
if (!this.initialised) return |
|||
|
|||
return window.Intercom("update") |
|||
} |
|||
|
|||
/** |
|||
* Capture analytics events and send them to intercom. |
|||
* @param {String} event - event identifier |
|||
* @param {Object} props - properties for the event |
|||
* @returns Intercom global object |
|||
*/ |
|||
captureEvent(event, props = {}) { |
|||
if (!this.initialised) return |
|||
|
|||
return window.Intercom("trackEvent", event, props) |
|||
} |
|||
|
|||
/** |
|||
* Disassociate the user from the current session. |
|||
* @returns Intercom global object |
|||
*/ |
|||
logout() { |
|||
if (!this.initialised) return |
|||
|
|||
return window.Intercom("shutdown") |
|||
} |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
import posthog from "posthog-js" |
|||
import { Events } from "./constants" |
|||
|
|||
export default class PosthogClient { |
|||
constructor(token, url) { |
|||
this.token = token |
|||
this.url = url |
|||
} |
|||
|
|||
init() { |
|||
if (!this.token || !this.url) return |
|||
|
|||
posthog.init(this.token, { |
|||
autocapture: false, |
|||
capture_pageview: false, |
|||
api_host: this.url, |
|||
}) |
|||
posthog.set_config({ persistence: "cookie" }) |
|||
|
|||
this.initialised = true |
|||
} |
|||
|
|||
/** |
|||
* Set the posthog context to the current user |
|||
* @param {String} id - unique user id |
|||
*/ |
|||
identify(id) { |
|||
if (!this.initialised) return |
|||
|
|||
posthog.identify(id) |
|||
} |
|||
|
|||
/** |
|||
* Update user metadata associated with current user in posthog |
|||
* @param {Object} meta - user fields |
|||
*/ |
|||
updateUser(meta) { |
|||
if (!this.initialised) return |
|||
|
|||
posthog.people.set(meta) |
|||
} |
|||
|
|||
/** |
|||
* Capture analytics events and send them to posthog. |
|||
* @param {String} event - event identifier |
|||
* @param {Object} props - properties for the event |
|||
*/ |
|||
captureEvent(eventName, props) { |
|||
if (!this.initialised) return |
|||
|
|||
props.sourceApp = "builder" |
|||
posthog.capture(eventName, props) |
|||
} |
|||
|
|||
/** |
|||
* Submit NPS feedback to posthog. |
|||
* @param {Object} values - NPS Values |
|||
*/ |
|||
npsFeedback(values) { |
|||
if (!this.initialised) return |
|||
|
|||
localStorage.setItem(Events.NPS.SUBMITTED, Date.now()) |
|||
|
|||
const prefixedFeedback = {} |
|||
for (let key in values) { |
|||
prefixedFeedback[`feedback_${key}`] = values[key] |
|||
} |
|||
|
|||
posthog.capture(Events.NPS.SUBMITTED, prefixedFeedback) |
|||
} |
|||
|
|||
/** |
|||
* Reset posthog user back to initial state on logout. |
|||
*/ |
|||
logout() { |
|||
if (!this.initialised) return |
|||
|
|||
posthog.reset() |
|||
} |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
import * as Sentry from "@sentry/browser" |
|||
|
|||
export default class SentryClient { |
|||
constructor(dsn) { |
|||
this.dsn = dsn |
|||
} |
|||
|
|||
init() { |
|||
if (this.dsn) { |
|||
Sentry.init({ dsn: this.dsn }) |
|||
|
|||
this.initalised = true |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Capture an exception and send it to sentry. |
|||
* @param {Error} err - JS error object |
|||
*/ |
|||
captureException(err) { |
|||
if (!this.initalised) return |
|||
|
|||
Sentry.captureException(err) |
|||
} |
|||
|
|||
/** |
|||
* Identify user in sentry. |
|||
* @param {String} id - Unique user id |
|||
*/ |
|||
identify(id) { |
|||
if (!this.initalised) return |
|||
|
|||
Sentry.configureScope(scope => { |
|||
scope.setUser({ id }) |
|||
}) |
|||
} |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
export const Events = { |
|||
BUILDER: { |
|||
STARTED: "Builder Started", |
|||
}, |
|||
COMPONENT: { |
|||
CREATED: "Added Component", |
|||
}, |
|||
DATASOURCE: { |
|||
CREATED: "Datasource Created", |
|||
UPDATED: "Datasource Updated", |
|||
}, |
|||
TABLE: { |
|||
CREATED: "Table Created", |
|||
}, |
|||
VIEW: { |
|||
CREATED: "View Created", |
|||
ADDED_FILTER: "Added View Filter", |
|||
ADDED_CALCULATE: "Added View Calculate", |
|||
}, |
|||
SCREEN: { |
|||
CREATED: "Screen Created", |
|||
}, |
|||
AUTOMATION: { |
|||
CREATED: "Automation Created", |
|||
SAVED: "Automation Saved", |
|||
BLOCK_ADDED: "Added Automation Block", |
|||
}, |
|||
NPS: { |
|||
SUBMITTED: "budibase:feedback_submitted", |
|||
}, |
|||
APP: { |
|||
CREATED: "budibase:app_created", |
|||
PUBLISHED: "budibase:app_published", |
|||
UNPUBLISHED: "budibase:app_unpublished", |
|||
}, |
|||
ANALYTICS: { |
|||
OPT_IN: "budibase:analytics_opt_in", |
|||
OPT_OUT: "budibase:analytics_opt_out", |
|||
}, |
|||
USER: { |
|||
INVITE: "budibase:portal_user_invite", |
|||
}, |
|||
SMTP: { |
|||
SAVED: "budibase:smtp_saved", |
|||
}, |
|||
SSO: { |
|||
SAVED: "budibase:sso_saved", |
|||
}, |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
import api from "builderStore/api" |
|||
import PosthogClient from "./PosthogClient" |
|||
import IntercomClient from "./IntercomClient" |
|||
import SentryClient from "./SentryClient" |
|||
import { Events } from "./constants" |
|||
|
|||
const posthog = new PosthogClient( |
|||
process.env.POSTHOG_TOKEN, |
|||
process.env.POSTHOG_URL |
|||
) |
|||
const sentry = new SentryClient(process.env.SENTRY_DSN) |
|||
const intercom = new IntercomClient(process.env.INTERCOM_TOKEN) |
|||
|
|||
class AnalyticsHub { |
|||
constructor() { |
|||
this.clients = [posthog, sentry, intercom] |
|||
} |
|||
|
|||
async activate() { |
|||
const analyticsStatus = await api.get("/api/analytics") |
|||
const json = await analyticsStatus.json() |
|||
|
|||
// Analytics disabled
|
|||
if (!json.enabled) return |
|||
|
|||
this.clients.forEach(client => client.init()) |
|||
} |
|||
|
|||
identify(id, metadata) { |
|||
posthog.identify(id) |
|||
if (metadata) { |
|||
posthog.updateUser(metadata) |
|||
} |
|||
sentry.identify(id) |
|||
} |
|||
|
|||
captureException(err) { |
|||
sentry.captureException(err) |
|||
} |
|||
|
|||
captureEvent(eventName, props = {}) { |
|||
posthog.captureEvent(eventName, props) |
|||
intercom.captureEvent(eventName, props) |
|||
} |
|||
|
|||
showChat(user) { |
|||
intercom.show(user) |
|||
} |
|||
|
|||
submitFeedback(values) { |
|||
posthog.npsFeedback(values) |
|||
} |
|||
|
|||
async logout() { |
|||
posthog.logout() |
|||
intercom.logout() |
|||
} |
|||
} |
|||
|
|||
const analytics = new AnalyticsHub() |
|||
|
|||
export { Events } |
|||
export default analytics |
|||
@ -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 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("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,35 @@ |
|||
<script> |
|||
import { Select, Label } from "@budibase/bbui" |
|||
import { currentAsset, store } from "builderStore" |
|||
import { getActionProviderComponents } from "builderStore/dataBinding" |
|||
|
|||
export let parameters |
|||
|
|||
$: actionProviders = getActionProviderComponents( |
|||
$currentAsset, |
|||
$store.selectedComponentId, |
|||
"RefreshDatasource" |
|||
) |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<Label small>Data Provider</Label> |
|||
<Select |
|||
bind:value={parameters.componentId} |
|||
options={actionProviders} |
|||
getOptionLabel={x => x._instanceName} |
|||
getOptionValue={x => x._id} |
|||
/> |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
display: grid; |
|||
column-gap: var(--spacing-l); |
|||
row-gap: var(--spacing-s); |
|||
grid-template-columns: 70px 1fr; |
|||
align-items: center; |
|||
max-width: 400px; |
|||
margin: 0 auto; |
|||
} |
|||
</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 /> |
|||
|
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue