mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-14 09:08:32 +00:00
#3331 better error handling in database app UI
This commit is contained in:
parent
f98e72156a
commit
b1c2f59bcb
7 changed files with 268 additions and 22 deletions
changelog/entries/unreleased/bug
web-frontend
modules/database
test/unit/database
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"type": "bug",
|
||||||
|
"message": "Database: when an error is returned by the backend, it will be shown inside of the body of the view, so user still have access to navigation interface.",
|
||||||
|
"issue_number": 3331,
|
||||||
|
"bullet_points": [],
|
||||||
|
"created_at": "2025-01-31"
|
||||||
|
}
|
|
@ -210,9 +210,10 @@
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<div class="layout__col-2-2 content">
|
<div class="layout__col-2-2 content">
|
||||||
|
<DefaultErrorPage v-if="viewError" :error="viewError" />
|
||||||
<component
|
<component
|
||||||
:is="getViewComponent(view)"
|
:is="getViewComponent(view)"
|
||||||
v-if="hasSelectedView && !tableLoading"
|
v-if="hasSelectedView && !tableLoading && !viewError"
|
||||||
ref="view"
|
ref="view"
|
||||||
:database="database"
|
:database="database"
|
||||||
:table="table"
|
:table="table"
|
||||||
|
@ -252,7 +253,8 @@ import ViewSearch from '@baserow/modules/database/components/view/ViewSearch'
|
||||||
import EditableViewName from '@baserow/modules/database/components/view/EditableViewName'
|
import EditableViewName from '@baserow/modules/database/components/view/EditableViewName'
|
||||||
import ShareViewLink from '@baserow/modules/database/components/view/ShareViewLink'
|
import ShareViewLink from '@baserow/modules/database/components/view/ShareViewLink'
|
||||||
import ExternalLinkBaserowLogo from '@baserow/modules/core/components/ExternalLinkBaserowLogo'
|
import ExternalLinkBaserowLogo from '@baserow/modules/core/components/ExternalLinkBaserowLogo'
|
||||||
import ViewGroupBy from '@baserow/modules/database/components/view/ViewGroupBy.vue'
|
import ViewGroupBy from '@baserow/modules/database/components/view/ViewGroupBy'
|
||||||
|
import DefaultErrorPage from '@baserow/modules/core/components/DefaultErrorPage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This page component is the skeleton for a table. Depending on the selected view it
|
* This page component is the skeleton for a table. Depending on the selected view it
|
||||||
|
@ -260,6 +262,7 @@ import ViewGroupBy from '@baserow/modules/database/components/view/ViewGroupBy.v
|
||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
DefaultErrorPage,
|
||||||
ViewGroupBy,
|
ViewGroupBy,
|
||||||
ExternalLinkBaserowLogo,
|
ExternalLinkBaserowLogo,
|
||||||
ShareViewLink,
|
ShareViewLink,
|
||||||
|
@ -300,6 +303,14 @@ export default {
|
||||||
required: true,
|
required: true,
|
||||||
validator: (prop) => typeof prop === 'object' || prop === undefined,
|
validator: (prop) => typeof prop === 'object' || prop === undefined,
|
||||||
},
|
},
|
||||||
|
viewError: {
|
||||||
|
required: false,
|
||||||
|
validator: (prop) =>
|
||||||
|
typeof prop === 'object' ||
|
||||||
|
typeof prop === 'function' ||
|
||||||
|
prop === undefined,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
tableLoading: {
|
tableLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<DefaultErrorPage v-if="error && !view" :error="error" />
|
||||||
<Table
|
<Table
|
||||||
|
v-else
|
||||||
:database="database"
|
:database="database"
|
||||||
:table="table"
|
:table="table"
|
||||||
:fields="fields"
|
:fields="fields"
|
||||||
:views="views"
|
:views="views"
|
||||||
:view="view"
|
:view="view"
|
||||||
|
:view-error="error"
|
||||||
:table-loading="tableLoading"
|
:table-loading="tableLoading"
|
||||||
store-prefix="page/"
|
store-prefix="page/"
|
||||||
@selected-view="selectedView"
|
@selected-view="selectedView"
|
||||||
|
@ -27,13 +30,15 @@ import { mapState } from 'vuex'
|
||||||
import Table from '@baserow/modules/database/components/table/Table'
|
import Table from '@baserow/modules/database/components/table/Table'
|
||||||
import { StoreItemLookupError } from '@baserow/modules/core/errors'
|
import { StoreItemLookupError } from '@baserow/modules/core/errors'
|
||||||
import { getDefaultView } from '@baserow/modules/database/utils/view'
|
import { getDefaultView } from '@baserow/modules/database/utils/view'
|
||||||
|
import DefaultErrorPage from '@baserow/modules/core/components/DefaultErrorPage'
|
||||||
|
import { normalizeError } from '@baserow/modules/database/utils/errors'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This page component is the skeleton for a table. Depending on the selected view it
|
* This page component is the skeleton for a table. Depending on the selected view it
|
||||||
* will load the correct components into the header and body.
|
* will load the correct components into the header and body.
|
||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
components: { Table },
|
components: { DefaultErrorPage, Table },
|
||||||
/**
|
/**
|
||||||
* When the user leaves to another page we want to unselect the selected table. This
|
* When the user leaves to another page we want to unselect the selected table. This
|
||||||
* way it will not be highlighted the left sidebar.
|
* way it will not be highlighted the left sidebar.
|
||||||
|
@ -56,6 +61,7 @@ export default {
|
||||||
function parseIntOrNull(x) {
|
function parseIntOrNull(x) {
|
||||||
return x != null ? parseInt(x) : null
|
return x != null ? parseInt(x) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentRowId = parseIntOrNull(to.params?.rowId)
|
const currentRowId = parseIntOrNull(to.params?.rowId)
|
||||||
const currentTableId = parseIntOrNull(to.params.tableId)
|
const currentTableId = parseIntOrNull(to.params.tableId)
|
||||||
|
|
||||||
|
@ -121,32 +127,39 @@ export default {
|
||||||
const databaseId = parseInt(params.databaseId)
|
const databaseId = parseInt(params.databaseId)
|
||||||
const tableId = parseInt(params.tableId)
|
const tableId = parseInt(params.tableId)
|
||||||
const viewId = params.viewId ? parseInt(params.viewId) : null
|
const viewId = params.viewId ? parseInt(params.viewId) : null
|
||||||
const data = {}
|
// let's use undefined for view, as it's explicitly checked in components
|
||||||
|
const data = { error: null, view: undefined, fields: null }
|
||||||
// Try to find the table in the already fetched applications by the
|
// Try to find the table in the already fetched applications by the
|
||||||
// workspacesAndApplications middleware and select that one. By selecting the table, the
|
// workspacesAndApplications middleware and select that one. By selecting the table, the
|
||||||
// fields and views are also going to be fetched.
|
// fields and views are also going to be fetched.
|
||||||
try {
|
try {
|
||||||
const { database, table } = await store.dispatch('table/selectById', {
|
const { database, table, error } = await store.dispatch(
|
||||||
databaseId,
|
'table/selectById',
|
||||||
tableId,
|
{
|
||||||
})
|
databaseId,
|
||||||
|
tableId,
|
||||||
|
}
|
||||||
|
)
|
||||||
await store.dispatch('workspace/selectById', database.workspace.id)
|
await store.dispatch('workspace/selectById', database.workspace.id)
|
||||||
data.database = database
|
data.database = database
|
||||||
data.table = table
|
data.table = table
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
data.error = normalizeError(error)
|
||||||
|
return data
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// In case of a network error we want to fail hard.
|
// In case of a network error we want to fail hard.
|
||||||
if (e.response === undefined && !(e instanceof StoreItemLookupError)) {
|
if (e.response === undefined && !(e instanceof StoreItemLookupError)) {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
data.error = normalizeError(e)
|
||||||
return error({ statusCode: 404, message: 'Table not found.' })
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// After selecting the table the fields become available which need to be added to
|
// After selecting the table the fields become available which need to be added to
|
||||||
// the data.
|
// the data.
|
||||||
data.fields = store.getters['field/getAll']
|
data.fields = store.getters['field/getAll']
|
||||||
data.view = undefined
|
|
||||||
|
|
||||||
// Without a viewId, redirect the user to the default or the first available view.
|
// Without a viewId, redirect the user to the default or the first available view.
|
||||||
if (viewId === null) {
|
if (viewId === null) {
|
||||||
|
@ -192,18 +205,17 @@ export default {
|
||||||
if (e.response === undefined && !(e instanceof StoreItemLookupError)) {
|
if (e.response === undefined && !(e instanceof StoreItemLookupError)) {
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
data.error = normalizeError(e)
|
||||||
|
|
||||||
return error({ statusCode: 404, message: 'View not found.' })
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.rowId) {
|
if (params.rowId) {
|
||||||
await store.dispatch('rowModalNavigation/fetchRow', {
|
await store.dispatch('rowModalNavigation/fetchRow', {
|
||||||
tableId,
|
tableId,
|
||||||
rowId: params.rowId,
|
rowId: params.rowId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
head() {
|
head() {
|
||||||
|
|
|
@ -211,14 +211,18 @@ export const actions = {
|
||||||
if (getters.getSelectedId === table.id) {
|
if (getters.getSelectedId === table.id) {
|
||||||
return { database, table }
|
return { database, table }
|
||||||
}
|
}
|
||||||
|
let error = null
|
||||||
await axios.all([
|
await axios
|
||||||
dispatch('view/fetchAll', table, { root: true }),
|
.all([
|
||||||
dispatch('field/fetchAll', table, { root: true }),
|
dispatch('view/fetchAll', table, { root: true }),
|
||||||
])
|
dispatch('field/fetchAll', table, { root: true }),
|
||||||
|
])
|
||||||
|
.catch((err) => {
|
||||||
|
error = err
|
||||||
|
})
|
||||||
await dispatch('application/clearChildrenSelected', null, { root: true })
|
await dispatch('application/clearChildrenSelected', null, { root: true })
|
||||||
await dispatch('forceSelect', { database, table })
|
await dispatch('forceSelect', { database, table })
|
||||||
return { database, table }
|
return { database, table, error }
|
||||||
},
|
},
|
||||||
forceSelect({ commit, dispatch }, { database, table }) {
|
forceSelect({ commit, dispatch }, { database, table }) {
|
||||||
dispatch(
|
dispatch(
|
||||||
|
@ -259,7 +263,7 @@ export const actions = {
|
||||||
}
|
}
|
||||||
const table = database.tables[index]
|
const table = database.tables[index]
|
||||||
|
|
||||||
return dispatch('select', { database, table })
|
return await dispatch('select', { database, table })
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Unselect the selected table.
|
* Unselect the selected table.
|
||||||
|
|
23
web-frontend/modules/database/utils/errors.js
Normal file
23
web-frontend/modules/database/utils/errors.js
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* Returns most expected error structure.
|
||||||
|
*
|
||||||
|
* When an error is thrown, it can be of any type. This function tries to return
|
||||||
|
* the most useful error data from the error. It tries to return any first of the
|
||||||
|
* following:
|
||||||
|
*
|
||||||
|
* * http response body, if it's a DRF error structure
|
||||||
|
* * http response object
|
||||||
|
* * the error as-is in any other case
|
||||||
|
*
|
||||||
|
* @param err
|
||||||
|
* @param errorMap
|
||||||
|
* @returns {*}
|
||||||
|
*/
|
||||||
|
export function normalizeError(err) {
|
||||||
|
err = err.response?.data?.message ? err.response.data : err.response || err
|
||||||
|
return {
|
||||||
|
message: err.message,
|
||||||
|
content: err.detail,
|
||||||
|
statusCode: err.statusCode,
|
||||||
|
}
|
||||||
|
}
|
|
@ -354,6 +354,8 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
|
||||||
<div
|
<div
|
||||||
class="layout__col-2-2 content"
|
class="layout__col-2-2 content"
|
||||||
>
|
>
|
||||||
|
<!---->
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="grid-view grid-view--row-height-undefined"
|
class="grid-view grid-view--row-height-undefined"
|
||||||
>
|
>
|
||||||
|
|
|
@ -361,6 +361,114 @@ describe('View Tests', () => {
|
||||||
expect(updatedCookieValue.length).toBeLessThan(originalDataLength)
|
expect(updatedCookieValue.length).toBeLessThan(originalDataLength)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Unknown error during views loading is displayed correctly - no view toolbar', async () => {
|
||||||
|
const viewsError = { statusCode: 500, data: 'some backend error' }
|
||||||
|
|
||||||
|
// no list of views
|
||||||
|
const { application, table } = await givenATableWithError({
|
||||||
|
viewsError,
|
||||||
|
})
|
||||||
|
const tableComponent = await testApp.mount(Table, {
|
||||||
|
asyncDataParams: {
|
||||||
|
databaseId: application.id,
|
||||||
|
tableId: table.id,
|
||||||
|
viewId: '123',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(tableComponent.vm.views).toEqual([])
|
||||||
|
|
||||||
|
expect(tableComponent.vm.error).toBeTruthy()
|
||||||
|
|
||||||
|
// no table header (view selection, filters, sorting, grouping...)
|
||||||
|
expect(tableComponent.find('header .header__filter-link').exists()).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(tableComponent.find('.placeholder__title').exists()).toBe(true)
|
||||||
|
// error message will be processed and replaced
|
||||||
|
expect(tableComponent.find('.placeholder__title').text()).toBe(
|
||||||
|
'errorLayout.wrong'
|
||||||
|
)
|
||||||
|
expect(tableComponent.find('.placeholder__content').exists()).toBe(true)
|
||||||
|
|
||||||
|
expect(tableComponent.find('.placeholder__content').text()).toBe(
|
||||||
|
'errorLayout.error'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('API error during views loading is displayed correctly', async () => {
|
||||||
|
const viewsError = {
|
||||||
|
statusCode: 400,
|
||||||
|
data: {
|
||||||
|
message: "The view filter type INVALID doesn't exist.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// no list of views
|
||||||
|
const { application, table } = await givenATableWithError({
|
||||||
|
viewsError,
|
||||||
|
})
|
||||||
|
const tableComponent = await testApp.mount(Table, {
|
||||||
|
asyncDataParams: {
|
||||||
|
databaseId: application.id,
|
||||||
|
tableId: table.id,
|
||||||
|
viewId: '123',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(tableComponent.vm.views).toEqual([])
|
||||||
|
expect(tableComponent.vm.error).toBeTruthy()
|
||||||
|
|
||||||
|
expect(tableComponent.find('header .header__filter-link').exists()).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
expect(tableComponent.find('.placeholder__title').exists()).toBe(true)
|
||||||
|
expect(tableComponent.find('.placeholder__title').text()).toEqual(
|
||||||
|
viewsError.data.message
|
||||||
|
)
|
||||||
|
expect(tableComponent.find('.placeholder__content').exists()).toBe(true)
|
||||||
|
|
||||||
|
expect(tableComponent.find('.placeholder__content').text()).toEqual(
|
||||||
|
'errorLayout.error'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('API error during view rows loading', async () => {
|
||||||
|
const rowsError = { statusCode: 500, data: { message: 'Unknown error' } }
|
||||||
|
|
||||||
|
// views list readable, fields readable, rows not readable
|
||||||
|
const { application, table, view } = await givenATableWithError({
|
||||||
|
rowsError,
|
||||||
|
})
|
||||||
|
|
||||||
|
//
|
||||||
|
const tableComponent = await testApp.mount(Table, {
|
||||||
|
asyncDataParams: {
|
||||||
|
databaseId: application.id,
|
||||||
|
tableId: table.id,
|
||||||
|
viewId: view.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(tableComponent.vm.views).toMatchObject([view])
|
||||||
|
|
||||||
|
// we're past views api call, so the table (with the error) and toolbar should be present
|
||||||
|
expect(tableComponent.find('.header__filter-link').exists()).toBe(true)
|
||||||
|
|
||||||
|
expect(tableComponent.vm.error).toBeTruthy()
|
||||||
|
|
||||||
|
expect(tableComponent.find('.placeholder__title').exists()).toBe(true)
|
||||||
|
expect(tableComponent.find('.placeholder__title').text()).toEqual(
|
||||||
|
rowsError.data.message
|
||||||
|
)
|
||||||
|
expect(tableComponent.find('.placeholder__content').exists()).toBe(true)
|
||||||
|
|
||||||
|
expect(tableComponent.find('.placeholder__content').text()).toEqual(
|
||||||
|
'errorLayout.error'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
async function givenATableInTheServerWithMultipleViews() {
|
async function givenATableInTheServerWithMultipleViews() {
|
||||||
const table = mockServer.createTable()
|
const table = mockServer.createTable()
|
||||||
const { application } = await mockServer.createAppAndWorkspace(table)
|
const { application } = await mockServer.createAppAndWorkspace(table)
|
||||||
|
@ -494,4 +602,83 @@ describe('View Tests', () => {
|
||||||
tables.push(secondTable)
|
tables.push(secondTable)
|
||||||
return { application, tables, views }
|
return { application, tables, views }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function givenATableWithError({ viewsError, fieldsError, rowsError }) {
|
||||||
|
const table = mockServer.createTable()
|
||||||
|
// we expect some endpoints to return errors
|
||||||
|
testApp.dontFailOnErrorResponses()
|
||||||
|
const { application } =
|
||||||
|
await mockServer.createAppAndWorkspaceWithMultipleTables([table])
|
||||||
|
const viewId = 1
|
||||||
|
|
||||||
|
const rawGridView = {
|
||||||
|
id: viewId,
|
||||||
|
table_id: table.id,
|
||||||
|
name: `mock_view_${viewId}`,
|
||||||
|
order: 0,
|
||||||
|
type: 'grid',
|
||||||
|
table: {
|
||||||
|
id: table.id,
|
||||||
|
name: table.name,
|
||||||
|
order: 0,
|
||||||
|
database_id: application.id,
|
||||||
|
},
|
||||||
|
filter_type: 'AND',
|
||||||
|
filters_disabled: false,
|
||||||
|
public: null,
|
||||||
|
row_identifier_type: 'id',
|
||||||
|
row_height_size: 'small',
|
||||||
|
filters: [],
|
||||||
|
sortings: [],
|
||||||
|
group_bys: [],
|
||||||
|
decorations: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFields = [
|
||||||
|
{
|
||||||
|
name: 'Name',
|
||||||
|
type: 'text',
|
||||||
|
primary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Last name',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Notes',
|
||||||
|
type: 'long_text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Active',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const rawRows = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
order: 0,
|
||||||
|
field_1: 'name',
|
||||||
|
field_2: 'last_name',
|
||||||
|
field_3: 'notes',
|
||||||
|
field_4: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mockServer.mock
|
||||||
|
.onGet(`/database/views/table/${table.id}/`)
|
||||||
|
.replyOnce(
|
||||||
|
viewsError?.statusCode || 200,
|
||||||
|
viewsError?.data || [rawGridView]
|
||||||
|
)
|
||||||
|
|
||||||
|
mockServer.mock
|
||||||
|
.onGet(`/database/fields/table/${table.id}/`)
|
||||||
|
.replyOnce(fieldsError?.statusCode || 200, fieldsError?.data || rawFields)
|
||||||
|
|
||||||
|
mockServer.mock
|
||||||
|
.onGet(`database/views/grid/${rawGridView.id}/`)
|
||||||
|
.replyOnce(rowsError?.statusCode || 200, rowsError?.data || rawRows)
|
||||||
|
|
||||||
|
return { application, table, view: rawGridView }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Reference in a new issue