diff --git a/changelog/entries/unreleased/bug/3331_database_enhance_backend_errors_handling_in_ui.json b/changelog/entries/unreleased/bug/3331_database_enhance_backend_errors_handling_in_ui.json new file mode 100644 index 000000000..477ff7eb1 --- /dev/null +++ b/changelog/entries/unreleased/bug/3331_database_enhance_backend_errors_handling_in_ui.json @@ -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" +} diff --git a/web-frontend/modules/database/components/table/Table.vue b/web-frontend/modules/database/components/table/Table.vue index e82da0345..dbe7f6753 100644 --- a/web-frontend/modules/database/components/table/Table.vue +++ b/web-frontend/modules/database/components/table/Table.vue @@ -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, diff --git a/web-frontend/modules/database/pages/table.vue b/web-frontend/modules/database/pages/table.vue index 2cb6fa162..faa509e86 100644 --- a/web-frontend/modules/database/pages/table.vue +++ b/web-frontend/modules/database/pages/table.vue @@ -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() { diff --git a/web-frontend/modules/database/store/table.js b/web-frontend/modules/database/store/table.js index b10ab1c88..5dfed2c3c 100644 --- a/web-frontend/modules/database/store/table.js +++ b/web-frontend/modules/database/store/table.js @@ -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. diff --git a/web-frontend/modules/database/utils/errors.js b/web-frontend/modules/database/utils/errors.js new file mode 100644 index 000000000..e98d20d2e --- /dev/null +++ b/web-frontend/modules/database/utils/errors.js @@ -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, + } +} diff --git a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap index ba6e01a66..555f5b2b8 100644 --- a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap +++ b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap @@ -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" > diff --git a/web-frontend/test/unit/database/view.spec.js b/web-frontend/test/unit/database/view.spec.js index e4d265ea1..dee817808 100644 --- a/web-frontend/test/unit/database/view.spec.js +++ b/web-frontend/test/unit/database/view.spec.js @@ -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 } + } })