mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-07 14:25:37 +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>
|
||||
<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,
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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.
|
||||
|
|
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
|
||||
class="layout__col-2-2 content"
|
||||
>
|
||||
<!---->
|
||||
|
||||
<div
|
||||
class="grid-view grid-view--row-height-undefined"
|
||||
>
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
|
|
Loading…
Add table
Reference in a new issue