Merge pull request #11039 from Budibase/cheeks-fixes
Collaboration and data section fixes
This commit is contained in:
commit
33e6c1e534
|
@ -61,6 +61,9 @@ const INITIAL_FRONTEND_STATE = {
|
||||||
showNotificationAction: false,
|
showNotificationAction: false,
|
||||||
sidePanel: false,
|
sidePanel: false,
|
||||||
},
|
},
|
||||||
|
features: {
|
||||||
|
componentValidation: false,
|
||||||
|
},
|
||||||
errors: [],
|
errors: [],
|
||||||
hasAppPackage: false,
|
hasAppPackage: false,
|
||||||
libraries: null,
|
libraries: null,
|
||||||
|
@ -148,6 +151,10 @@ export const getFrontendStore = () => {
|
||||||
navigation: application.navigation || {},
|
navigation: application.navigation || {},
|
||||||
usedPlugins: application.usedPlugins || [],
|
usedPlugins: application.usedPlugins || [],
|
||||||
hasLock,
|
hasLock,
|
||||||
|
features: {
|
||||||
|
...INITIAL_FRONTEND_STATE.features,
|
||||||
|
...application.features,
|
||||||
|
},
|
||||||
initialised: true,
|
initialised: true,
|
||||||
}))
|
}))
|
||||||
screenHistoryStore.reset()
|
screenHistoryStore.reset()
|
||||||
|
@ -283,9 +290,12 @@ export const getFrontendStore = () => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
save: async screen => {
|
save: async screen => {
|
||||||
// Validate screen structure
|
const state = get(store)
|
||||||
// Temporarily disabled to accommodate migration issues
|
|
||||||
// store.actions.screens.validate(screen)
|
// Validate screen structure if the app supports it
|
||||||
|
if (state.features?.componentValidation) {
|
||||||
|
store.actions.screens.validate(screen)
|
||||||
|
}
|
||||||
|
|
||||||
// Check screen definition for any component settings which need updated
|
// Check screen definition for any component settings which need updated
|
||||||
store.actions.screens.enrichEmptySettings(screen)
|
store.actions.screens.enrichEmptySettings(screen)
|
||||||
|
@ -296,7 +306,6 @@ export const getFrontendStore = () => {
|
||||||
const routesResponse = await API.fetchAppRoutes()
|
const routesResponse = await API.fetchAppRoutes()
|
||||||
|
|
||||||
// If plugins changed we need to fetch the latest app metadata
|
// If plugins changed we need to fetch the latest app metadata
|
||||||
const state = get(store)
|
|
||||||
let usedPlugins = state.usedPlugins
|
let usedPlugins = state.usedPlugins
|
||||||
if (savedScreen.pluginAdded) {
|
if (savedScreen.pluginAdded) {
|
||||||
const { application } = await API.fetchAppPackage(state.appId)
|
const { application } = await API.fetchAppPackage(state.appId)
|
||||||
|
|
|
@ -12,8 +12,10 @@
|
||||||
customQueryText,
|
customQueryText,
|
||||||
} from "helpers/data/utils"
|
} from "helpers/data/utils"
|
||||||
import IntegrationIcon from "./IntegrationIcon.svelte"
|
import IntegrationIcon from "./IntegrationIcon.svelte"
|
||||||
|
import { TableNames } from "constants"
|
||||||
|
|
||||||
let openDataSources = []
|
let openDataSources = []
|
||||||
|
|
||||||
$: enrichedDataSources = enrichDatasources(
|
$: enrichedDataSources = enrichDatasources(
|
||||||
$datasources,
|
$datasources,
|
||||||
$params,
|
$params,
|
||||||
|
@ -71,6 +73,13 @@
|
||||||
$goto(`./datasource/${datasource._id}`)
|
$goto(`./datasource/${datasource._id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectTable = tableId => {
|
||||||
|
tables.select(tableId)
|
||||||
|
if (!$isActive("./table/:tableId")) {
|
||||||
|
$goto(`./table/${tableId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeNode(datasource) {
|
function closeNode(datasource) {
|
||||||
openDataSources = openDataSources.filter(id => datasource._id !== id)
|
openDataSources = openDataSources.filter(id => datasource._id !== id)
|
||||||
}
|
}
|
||||||
|
@ -151,9 +160,16 @@
|
||||||
|
|
||||||
{#if $database?._id}
|
{#if $database?._id}
|
||||||
<div class="hierarchy-items-container">
|
<div class="hierarchy-items-container">
|
||||||
|
<NavItem
|
||||||
|
icon="UserGroup"
|
||||||
|
text="Users"
|
||||||
|
selected={$isActive("./table/:tableId") &&
|
||||||
|
$tables.selected?._id === TableNames.USERS}
|
||||||
|
on:click={() => selectTable(TableNames.USERS)}
|
||||||
|
/>
|
||||||
{#each enrichedDataSources as datasource, idx}
|
{#each enrichedDataSources as datasource, idx}
|
||||||
<NavItem
|
<NavItem
|
||||||
border={idx > 0}
|
border
|
||||||
text={datasource.name}
|
text={datasource.name}
|
||||||
opened={datasource.open}
|
opened={datasource.open}
|
||||||
selected={$isActive("./datasource") && datasource.selected}
|
selected={$isActive("./datasource") && datasource.selected}
|
||||||
|
@ -174,7 +190,7 @@
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
|
||||||
{#if datasource.open}
|
{#if datasource.open}
|
||||||
<TableNavigator sourceId={datasource._id} />
|
<TableNavigator sourceId={datasource._id} {selectTable} />
|
||||||
{#each $queries.list.filter(query => query.datasourceId === datasource._id) as query}
|
{#each $queries.list.filter(query => query.datasourceId === datasource._id) as query}
|
||||||
<NavItem
|
<NavItem
|
||||||
indentLevel={1}
|
indentLevel={1}
|
||||||
|
|
|
@ -10,17 +10,13 @@
|
||||||
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
|
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
|
||||||
|
|
||||||
export let sourceId
|
export let sourceId
|
||||||
|
export let selectTable
|
||||||
|
|
||||||
$: sortedTables = $tables.list
|
$: sortedTables = $tables.list
|
||||||
.filter(table => table.sourceId === sourceId)
|
.filter(
|
||||||
|
table => table.sourceId === sourceId && table._id !== TableNames.USERS
|
||||||
|
)
|
||||||
.sort(alphabetical)
|
.sort(alphabetical)
|
||||||
|
|
||||||
const selectTable = tableId => {
|
|
||||||
tables.select(tableId)
|
|
||||||
if (!$isActive("./table/:tableId")) {
|
|
||||||
$goto(`./table/${tableId}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $database?._id}
|
{#if $database?._id}
|
||||||
|
|
|
@ -469,10 +469,12 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-xl);
|
gap: var(--spacing-xl);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.overlay-wrap {
|
.overlay-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.mode-overlay {
|
.mode-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -2,8 +2,17 @@
|
||||||
import { Button, Layout } from "@budibase/bbui"
|
import { Button, Layout } from "@budibase/bbui"
|
||||||
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
|
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
|
||||||
import Panel from "components/design/Panel.svelte"
|
import Panel from "components/design/Panel.svelte"
|
||||||
import { isActive, goto } from "@roxi/routify"
|
import { isActive, goto, redirect } from "@roxi/routify"
|
||||||
import BetaButton from "./_components/BetaButton.svelte"
|
import BetaButton from "./_components/BetaButton.svelte"
|
||||||
|
import { datasources } from "stores/backend"
|
||||||
|
|
||||||
|
$: {
|
||||||
|
// If we ever don't have any data other than the users table, prompt the
|
||||||
|
// user to add some
|
||||||
|
if (!$datasources.hasData) {
|
||||||
|
$redirect("./new")
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- routify:options index=1 -->
|
<!-- routify:options index=1 -->
|
||||||
|
|
|
@ -6,12 +6,15 @@
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
|
import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
|
||||||
|
import { TableNames } from "constants"
|
||||||
|
|
||||||
let modal
|
let modal
|
||||||
|
|
||||||
$: internalTablesBySourceId = $tables.list.filter(
|
$: internalTablesBySourceId = $tables.list.filter(
|
||||||
table =>
|
table =>
|
||||||
table.type !== "external" && table.sourceId === BUDIBASE_INTERNAL_DB_ID
|
table.type !== "external" &&
|
||||||
|
table.sourceId === BUDIBASE_INTERNAL_DB_ID &&
|
||||||
|
table._id !== TableNames.USERS
|
||||||
)
|
)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
|
@ -4,11 +4,13 @@
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const { list, selected } = $datasources
|
const { list, selected, hasData } = $datasources
|
||||||
if (selected) {
|
if (selected) {
|
||||||
$redirect(`./${selected?._id}`)
|
$redirect(`./${selected?._id}`)
|
||||||
} else {
|
} else if (hasData && list?.length) {
|
||||||
$redirect(`./${list[0]._id}`)
|
$redirect(`./${list[0]._id}`)
|
||||||
|
} else {
|
||||||
|
$redirect("../new")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,17 +1,13 @@
|
||||||
<script>
|
<script>
|
||||||
import { redirect } from "@roxi/routify"
|
import { redirect } from "@roxi/routify"
|
||||||
import { onMount } from "svelte"
|
import { TableNames } from "constants"
|
||||||
import { datasources } from "stores/backend"
|
import { datasources } from "stores/backend"
|
||||||
|
|
||||||
$: hasData =
|
$: {
|
||||||
$datasources.list.find(x => (x._id = "bb_internal"))?.entities?.length >
|
if ($datasources.hasData) {
|
||||||
1 || $datasources.list.length > 1
|
$redirect(`./table/${TableNames.USERS}`)
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (!hasData) {
|
|
||||||
$redirect("./new")
|
|
||||||
} else {
|
} else {
|
||||||
$redirect("./table")
|
$redirect("./new")
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import { tables } from "stores/backend"
|
import { datasources, tables } from "stores/backend"
|
||||||
import { redirect } from "@roxi/routify"
|
import { redirect } from "@roxi/routify"
|
||||||
|
import { TableNames } from "constants"
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
const { list, selected } = $tables
|
if ($tables.selected) {
|
||||||
if (selected) {
|
$redirect(`./${$tables.selected._id}`)
|
||||||
$redirect(`./${selected?._id}`)
|
} else if ($datasources.hasData) {
|
||||||
} else if (list?.length) {
|
$redirect(`./${TableNames.USERS}`)
|
||||||
$redirect(`./${list[0]._id}`)
|
} else {
|
||||||
|
$redirect("../new")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
import { writable, derived, get } from "svelte/store"
|
import { writable, derived, get } from "svelte/store"
|
||||||
import { IntegrationTypes, DEFAULT_BB_DATASOURCE_ID } from "constants/backend"
|
import {
|
||||||
import { queries, tables } from "./"
|
IntegrationTypes,
|
||||||
|
DEFAULT_BB_DATASOURCE_ID,
|
||||||
|
BUDIBASE_INTERNAL_DB_ID,
|
||||||
|
} from "constants/backend"
|
||||||
|
import { tables, queries } from "./"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { DatasourceFeature } from "@budibase/types"
|
import { DatasourceFeature } from "@budibase/types"
|
||||||
|
import { TableNames } from "constants"
|
||||||
|
|
||||||
export class ImportTableError extends Error {
|
export class ImportTableError extends Error {
|
||||||
constructor(message) {
|
constructor(message) {
|
||||||
|
@ -23,13 +28,40 @@ export function createDatasourcesStore() {
|
||||||
schemaError: null,
|
schemaError: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const derivedStore = derived(store, $store => ({
|
const derivedStore = derived([store, tables], ([$store, $tables]) => {
|
||||||
...$store,
|
// Set the internal datasource entities from the table list, which we're
|
||||||
selected: $store.list?.find(ds => ds._id === $store.selectedDatasourceId),
|
// able to keep updated unlike the egress generated definition of the
|
||||||
hasDefaultData: $store.list.some(
|
// internal datasource
|
||||||
datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID
|
let internalDS = $store.list?.find(ds => ds._id === BUDIBASE_INTERNAL_DB_ID)
|
||||||
),
|
let otherDS = $store.list?.filter(ds => ds._id !== BUDIBASE_INTERNAL_DB_ID)
|
||||||
}))
|
if (internalDS) {
|
||||||
|
internalDS = {
|
||||||
|
...internalDS,
|
||||||
|
entities: $tables.list?.filter(table => {
|
||||||
|
return (
|
||||||
|
table.sourceId === BUDIBASE_INTERNAL_DB_ID &&
|
||||||
|
table._id !== TableNames.USERS
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build up enriched DS list
|
||||||
|
// Only add the internal DS if we have at least one non-users table
|
||||||
|
let list = []
|
||||||
|
if (internalDS?.entities?.length) {
|
||||||
|
list.push(internalDS)
|
||||||
|
}
|
||||||
|
list = list.concat(otherDS || [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...$store,
|
||||||
|
list,
|
||||||
|
selected: list?.find(ds => ds._id === $store.selectedDatasourceId),
|
||||||
|
hasDefaultData: list?.some(ds => ds._id === DEFAULT_BB_DATASOURCE_ID),
|
||||||
|
hasData: list?.length > 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const fetch = async () => {
|
const fetch = async () => {
|
||||||
const datasources = await API.getDatasources()
|
const datasources = await API.getDatasources()
|
||||||
|
@ -50,20 +82,14 @@ export function createDatasourcesStore() {
|
||||||
|
|
||||||
const updateDatasource = response => {
|
const updateDatasource = response => {
|
||||||
const { datasource, error } = response
|
const { datasource, error } = response
|
||||||
store.update(state => {
|
if (error) {
|
||||||
const currentIdx = state.list.findIndex(ds => ds._id === datasource._id)
|
store.update(state => ({
|
||||||
const sources = state.list
|
...state,
|
||||||
if (currentIdx >= 0) {
|
|
||||||
sources.splice(currentIdx, 1, datasource)
|
|
||||||
} else {
|
|
||||||
sources.push(datasource)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
list: sources,
|
|
||||||
selectedDatasourceId: datasource._id,
|
|
||||||
schemaError: error,
|
schemaError: error,
|
||||||
}
|
}))
|
||||||
})
|
}
|
||||||
|
replaceDatasource(datasource._id, datasource)
|
||||||
|
select(datasource._id)
|
||||||
return datasource
|
return datasource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,18 +160,14 @@ export function createDatasourcesStore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteDatasource = async datasource => {
|
const deleteDatasource = async datasource => {
|
||||||
|
if (!datasource?._id || !datasource?._rev) {
|
||||||
|
return
|
||||||
|
}
|
||||||
await API.deleteDatasource({
|
await API.deleteDatasource({
|
||||||
datasourceId: datasource?._id,
|
datasourceId: datasource._id,
|
||||||
datasourceRev: datasource?._rev,
|
datasourceRev: datasource._rev,
|
||||||
})
|
})
|
||||||
store.update(state => {
|
replaceDatasource(datasource._id, null)
|
||||||
const sources = state.list.filter(
|
|
||||||
existing => existing._id !== datasource._id
|
|
||||||
)
|
|
||||||
return { list: sources, selected: null }
|
|
||||||
})
|
|
||||||
await queries.fetch()
|
|
||||||
await tables.fetch()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeSchemaError = () => {
|
const removeSchemaError = () => {
|
||||||
|
@ -154,7 +176,6 @@ export function createDatasourcesStore() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handles external updates of datasources
|
|
||||||
const replaceDatasource = (datasourceId, datasource) => {
|
const replaceDatasource = (datasourceId, datasource) => {
|
||||||
if (!datasourceId) {
|
if (!datasourceId) {
|
||||||
return
|
return
|
||||||
|
@ -166,6 +187,8 @@ export function createDatasourcesStore() {
|
||||||
...state,
|
...state,
|
||||||
list: state.list.filter(x => x._id !== datasourceId),
|
list: state.list.filter(x => x._id !== datasourceId),
|
||||||
}))
|
}))
|
||||||
|
tables.removeDatasourceTables(datasourceId)
|
||||||
|
queries.removeDatasourceQueries(datasourceId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -121,6 +121,13 @@ export function createQueriesStore() {
|
||||||
return await save(datasourceId, newQuery)
|
return await save(datasourceId, newQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeDatasourceQueries = datasourceId => {
|
||||||
|
store.update(state => ({
|
||||||
|
...state,
|
||||||
|
list: state.list.filter(table => table.datasourceId !== datasourceId),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe: derivedStore.subscribe,
|
subscribe: derivedStore.subscribe,
|
||||||
fetch,
|
fetch,
|
||||||
|
@ -131,6 +138,7 @@ export function createQueriesStore() {
|
||||||
delete: deleteQuery,
|
delete: deleteQuery,
|
||||||
preview,
|
preview,
|
||||||
duplicate,
|
duplicate,
|
||||||
|
removeDatasourceQueries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -67,12 +67,12 @@ export function createTablesStore() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteTable = async table => {
|
const deleteTable = async table => {
|
||||||
if (!table?._id || !table?._rev) {
|
if (!table?._id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await API.deleteTable({
|
await API.deleteTable({
|
||||||
tableId: table._id,
|
tableId: table._id,
|
||||||
tableRev: table._rev,
|
tableRev: table._rev || "rev",
|
||||||
})
|
})
|
||||||
replaceTable(table._id, null)
|
replaceTable(table._id, null)
|
||||||
}
|
}
|
||||||
|
@ -161,6 +161,13 @@ export function createTablesStore() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeDatasourceTables = datasourceId => {
|
||||||
|
store.update(state => ({
|
||||||
|
...state,
|
||||||
|
list: state.list.filter(table => table.sourceId !== datasourceId),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...store,
|
...store,
|
||||||
subscribe: derivedStore.subscribe,
|
subscribe: derivedStore.subscribe,
|
||||||
|
@ -172,6 +179,7 @@ export function createTablesStore() {
|
||||||
saveField,
|
saveField,
|
||||||
deleteField,
|
deleteField,
|
||||||
replaceTable,
|
replaceTable,
|
||||||
|
removeDatasourceTables,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2216,7 +2216,7 @@
|
||||||
"name": "Form",
|
"name": "Form",
|
||||||
"icon": "Form",
|
"icon": "Form",
|
||||||
"hasChildren": true,
|
"hasChildren": true,
|
||||||
"illegalChildren": ["section", "form"],
|
"illegalChildren": ["section", "form", "formblock"],
|
||||||
"actions": [
|
"actions": [
|
||||||
"ValidateForm",
|
"ValidateForm",
|
||||||
"ClearForm",
|
"ClearForm",
|
||||||
|
@ -2304,7 +2304,7 @@
|
||||||
"name": "Form Step",
|
"name": "Form Step",
|
||||||
"icon": "AssetsAdded",
|
"icon": "AssetsAdded",
|
||||||
"hasChildren": true,
|
"hasChildren": true,
|
||||||
"illegalChildren": ["section", "form", "form step"],
|
"illegalChildren": ["section", "form", "formstep", "formblock"],
|
||||||
"styles": ["size"],
|
"styles": ["size"],
|
||||||
"size": {
|
"size": {
|
||||||
"width": 400,
|
"width": 400,
|
||||||
|
|
|
@ -68,6 +68,7 @@
|
||||||
rowHeight,
|
rowHeight,
|
||||||
contentLines,
|
contentLines,
|
||||||
gridFocused,
|
gridFocused,
|
||||||
|
error,
|
||||||
} = context
|
} = context
|
||||||
|
|
||||||
// Keep config store up to date with props
|
// Keep config store up to date with props
|
||||||
|
@ -149,8 +150,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if $error}
|
||||||
|
<div class="grid-error">
|
||||||
|
<div class="grid-error-title">There was a problem loading your grid</div>
|
||||||
|
<div class="grid-error-subtitle">
|
||||||
|
{$error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $loading}
|
{#if $loading && !$error}
|
||||||
<div in:fade|local={{ duration: 130 }} class="grid-loading">
|
<div in:fade|local={{ duration: 130 }} class="grid-loading">
|
||||||
<ProgressCircle />
|
<ProgressCircle />
|
||||||
</div>
|
</div>
|
||||||
|
@ -273,6 +281,25 @@
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.grid-error {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.grid-error-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.grid-error-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Disable checkbox animation anywhere in the grid data */
|
/* Disable checkbox animation anywhere in the grid data */
|
||||||
.grid-data-outer :global(.spectrum-Checkbox-box:before),
|
.grid-data-outer :global(.spectrum-Checkbox-box:before),
|
||||||
.grid-data-outer :global(.spectrum-Checkbox-box:after),
|
.grid-data-outer :global(.spectrum-Checkbox-box:after),
|
||||||
|
|
|
@ -14,6 +14,7 @@ export const createStores = () => {
|
||||||
const rowChangeCache = writable({})
|
const rowChangeCache = writable({})
|
||||||
const inProgressChanges = writable({})
|
const inProgressChanges = writable({})
|
||||||
const hasNextPage = writable(false)
|
const hasNextPage = writable(false)
|
||||||
|
const error = writable(null)
|
||||||
|
|
||||||
// Generate a lookup map to quick find a row by ID
|
// Generate a lookup map to quick find a row by ID
|
||||||
const rowLookupMap = derived(
|
const rowLookupMap = derived(
|
||||||
|
@ -47,6 +48,7 @@ export const createStores = () => {
|
||||||
rowChangeCache,
|
rowChangeCache,
|
||||||
inProgressChanges,
|
inProgressChanges,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
|
error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,6 +70,7 @@ export const deriveStores = context => {
|
||||||
inProgressChanges,
|
inProgressChanges,
|
||||||
previousFocusedRowId,
|
previousFocusedRowId,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
|
error,
|
||||||
} = context
|
} = context
|
||||||
const instanceLoaded = writable(false)
|
const instanceLoaded = writable(false)
|
||||||
const fetch = writable(null)
|
const fetch = writable(null)
|
||||||
|
@ -122,7 +125,17 @@ export const deriveStores = context => {
|
||||||
|
|
||||||
// Subscribe to changes of this fetch model
|
// Subscribe to changes of this fetch model
|
||||||
unsubscribe = newFetch.subscribe(async $fetch => {
|
unsubscribe = newFetch.subscribe(async $fetch => {
|
||||||
if ($fetch.loaded && !$fetch.loading) {
|
if ($fetch.error) {
|
||||||
|
// Present a helpful error to the user
|
||||||
|
let message = "An unknown error occurred"
|
||||||
|
if ($fetch.error.status === 403) {
|
||||||
|
message = "You don't have access to this data"
|
||||||
|
} else if ($fetch.error.message) {
|
||||||
|
message = $fetch.error.message
|
||||||
|
}
|
||||||
|
error.set(message)
|
||||||
|
} else if ($fetch.loaded && !$fetch.loading) {
|
||||||
|
error.set(null)
|
||||||
hasNextPage.set($fetch.hasNextPage)
|
hasNextPage.set($fetch.hasNextPage)
|
||||||
const $instanceLoaded = get(instanceLoaded)
|
const $instanceLoaded = get(instanceLoaded)
|
||||||
const resetRows = $fetch.resetKey !== lastResetKey
|
const resetRows = $fetch.resetKey !== lastResetKey
|
||||||
|
|
|
@ -57,6 +57,7 @@ export default class DataFetch {
|
||||||
cursor: null,
|
cursor: null,
|
||||||
cursors: [],
|
cursors: [],
|
||||||
resetKey: Math.random(),
|
resetKey: Math.random(),
|
||||||
|
error: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge options with their default values
|
// Merge options with their default values
|
||||||
|
@ -252,6 +253,10 @@ export default class DataFetch {
|
||||||
try {
|
try {
|
||||||
return await this.API.fetchTableDefinition(datasource.tableId)
|
return await this.API.fetchTableDefinition(datasource.tableId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.store.update(state => ({
|
||||||
|
...state,
|
||||||
|
error,
|
||||||
|
}))
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -308,6 +308,9 @@ async function performAppCreate(ctx: UserCtx) {
|
||||||
customTheme: {
|
customTheme: {
|
||||||
buttonBorderRadius: "16px",
|
buttonBorderRadius: "16px",
|
||||||
},
|
},
|
||||||
|
features: {
|
||||||
|
componentValidation: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we used a template or imported an app there will be an existing doc.
|
// If we used a template or imported an app there will be an existing doc.
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import authorized from "../middleware/authorized"
|
import authorized from "../middleware/authorized"
|
||||||
|
import currentApp from "../middleware/currentapp"
|
||||||
import { BaseSocket } from "./websocket"
|
import { BaseSocket } from "./websocket"
|
||||||
import { context, permissions } from "@budibase/backend-core"
|
import { auth, permissions } from "@budibase/backend-core"
|
||||||
import http from "http"
|
import http from "http"
|
||||||
import Koa from "koa"
|
import Koa from "koa"
|
||||||
import { getTableId } from "../api/controllers/row/utils"
|
import { getTableId } from "../api/controllers/row/utils"
|
||||||
import { Row, Table } from "@budibase/types"
|
import { Row, Table } from "@budibase/types"
|
||||||
import { Socket } from "socket.io"
|
import { Socket } from "socket.io"
|
||||||
import { GridSocketEvent } from "@budibase/shared-core"
|
import { GridSocketEvent } from "@budibase/shared-core"
|
||||||
|
import { userAgent } from "koa-useragent"
|
||||||
|
import { createContext, runMiddlewares } from "./middleware"
|
||||||
|
|
||||||
const { PermissionType, PermissionLevel } = permissions
|
const { PermissionType, PermissionLevel } = permissions
|
||||||
|
|
||||||
|
@ -26,28 +29,27 @@ export default class GridSocket extends BaseSocket {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user has permission to read this resource
|
// Create context
|
||||||
const middleware = authorized(
|
const ctx = createContext(this.app, socket, {
|
||||||
PermissionType.TABLE,
|
|
||||||
PermissionLevel.READ
|
|
||||||
)
|
|
||||||
const ctx = {
|
|
||||||
appId,
|
|
||||||
resourceId: tableId,
|
resourceId: tableId,
|
||||||
roleId: socket.data.roleId,
|
appId,
|
||||||
user: { _id: socket.data._id },
|
})
|
||||||
isAuthenticated: socket.data.isAuthenticated,
|
|
||||||
request: {
|
// Construct full middleware chain to assess permissions
|
||||||
url: "/fake",
|
const middlewares = [
|
||||||
},
|
userAgent,
|
||||||
get: () => null,
|
auth.buildAuthMiddleware([], {
|
||||||
throw: () => {
|
publicAllowed: true,
|
||||||
// If they don't have access, immediately disconnect them
|
}),
|
||||||
socket.disconnect(true)
|
currentApp,
|
||||||
},
|
authorized(PermissionType.TABLE, PermissionLevel.READ),
|
||||||
}
|
]
|
||||||
await context.doInAppContext(appId, async () => {
|
|
||||||
await middleware(ctx, async () => {
|
// Run all koa middlewares
|
||||||
|
try {
|
||||||
|
await runMiddlewares(ctx, middlewares, async () => {
|
||||||
|
// Middlewares are finished and we have permission
|
||||||
|
// Join room for this resource
|
||||||
const room = `${appId}-${tableId}`
|
const room = `${appId}-${tableId}`
|
||||||
await this.joinRoom(socket, room)
|
await this.joinRoom(socket, room)
|
||||||
|
|
||||||
|
@ -55,7 +57,9 @@ export default class GridSocket extends BaseSocket {
|
||||||
const sessions = await this.getRoomSessions(room)
|
const sessions = await this.getRoomSessions(room)
|
||||||
callback({ users: sessions })
|
callback({ users: sessions })
|
||||||
})
|
})
|
||||||
})
|
} catch (error) {
|
||||||
|
socket.disconnect(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { Socket } from "socket.io"
|
||||||
|
import Cookies from "cookies"
|
||||||
|
import http from "http"
|
||||||
|
import Koa from "koa"
|
||||||
|
import { Header } from "@budibase/backend-core"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a fake Koa context to use for manually running middlewares in
|
||||||
|
* sockets
|
||||||
|
* @param app the Koa app
|
||||||
|
* @param socket the socket.io socket instance
|
||||||
|
* @param options additional metadata to populate the context with
|
||||||
|
*/
|
||||||
|
export const createContext = (
|
||||||
|
app: Koa,
|
||||||
|
socket: Socket,
|
||||||
|
options?: WebsocketContextOptions
|
||||||
|
) => {
|
||||||
|
const res = new http.ServerResponse(socket.request)
|
||||||
|
const context: WebsocketContext = {
|
||||||
|
...app.createContext(socket.request, res),
|
||||||
|
|
||||||
|
// Additional overrides needed to make our middlewares work with this
|
||||||
|
// fake koa context
|
||||||
|
resourceId: options?.resourceId,
|
||||||
|
path: "/fake",
|
||||||
|
request: {
|
||||||
|
url: "/fake",
|
||||||
|
headers: {
|
||||||
|
[Header.APP_ID]: options?.appId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cookies: new Cookies(socket.request, res),
|
||||||
|
get: (field: string) => socket.request.headers?.[field] as string,
|
||||||
|
throw: (...params: any[]) => {
|
||||||
|
// Throw has a bunch of different signatures, so we'll just stringify
|
||||||
|
// whatever params we get given
|
||||||
|
throw new Error(
|
||||||
|
...(params?.join(" ") || "Unknown error in socket middleware")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Needed for koa-useragent middleware
|
||||||
|
headers: socket.request.headers,
|
||||||
|
header: socket.request.headers,
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a list of middlewares, nesting each callback inside each other to mimic
|
||||||
|
* how the real middlewares run and ensuring that app and tenant contexts work
|
||||||
|
* as expected
|
||||||
|
* @param ctx the Koa context
|
||||||
|
* @param middlewares the array of middlewares to run
|
||||||
|
* @param callback a final callback for when all middlewares are completed
|
||||||
|
*/
|
||||||
|
export const runMiddlewares = async (
|
||||||
|
ctx: any,
|
||||||
|
middlewares: any[],
|
||||||
|
callback: Function
|
||||||
|
) => {
|
||||||
|
if (!middlewares[0]) {
|
||||||
|
await callback()
|
||||||
|
} else {
|
||||||
|
await middlewares[0](ctx, async () => {
|
||||||
|
await runMiddlewares(ctx, middlewares.slice(1), callback)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsocketContext extends Omit<Koa.Context, "request"> {
|
||||||
|
request: {
|
||||||
|
url: string
|
||||||
|
headers: {
|
||||||
|
[key: string]: string | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cookies: Cookies
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebsocketContextOptions {
|
||||||
|
appId?: string
|
||||||
|
resourceId?: string
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
import { Server } from "socket.io"
|
import { Server } from "socket.io"
|
||||||
import http from "http"
|
import http from "http"
|
||||||
import Koa from "koa"
|
import Koa from "koa"
|
||||||
import Cookies from "cookies"
|
|
||||||
import { userAgent } from "koa-useragent"
|
import { userAgent } from "koa-useragent"
|
||||||
import { auth, Header, redis } from "@budibase/backend-core"
|
import { auth, Header, redis } from "@budibase/backend-core"
|
||||||
import { createAdapter } from "@socket.io/redis-adapter"
|
import { createAdapter } from "@socket.io/redis-adapter"
|
||||||
|
@ -10,6 +9,7 @@ import { getSocketPubSubClients } from "../utilities/redis"
|
||||||
import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core"
|
import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core"
|
||||||
import { SocketSession } from "@budibase/types"
|
import { SocketSession } from "@budibase/types"
|
||||||
import { v4 as uuid } from "uuid"
|
import { v4 as uuid } from "uuid"
|
||||||
|
import { createContext, runMiddlewares } from "./middleware"
|
||||||
|
|
||||||
const anonUser = () => ({
|
const anonUser = () => ({
|
||||||
_id: uuid(),
|
_id: uuid(),
|
||||||
|
@ -18,6 +18,7 @@ const anonUser = () => ({
|
||||||
})
|
})
|
||||||
|
|
||||||
export class BaseSocket {
|
export class BaseSocket {
|
||||||
|
app: Koa
|
||||||
io: Server
|
io: Server
|
||||||
path: string
|
path: string
|
||||||
redisClient?: redis.Client
|
redisClient?: redis.Client
|
||||||
|
@ -28,6 +29,7 @@ export class BaseSocket {
|
||||||
path: string = "/",
|
path: string = "/",
|
||||||
additionalMiddlewares?: any[]
|
additionalMiddlewares?: any[]
|
||||||
) {
|
) {
|
||||||
|
this.app = app
|
||||||
this.path = path
|
this.path = path
|
||||||
this.io = new Server(server, {
|
this.io = new Server(server, {
|
||||||
path,
|
path,
|
||||||
|
@ -45,52 +47,25 @@ export class BaseSocket {
|
||||||
|
|
||||||
// Apply middlewares
|
// Apply middlewares
|
||||||
this.io.use(async (socket, next) => {
|
this.io.use(async (socket, next) => {
|
||||||
// Build fake koa context
|
const ctx = createContext(this.app, socket)
|
||||||
const res = new http.ServerResponse(socket.request)
|
|
||||||
const ctx: any = {
|
|
||||||
...app.createContext(socket.request, res),
|
|
||||||
|
|
||||||
// Additional overrides needed to make our middlewares work with this
|
|
||||||
// fake koa context
|
|
||||||
cookies: new Cookies(socket.request, res),
|
|
||||||
get: (field: string) => socket.request.headers[field],
|
|
||||||
throw: (code: number, message: string) => {
|
|
||||||
throw new Error(message)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Needed for koa-useragent middleware
|
|
||||||
headers: socket.request.headers,
|
|
||||||
header: socket.request.headers,
|
|
||||||
|
|
||||||
// We don't really care about the path since it will never contain
|
|
||||||
// an app ID
|
|
||||||
path: "/socket",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run all koa middlewares
|
|
||||||
try {
|
try {
|
||||||
for (let [idx, middleware] of middlewares.entries()) {
|
await runMiddlewares(ctx, middlewares, () => {
|
||||||
await middleware(ctx, () => {
|
// Middlewares are finished
|
||||||
if (idx === middlewares.length - 1) {
|
// Extract some data from our enriched koa context to persist
|
||||||
// Middlewares are finished
|
// as metadata for the socket
|
||||||
// Extract some data from our enriched koa context to persist
|
const user = ctx.user?._id ? ctx.user : anonUser()
|
||||||
// as metadata for the socket
|
const { _id, email, firstName, lastName } = user
|
||||||
const user = ctx.user?._id ? ctx.user : anonUser()
|
socket.data = {
|
||||||
const { _id, email, firstName, lastName } = user
|
_id,
|
||||||
socket.data = {
|
email,
|
||||||
_id,
|
firstName,
|
||||||
email,
|
lastName,
|
||||||
firstName,
|
sessionId: socket.id,
|
||||||
lastName,
|
connectedAt: Date.now(),
|
||||||
sessionId: socket.id,
|
}
|
||||||
connectedAt: Date.now(),
|
next()
|
||||||
isAuthenticated: ctx.isAuthenticated,
|
})
|
||||||
roleId: ctx.roleId,
|
|
||||||
}
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
next(error)
|
next(error)
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ export interface App extends Document {
|
||||||
navigation?: AppNavigation
|
navigation?: AppNavigation
|
||||||
automationErrors?: AppMetadataErrors
|
automationErrors?: AppMetadataErrors
|
||||||
icon?: AppIcon
|
icon?: AppIcon
|
||||||
|
features?: AppFeatures
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppInstance {
|
export interface AppInstance {
|
||||||
|
@ -60,3 +61,7 @@ export interface AppIcon {
|
||||||
name: string
|
name: string
|
||||||
color: string
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppFeatures {
|
||||||
|
componentValidation?: boolean
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue