1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-07 14:25:37 +00:00

better error handling in database app UI

This commit is contained in:
Cezary Statkiewicz 2025-02-26 12:37:45 +00:00
parent f98e72156a
commit b1c2f59bcb
7 changed files with 268 additions and 22 deletions
changelog/entries/unreleased/bug
web-frontend
modules/database
components/table
pages
store
utils
test/unit/database

View file

@ -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"
}

View file

@ -210,9 +210,10 @@
/>
</header>
<div class="layout__col-2-2 content">
<DefaultErrorPage v-if="viewError" :error="viewError" />
<component
:is="getViewComponent(view)"
v-if="hasSelectedView && !tableLoading"
v-if="hasSelectedView && !tableLoading && !viewError"
ref="view"
:database="database"
: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 ShareViewLink from '@baserow/modules/database/components/view/ShareViewLink'
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
@ -260,6 +262,7 @@ import ViewGroupBy from '@baserow/modules/database/components/view/ViewGroupBy.v
*/
export default {
components: {
DefaultErrorPage,
ViewGroupBy,
ExternalLinkBaserowLogo,
ShareViewLink,
@ -300,6 +303,14 @@ export default {
required: true,
validator: (prop) => typeof prop === 'object' || prop === undefined,
},
viewError: {
required: false,
validator: (prop) =>
typeof prop === 'object' ||
typeof prop === 'function' ||
prop === undefined,
default: null,
},
tableLoading: {
type: Boolean,
required: true,

View file

@ -1,11 +1,14 @@
<template>
<div>
<DefaultErrorPage v-if="error && !view" :error="error" />
<Table
v-else
:database="database"
:table="table"
:fields="fields"
:views="views"
:view="view"
:view-error="error"
:table-loading="tableLoading"
store-prefix="page/"
@selected-view="selectedView"
@ -27,13 +30,15 @@ import { mapState } from 'vuex'
import Table from '@baserow/modules/database/components/table/Table'
import { StoreItemLookupError } from '@baserow/modules/core/errors'
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
* will load the correct components into the header and body.
*/
export default {
components: { Table },
components: { DefaultErrorPage, Table },
/**
* When the user leaves to another page we want to unselect the selected table. This
* way it will not be highlighted the left sidebar.
@ -56,6 +61,7 @@ export default {
function parseIntOrNull(x) {
return x != null ? parseInt(x) : null
}
const currentRowId = parseIntOrNull(to.params?.rowId)
const currentTableId = parseIntOrNull(to.params.tableId)
@ -121,32 +127,39 @@ export default {
const databaseId = parseInt(params.databaseId)
const tableId = parseInt(params.tableId)
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
// workspacesAndApplications middleware and select that one. By selecting the table, the
// fields and views are also going to be fetched.
try {
const { database, table } = await store.dispatch('table/selectById', {
databaseId,
tableId,
})
const { database, table, error } = await store.dispatch(
'table/selectById',
{
databaseId,
tableId,
}
)
await store.dispatch('workspace/selectById', database.workspace.id)
data.database = database
data.table = table
if (error) {
data.error = normalizeError(error)
return data
}
} catch (e) {
// In case of a network error we want to fail hard.
if (e.response === undefined && !(e instanceof StoreItemLookupError)) {
throw e
}
return error({ statusCode: 404, message: 'Table not found.' })
data.error = normalizeError(e)
return data
}
// After selecting the table the fields become available which need to be added to
// the data.
data.fields = store.getters['field/getAll']
data.view = undefined
// Without a viewId, redirect the user to the default or the first available view.
if (viewId === null) {
@ -192,18 +205,17 @@ export default {
if (e.response === undefined && !(e instanceof StoreItemLookupError)) {
throw e
}
data.error = normalizeError(e)
return error({ statusCode: 404, message: 'View not found.' })
return data
}
}
if (params.rowId) {
await store.dispatch('rowModalNavigation/fetchRow', {
tableId,
rowId: params.rowId,
})
}
return data
},
head() {

View file

@ -211,14 +211,18 @@ export const actions = {
if (getters.getSelectedId === table.id) {
return { database, table }
}
await axios.all([
dispatch('view/fetchAll', table, { root: true }),
dispatch('field/fetchAll', table, { root: true }),
])
let error = null
await axios
.all([
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('forceSelect', { database, table })
return { database, table }
return { database, table, error }
},
forceSelect({ commit, dispatch }, { database, table }) {
dispatch(
@ -259,7 +263,7 @@ export const actions = {
}
const table = database.tables[index]
return dispatch('select', { database, table })
return await dispatch('select', { database, table })
},
/**
* Unselect the selected table.

View 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,
}
}

View file

@ -354,6 +354,8 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
<div
class="layout__col-2-2 content"
>
<!---->
<div
class="grid-view grid-view--row-height-undefined"
>

View file

@ -361,6 +361,114 @@ describe('View Tests', () => {
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() {
const table = mockServer.createTable()
const { application } = await mockServer.createAppAndWorkspace(table)
@ -494,4 +602,83 @@ describe('View Tests', () => {
tables.push(secondTable)
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 }
}
})