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") |
const env = require("../src/environment") |
||||
|
|
||||
|
env._set("SELF_HOSTED", "1") |
||||
env._set("NODE_ENV", "jest") |
env._set("NODE_ENV", "jest") |
||||
env._set("JWT_SECRET", "test-jwtsecret") |
env._set("JWT_SECRET", "test-jwtsecret") |
||||
env._set("LOG_LEVEL", "silent") |
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> |
<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 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("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,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: { |
* 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 /> |
||||
|
|||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue