diff --git a/packages/bbui/src/FancyForm/FancyCheckboxGroup.svelte b/packages/bbui/src/FancyForm/FancyCheckboxGroup.svelte
index aaea388c36..ca3a6d937a 100644
--- a/packages/bbui/src/FancyForm/FancyCheckboxGroup.svelte
+++ b/packages/bbui/src/FancyForm/FancyCheckboxGroup.svelte
@@ -12,7 +12,6 @@
const dispatch = createEventDispatcher()
$: updateSelected(selectedBooleans)
- $: dispatch("change", selected)
$: allSelected = selected?.length === options.length
$: noneSelected = !selected?.length
@@ -28,6 +27,7 @@
}
}
selected = array
+ dispatch("change", selected)
}
function toggleSelectAll() {
@@ -36,6 +36,7 @@
} else {
selectedBooleans = reset()
}
+ dispatch("change", selected)
}
diff --git a/packages/builder/package.json b/packages/builder/package.json
index a2567dc638..646bb144df 100644
--- a/packages/builder/package.json
+++ b/packages/builder/package.json
@@ -9,7 +9,8 @@
"dev:builder": "routify -c dev:vite",
"dev:vite": "vite --host 0.0.0.0",
"rollup": "rollup -c -w",
- "test": "vitest run"
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"jest": {
"globals": {
diff --git a/packages/builder/src/components/backend/DatasourceNavigator/_components/DatasourceCard.svelte b/packages/builder/src/components/backend/DatasourceNavigator/_components/DatasourceCard.svelte
deleted file mode 100644
index 67ecf1c56c..0000000000
--- a/packages/builder/src/components/backend/DatasourceNavigator/_components/DatasourceCard.svelte
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
- {#if hasData}
+ {#if hasData($datasources, $tables)}
{/if}
@@ -191,7 +69,7 @@
internalTableModal.show({ promptUpload: true })}
title="Upload data"
description="Non-relational"
{disabled}
@@ -221,14 +99,17 @@
- {#each integrations as [key, value]}
+ {#each $integrations as integration}
handleIntegrationSelect(key)}
- title={value.friendlyName}
- description={value.type}
+ on:click={() => externalDatasourceModal.show(integration)}
+ title={integration.friendlyName}
+ description={integration.type}
{disabled}
>
-
+
{/each}
diff --git a/packages/builder/src/stores/backend/datasources.js b/packages/builder/src/stores/backend/datasources.js
index 1a19cd5638..56c0a1b688 100644
--- a/packages/builder/src/stores/backend/datasources.js
+++ b/packages/builder/src/stores/backend/datasources.js
@@ -1,6 +1,21 @@
import { writable, derived, get } from "svelte/store"
+import { IntegrationTypes, DEFAULT_BB_DATASOURCE_ID } from "constants/backend"
import { queries, tables } from "./"
import { API } from "api"
+import { DatasourceFeature } from "@budibase/types"
+import { notifications } from "@budibase/bbui"
+
+export class ImportTableError extends Error {
+ constructor(message) {
+ super(message)
+ const [title, description] = message.split(" - ")
+
+ this.name = "TableSelectionError"
+ // Capitalize the first character of both the title and description
+ this.title = title[0].toUpperCase() + title.substr(1)
+ this.description = description[0].toUpperCase() + description.substr(1)
+ }
+}
export function createDatasourcesStore() {
const store = writable({
@@ -8,9 +23,13 @@ export function createDatasourcesStore() {
selectedDatasourceId: null,
schemaError: null,
})
+
const derivedStore = derived(store, $store => ({
...$store,
selected: $store.list?.find(ds => ds._id === $store.selectedDatasourceId),
+ hasDefaultData: $store.list.some(
+ datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID
+ ),
}))
const fetch = async () => {
@@ -50,27 +69,62 @@ export function createDatasourcesStore() {
}
const updateSchema = async (datasource, tablesFilter) => {
- const response = await API.buildDatasourceSchema({
- datasourceId: datasource?._id,
- tablesFilter,
- })
- return updateDatasource(response)
- }
-
- const save = async (body, { fetchSchema, tablesFilter } = {}) => {
- if (fetchSchema == null) {
- fetchSchema = false
- }
- let response
- if (body._id) {
- response = await API.updateDatasource(body)
- } else {
- response = await API.createDatasource({
- datasource: body,
- fetchSchema,
+ try {
+ const response = await API.buildDatasourceSchema({
+ datasourceId: datasource?._id,
tablesFilter,
})
+ updateDatasource(response)
+ } catch (e) {
+ // buildDatasourceSchema call returns user presentable errors with two parts divided with a " - ".
+ if (e.message.split(" - ").length === 2) {
+ throw new ImportTableError(e.message)
+ } else {
+ throw e
+ }
}
+ }
+
+ const sourceCount = source => {
+ return get(store).list.filter(datasource => datasource.source === source)
+ .length
+ }
+
+ const create = async ({ integration, fields }) => {
+ try {
+ const datasource = {
+ type: "datasource",
+ source: integration.name,
+ config: fields,
+ name: `${integration.friendlyName}-${
+ sourceCount(integration.name) + 1
+ }`,
+ plus: integration.plus && integration.name !== IntegrationTypes.REST,
+ }
+
+ if (integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
+ const { connected } = await API.validateDatasource(datasource)
+ if (!connected) throw new Error("Unable to connect")
+ }
+
+ const response = await API.createDatasource({
+ datasource,
+ fetchSchema:
+ integration.plus &&
+ integration.name !== IntegrationTypes.GOOGLE_SHEETS,
+ })
+
+ notifications.success("Datasource created successfully.")
+
+ return updateDatasource(response)
+ } catch (e) {
+ notifications.error(`Error creating datasource: ${e.message}`)
+ throw e
+ }
+ }
+
+ const save = async body => {
+ const response = await API.updateDatasource(body)
return updateDatasource(response)
}
@@ -132,16 +186,23 @@ export function createDatasourcesStore() {
}
}
+ const getTableNames = async datasource => {
+ const info = await API.fetchInfoForDatasource(datasource)
+ return info.tableNames || []
+ }
+
return {
subscribe: derivedStore.subscribe,
fetch,
init: fetch,
select,
updateSchema,
+ create,
save,
delete: deleteDatasource,
removeSchemaError,
replaceDatasource,
+ getTableNames,
}
}
diff --git a/packages/builder/src/stores/backend/index.js b/packages/builder/src/stores/backend/index.js
index 2c2621e009..6fbc9f82c7 100644
--- a/packages/builder/src/stores/backend/index.js
+++ b/packages/builder/src/stores/backend/index.js
@@ -3,7 +3,8 @@ export { tables } from "./tables"
export { views } from "./views"
export { permissions } from "./permissions"
export { roles } from "./roles"
-export { datasources } from "./datasources"
+export { datasources, ImportTableError } from "./datasources"
export { integrations } from "./integrations"
+export { sortedIntegrations } from "./sortedIntegrations"
export { queries } from "./queries"
export { flags } from "./flags"
diff --git a/packages/builder/src/stores/backend/integrations.js b/packages/builder/src/stores/backend/integrations.js
index 717b656c72..6ee58961c7 100644
--- a/packages/builder/src/stores/backend/integrations.js
+++ b/packages/builder/src/stores/backend/integrations.js
@@ -2,14 +2,16 @@ import { writable } from "svelte/store"
import { API } from "api"
const createIntegrationsStore = () => {
- const store = writable(null)
+ const store = writable({})
+
+ const init = async () => {
+ const integrations = await API.getIntegrations()
+ store.set(integrations)
+ }
return {
...store,
- init: async () => {
- const integrations = await API.getIntegrations()
- store.set(integrations)
- },
+ init,
}
}
diff --git a/packages/builder/src/stores/backend/sortedIntegrations.js b/packages/builder/src/stores/backend/sortedIntegrations.js
new file mode 100644
index 0000000000..3f5dd850ab
--- /dev/null
+++ b/packages/builder/src/stores/backend/sortedIntegrations.js
@@ -0,0 +1,39 @@
+import { integrations } from "./integrations"
+import { derived } from "svelte/store"
+
+import { DatasourceTypes } from "constants/backend"
+
+const getIntegrationOrder = type => {
+ if (type === DatasourceTypes.API) return 1
+ if (type === DatasourceTypes.RELATIONAL) return 2
+ if (type === DatasourceTypes.NON_RELATIONAL) return 3
+
+ // Sort all others arbitrarily by the first character of their name.
+ // Character codes can technically be as low as 0, so make sure the number is at least 4
+ return type.charCodeAt(0) + 4
+}
+
+export const createSortedIntegrationsStore = () => {
+ return derived(integrations, $integrations => {
+ const integrationsAsArray = Object.entries($integrations).map(
+ ([name, integration]) => ({
+ name,
+ ...integration,
+ })
+ )
+
+ return integrationsAsArray.sort((integrationA, integrationB) => {
+ const integrationASortOrder = getIntegrationOrder(integrationA.type)
+ const integrationBSortOrder = getIntegrationOrder(integrationB.type)
+ if (integrationASortOrder === integrationBSortOrder) {
+ return integrationA.friendlyName.localeCompare(
+ integrationB.friendlyName
+ )
+ }
+
+ return integrationASortOrder < integrationBSortOrder ? -1 : 1
+ })
+ })
+}
+
+export const sortedIntegrations = createSortedIntegrationsStore()
diff --git a/packages/builder/src/stores/backend/sortedIntegrations.test.js b/packages/builder/src/stores/backend/sortedIntegrations.test.js
new file mode 100644
index 0000000000..db2323aae5
--- /dev/null
+++ b/packages/builder/src/stores/backend/sortedIntegrations.test.js
@@ -0,0 +1,127 @@
+import { it, expect, describe, beforeEach, vi } from "vitest"
+import { createSortedIntegrationsStore } from "./sortedIntegrations"
+import { DatasourceTypes } from "constants/backend"
+
+import { derived } from "svelte/store"
+import { integrations } from "stores/backend/integrations"
+
+vi.mock("svelte/store", () => ({
+ derived: vi.fn(() => {}),
+}))
+
+vi.mock("stores/backend/integrations", () => ({ integrations: vi.fn() }))
+
+const inputA = {
+ nonRelationalA: {
+ friendlyName: "non-relational A",
+ type: DatasourceTypes.NON_RELATIONAL,
+ },
+ relationalB: {
+ friendlyName: "relational B",
+ type: DatasourceTypes.RELATIONAL,
+ },
+ relationalA: {
+ friendlyName: "relational A",
+ type: DatasourceTypes.RELATIONAL,
+ },
+ api: {
+ friendlyName: "api",
+ type: DatasourceTypes.API,
+ },
+ relationalC: {
+ friendlyName: "relational C",
+ type: DatasourceTypes.RELATIONAL,
+ },
+ nonRelationalB: {
+ friendlyName: "non-relational B",
+ type: DatasourceTypes.NON_RELATIONAL,
+ },
+ otherC: {
+ friendlyName: "other C",
+ type: "random",
+ },
+ otherB: {
+ friendlyName: "other B",
+ type: "arbitrary",
+ },
+ otherA: {
+ friendlyName: "other A",
+ type: "arbitrary",
+ },
+}
+
+const inputB = Object.fromEntries(Object.entries(inputA).reverse())
+
+const expectedOutput = [
+ {
+ name: "api",
+ friendlyName: "api",
+ type: DatasourceTypes.API,
+ },
+ {
+ name: "relationalA",
+ friendlyName: "relational A",
+ type: DatasourceTypes.RELATIONAL,
+ },
+ {
+ name: "relationalB",
+ friendlyName: "relational B",
+ type: DatasourceTypes.RELATIONAL,
+ },
+ {
+ name: "relationalC",
+ friendlyName: "relational C",
+ type: DatasourceTypes.RELATIONAL,
+ },
+ {
+ name: "nonRelationalA",
+ friendlyName: "non-relational A",
+ type: DatasourceTypes.NON_RELATIONAL,
+ },
+ {
+ name: "nonRelationalB",
+ friendlyName: "non-relational B",
+ type: DatasourceTypes.NON_RELATIONAL,
+ },
+ {
+ name: "otherA",
+ friendlyName: "other A",
+ type: "arbitrary",
+ },
+ {
+ name: "otherB",
+ friendlyName: "other B",
+ type: "arbitrary",
+ },
+ {
+ name: "otherC",
+ friendlyName: "other C",
+ type: "random",
+ },
+]
+
+describe("sorted integrations store", () => {
+ beforeEach(ctx => {
+ vi.clearAllMocks()
+
+ ctx.returnedStore = createSortedIntegrationsStore()
+
+ ctx.derivedCallback = derived.mock.calls[0][1]
+ })
+
+ it("calls derived with the correct parameters", () => {
+ expect(derived).toHaveBeenCalledTimes(1)
+ expect(derived).toHaveBeenCalledWith(integrations, expect.toBeFunc())
+ })
+
+ describe("derived callback", () => {
+ it("When no integrations are loaded", ctx => {
+ expect(ctx.derivedCallback({})).toEqual([])
+ })
+
+ it("When integrations are present", ctx => {
+ expect(ctx.derivedCallback(inputA)).toEqual(expectedOutput)
+ expect(ctx.derivedCallback(inputB)).toEqual(expectedOutput)
+ })
+ })
+})
diff --git a/packages/builder/src/stores/selectors.js b/packages/builder/src/stores/selectors.js
new file mode 100644
index 0000000000..f592250f25
--- /dev/null
+++ b/packages/builder/src/stores/selectors.js
@@ -0,0 +1,35 @@
+import { DEFAULT_BB_DATASOURCE_ID } from "constants/backend"
+import { DatasourceFeature } from "@budibase/types"
+
+export const integrationForDatasource = (integrations, datasource) => ({
+ name: datasource.source,
+ ...integrations[datasource.source],
+})
+
+export const hasData = (datasources, tables) =>
+ datasources.list.length > 1 || tables.list.length > 1
+
+export const hasDefaultData = datasources =>
+ datasources.list.some(
+ datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID
+ )
+
+export const configFromIntegration = integration => {
+ const config = {}
+
+ Object.entries(integration?.datasource || {}).forEach(([key, properties]) => {
+ if (properties.type === "fieldGroup") {
+ Object.keys(properties.fields).forEach(fieldKey => {
+ config[fieldKey] = null
+ })
+ } else {
+ config[key] = properties.default ?? null
+ }
+ })
+
+ return config
+}
+
+export const shouldIntegrationFetchTableNames = integration => {
+ return integration.features?.[DatasourceFeature.FETCH_TABLE_NAMES]
+}
diff --git a/packages/builder/src/stores/selectors.test.js b/packages/builder/src/stores/selectors.test.js
new file mode 100644
index 0000000000..7bc790ad75
--- /dev/null
+++ b/packages/builder/src/stores/selectors.test.js
@@ -0,0 +1,56 @@
+import { it, expect, describe, beforeEach, vi } from "vitest"
+import { DEFAULT_BB_DATASOURCE_ID } from "constants/backend"
+import { integrationForDatasource, hasData, hasDefaultData } from "./selectors"
+
+describe("selectors", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe("integrationForDatasource", () => {
+ it("returns the integration corresponding to the given datasource", () => {
+ expect(
+ integrationForDatasource(
+ { integrationOne: { some: "data" } },
+ { source: "integrationOne" }
+ )
+ ).toEqual({ some: "data", name: "integrationOne" })
+ })
+ })
+
+ describe("hasData", () => {
+ describe("when the user has created a datasource in addition to the premade Budibase DB source", () => {
+ it("returns true", () => {
+ expect(hasData({ list: [1, 1] }, { list: [] })).toBe(true)
+ })
+ })
+
+ describe("when the user has created a table in addition to the premade users table", () => {
+ it("returns true", () => {
+ expect(hasData({ list: [] }, { list: [1, 1] })).toBe(true)
+ })
+ })
+
+ describe("when the user doesn't have data", () => {
+ it("returns false", () => {
+ expect(hasData({ list: [] }, { list: [] })).toBe(false)
+ })
+ })
+ })
+
+ describe("hasDefaultData", () => {
+ describe("when the user has default data", () => {
+ it("returns true", () => {
+ expect(
+ hasDefaultData({ list: [{ _id: DEFAULT_BB_DATASOURCE_ID }] })
+ ).toBe(true)
+ })
+ })
+
+ describe("when the user doesn't have default data", () => {
+ it("returns false", () => {
+ expect(hasDefaultData({ list: [{ _id: "some other id" }] })).toBe(false)
+ })
+ })
+ })
+})