1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 17:18:33 +00:00

Remember the last view used per table, per user

This commit is contained in:
Eimantas Stonys 2023-09-13 08:43:36 +00:00
parent f3e21464c5
commit feff125f25
8 changed files with 651 additions and 9 deletions
changelog/entries/unreleased/feature
web-frontend
modules/database
test

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Remember the last used view, per table, per user",
"issue_number": 1273,
"bullet_points": [],
"created_at": "2023-09-01"
}

View file

@ -131,16 +131,17 @@ export default {
data.view = undefined
// Because we do not have a dashboard for the table yet we're going to redirect to
// the first available view.
const firstView = store.getters['view/first']
if (viewId === null && firstView !== null) {
const firstViewType = app.$registry.get('view', firstView.type)
// the last visited or the first available view.
const viewToUse = store.getters['view/defaultOrFirst']
if (viewId === null && viewToUse !== null) {
const firstViewType = app.$registry.get('view', viewToUse.type)
// If the view is deactivated, it's not possible to open the view because it will
// put the user in an unrecoverable state. Therefore, it's better to not select a
// view, so that the user can choose which they want to select in the top left
// corner.
if (!firstViewType.isDeactivated(data.database.workspace.id)) {
viewId = firstView.id
viewId = viewToUse.id
}
}

View file

@ -1,5 +1,6 @@
import { StoreItemLookupError } from '@baserow/modules/core/errors'
import { uuid } from '@baserow/modules/core/utils/string'
import { uuid, isSecureURL } from '@baserow/modules/core/utils/string'
import { fitInCookie } from '@baserow/modules/database/utils/view'
import ViewService from '@baserow/modules/database/services/view'
import FilterService from '@baserow/modules/database/services/filter'
import DecorationService from '@baserow/modules/database/services/decoration'
@ -69,6 +70,7 @@ export const state = () => ({
loading: false,
items: [],
selected: {},
defaultViewId: null,
})
export const mutations = {
@ -209,6 +211,21 @@ export const mutations = {
SET_SORT_LOADING(state, { sort, value }) {
sort._.loading = value
},
/**
* Data for defaultViewId for $cookies:
* [
* {table_id: table1Id, id: view1Id},
* {table_id: table2Id, id: view2Id},
* . . .
* ]
* Data for defaultViewId for Vuex store:
* {
* defaultViewId: view1Id,
* }
*/
SET_DEFAULT_VIEW(state, data) {
state.defaultViewId = data
},
}
export const actions = {
@ -238,6 +255,9 @@ export const actions = {
})
commit('SET_ITEMS', data)
commit('SET_LOADING', false)
// Get the default view for the table.
dispatch('getDefaultView', { tableId: table.id })
} catch (error) {
commit('SET_ITEMS', [])
commit('SET_LOADING', false)
@ -413,6 +433,10 @@ export const actions = {
*/
select({ commit, dispatch }, view) {
commit('SET_SELECTED', view)
// Set the default view for the table.
dispatch('setDefaultView', { view })
dispatch(
'undoRedo/updateCurrentScopeSet',
DATABASE_ACTION_SCOPES.view(view.id),
@ -447,6 +471,74 @@ export const actions = {
}
return dispatch('select', view)
},
/**
* Gets the default view from cookies (if it exists) OR the first view
* otherwise, sets it in Vuex store
*/
getDefaultView({ commit, getters }, { tableId }) {
try {
const defaultViewIdData = this.$cookies.get('defaultViewId') || []
const foundView = defaultViewIdData.find(
(view) => view.table_id === tableId
)
if (foundView) {
const view = getters.get(foundView.id)
commit('SET_DEFAULT_VIEW', view?.id)
} else {
commit('SET_DEFAULT_VIEW', null)
}
} catch (error) {
// in case of any exception, set default view to null, this should load the
// first view:
commit('SET_DEFAULT_VIEW', null)
}
},
/**
* Updates the default view for table in cookies and in Vuex store
*/
setDefaultView({ commit }, { view }) {
const defaultViewIdData = this.$cookies.get('defaultViewId') || []
try {
// Find the existing object with the same table_id, if it exists
const existingViewIndex = defaultViewIdData.findIndex(
(obj) => obj.table_id === view.table_id
)
if (existingViewIndex !== -1) {
// If existingView is found, remove it from the array
const existingView = defaultViewIdData.splice(existingViewIndex, 1)[0]
// Update the id of the existing object
existingView.id = view.id
// Add the existingView back to the end of the array
defaultViewIdData.push(existingView)
} else {
if (view.id === view.slug) {
// we are viewing a public view so ignore for the purposes of setting
// a default view
return
}
// Add a new object for the table_id
defaultViewIdData.push({ table_id: view.table_id, id: view.id })
}
} catch (error) {
const defaultViewIdData = []
// in case of any exception, set default view to the current view:
defaultViewIdData.push({ table_id: view.table_id, id: view.id })
} finally {
// Limit the number of views to remember (based on the max. cookie size)
const fittedList = fitInCookie('defaultViewId', defaultViewIdData)
const secure = isSecureURL(this.$config.PUBLIC_WEB_FRONTEND_URL)
this.$cookies.set('defaultViewId', fittedList, {
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1 year
sameSite: 'lax',
secure,
})
commit('SET_DEFAULT_VIEW', view.id)
}
},
/**
* Changes the loading state of a specific filter.
*/
@ -903,6 +995,13 @@ export const getters = {
.sort((a, b) => a.order - b.order)
return items.length > 0 ? items[0] : null
},
// currently only used during unit tests:
defaultId: (state) => {
return state.defaultViewId
},
defaultOrFirst: (state, getters) => {
return getters.get(state.defaultViewId) || getters.first
},
getAll(state) {
return state.items
},

View file

@ -300,3 +300,45 @@ export function getFilters(rootGetters, viewId) {
return filters
}
/**
* Calculates the size of a UTF-8 encoded string in bytes - computes the size
* of a string in UTF-8 encoding and utilizes the TextEncoder API if available.
*
* Using TextEncoder is preferred in Modern Browsers and Node.js Supported
* environments because it provides a more efficient and accurate way to encode
* strings into UTF-8 bytes and directly calculate the byte size of the encoded
* string.
*
* In some older web browsers or environments where TextEncoder may not be available
* (such as SSR where certain browser APIs are absent), it falls back to a less
* accurate method and simply returns the length of the string.
*/
export function utf8ByteSize(str) {
// Use TextEncoder if available (modern browsers and Node.js)
if (typeof TextEncoder !== 'undefined') {
const encoder = new TextEncoder()
const data = encoder.encode(str)
return data.length
} else {
// Fallback for older browsers (may not be as accurate)
return str.length
}
}
/**
* Limit the size of a cookie's value by removing elements from an array
* until it fits within the maximum allowed cookie size.
*/
export function fitInCookie(name, list) {
const result = []
for (let i = list.length - 1; i >= 0; i--) {
result.unshift(list[i])
const serialized = encodeURIComponent(JSON.stringify(result))
if (utf8ByteSize(serialized) > 4096) {
result.shift() // Remove the last added item as it caused the size to exceed the limit
break
}
}
return result
}

View file

@ -43,8 +43,20 @@ export class MockServer {
return { application, workspace }
}
createTable() {
return { id: 1, name: 'Test Table 1' }
async createAppAndWorkspaceWithMultipleTables(tables) {
const workspace = createWorkspace(this.mock, {})
this.loadPermissions(workspace)
const application = createApplication(this.mock, {
workspaceId: workspace.id,
tables,
})
await this.store.dispatch('workspace/fetchAll')
await this.store.dispatch('application/fetchAll')
return { application, workspace }
}
createTable(id = 1, name = 'Test Table 1') {
return { id, name }
}
createGridView(

View file

@ -96,7 +96,7 @@ export class TestApp {
this._app = {
$realtime: this._realtime,
$cookies: {
set(name, id, value) {
set(name, value) {
cookieStorage[name] = value
},
get(name) {
@ -134,6 +134,7 @@ export class TestApp {
this._vueContext,
extraPluginSetupFunc
)
this.store.$cookies = this._app.$cookies
this._initialCleanStoreState = _.cloneDeep(this.store.state)
Papa.arrayToString = (array) => {
return Papa.unparse([array])

View file

@ -31,6 +31,22 @@ describe('Public View Page Tests', () => {
expect(publicGridViewPage.element).toMatchSnapshot()
})
test('Publicly shared view is not saved as a last visited view', async () => {
const slug = 'testSlug'
const gridViewName = 'my public grid view name'
givenAPubliclySharedGridViewWithSlug(gridViewName, slug)
await testApp.mount(PublicGrid, {
asyncDataParams: {
slug,
},
})
const allCookies = testApp.store.$cookies
const cookieValue = allCookies.get('defaultViewId')
expect(cookieValue.length).toBe(0)
})
function givenAPubliclySharedGridViewWithSlug(name, slug) {
const fields = [
{

View file

@ -0,0 +1,464 @@
import { TestApp } from '@baserow/test/helpers/testApp'
import Table from '@baserow/modules/database/pages/table'
import { utf8ByteSize } from '@baserow/modules/database/utils/view'
// Mock out debounce so we dont have to wait or simulate waiting for the various
// debounces in the search functionality.
jest.mock('lodash/debounce', () => jest.fn((fn) => fn))
describe('View Tests', () => {
let testApp = null
let mockServer = null
beforeAll(() => {
testApp = new TestApp()
mockServer = testApp.mockServer
})
afterEach(() => testApp.afterEach())
test('Default view is being set correctly initially', async () => {
const { application, table, views } =
await givenATableInTheServerWithMultipleViews()
const gridView = views[0]
const galleryView = views[1]
// The first view is the Grid view, the Default view is the Gallery view which
// is going to be rendered initially:
const tableComponent = await testApp.mount(Table, {
asyncDataParams: {
databaseId: application.id,
tableId: table.id,
viewId: galleryView.id,
},
})
const allCookies = testApp.store.$cookies
const tableId = gridView.table_id
// Check if Vuex store is updated correctly (first view):
expect(testApp.store.getters['view/first'].id).toBe(gridView.id)
// Check if cookie is updated correctly (default view):
const cookieValue = allCookies.get('defaultViewId')
expect(cookieValue.length).toBe(1)
const defaultViewIdObject = cookieValue.find(
(obj) => obj.table_id === tableId
)
expect(defaultViewIdObject.table_id).toBe(tableId)
expect(defaultViewIdObject.id).toBe(galleryView.id)
// Check if Vuex store is updated correctly (default view):
expect(testApp.store.getters['view/defaultId']).toBe(galleryView.id)
// Check if component is rendered:
expect(tableComponent.find('div.gallery-view').exists()).toBe(true)
expect(tableComponent.find('div.grid-view').exists()).toBe(false)
})
test('Default view is being set correctly after changing views', async () => {
const { application, table, views } =
await givenATableInTheServerWithMultipleViews()
const gridView = views[0]
const galleryView = views[1]
// The first view is the Grid view, the Default view is the Gallery view which
// is going to be rendered initially:
const tableComponent = await testApp.mount(Table, {
asyncDataParams: {
databaseId: application.id,
tableId: table.id,
viewId: galleryView.id,
},
})
const allCookies = testApp.store.$cookies
const tableId = gridView.table_id
// Check if Vuex store is updated correctly (first view):
expect(testApp.store.getters['view/first'].id).toBe(gridView.id)
// Check if cookie is updated correctly (default view):
const cookieValue = allCookies.get('defaultViewId')
expect(cookieValue.length).toBe(1)
const defaultViewIdObject = cookieValue.find(
(obj) => obj.table_id === tableId
)
expect(defaultViewIdObject.table_id).toBe(tableId)
expect(defaultViewIdObject.id).toBe(galleryView.id)
// Check if Vuex store is updated correctly (default view):
expect(testApp.store.getters['view/defaultId']).toBe(galleryView.id)
// Check if component is rendered:
expect(tableComponent.find('div.gallery-view').exists()).toBe(true)
expect(tableComponent.find('div.grid-view').exists()).toBe(false)
// Let's switch back (select) the Grid (first) view:
testApp.store.dispatch('view/selectById', gridView.id)
// Check if Vuex store is updated correctly (first view):
expect(testApp.store.getters['view/first'].id).toBe(gridView.id)
// Check if cookie is updated correctly (default view):
const updatedCookieValue = allCookies.get('defaultViewId')
expect(updatedCookieValue.length).toBe(1)
const updatedDefaultViewIdObject = updatedCookieValue.find(
(obj) => obj.table_id === tableId
)
expect(updatedDefaultViewIdObject.table_id).toBe(tableId)
expect(updatedDefaultViewIdObject.id).toBe(gridView.id)
// Check if Vuex store is updated correctly (default view):
expect(testApp.store.getters['view/defaultId']).toBe(gridView.id)
})
test('Default view is being set correctly after switching tables', async () => {
const { application, tables, views } =
await givenATableInTheServerWithMultipleTables()
const firstTable = tables[0]
const secondTable = tables[1]
const firstTableGridView = views[0]
const secondTableGridView = views[1]
// The first (and default) view is the Grid view, which is going to be rendered
// initially for the firstTable:
const firstTableComponent = await testApp.mount(Table, {
asyncDataParams: {
databaseId: application.id,
tableId: firstTable.id,
},
})
const allCookies = testApp.store.$cookies
// Check if Vuex store is updated correctly (first view):
expect(testApp.store.getters['view/first'].id).toBe(firstTableGridView.id)
// Check if cookie is updated correctly (default view):
const cookieValue = allCookies.get('defaultViewId')
expect(cookieValue.length).toBe(1)
const defaultViewIdObject = cookieValue.find(
(obj) => obj.table_id === firstTableGridView.table_id
)
expect(defaultViewIdObject.table_id).toBe(firstTableGridView.table_id)
expect(defaultViewIdObject.id).toBe(firstTableGridView.id)
// Check if Vuex store is updated correctly (default view):
expect(testApp.store.getters['view/defaultId']).toBe(firstTableGridView.id)
// Check if component is rendered:
expect(firstTableComponent.find('div.grid-view').exists()).toBe(true)
expect(firstTableComponent.find('div.gallery-view').exists()).toBe(false)
// The first (and default) view is the Grid view, which is going to be rendered
// initially for the secondTable:
await testApp.mount(Table, {
asyncDataParams: {
databaseId: application.id,
tableId: secondTable.id,
},
})
// Let's switch to a different table in the database:
testApp.store.dispatch('table/selectById', {
databaseId: application.id,
tableId: secondTable.id,
})
testApp.store.dispatch('view/selectById', secondTableGridView.id)
const allCookiesAfterChangingTable = testApp.store.$cookies
const cookieValueAfterChangingTable =
allCookiesAfterChangingTable.get('defaultViewId')
// Check if Vuex store is updated correctly (first view):
expect(testApp.store.getters['view/first'].id).toBe(secondTableGridView.id)
// Check if cookie is updated correctly (default view):
expect(cookieValueAfterChangingTable.length).toBe(2)
const defaultViewIdObjectAfterChangingTable =
cookieValueAfterChangingTable.find(
(obj) => obj.table_id === secondTableGridView.table_id
)
expect(defaultViewIdObjectAfterChangingTable.table_id).toBe(
secondTableGridView.table_id
)
expect(defaultViewIdObjectAfterChangingTable.id).toBe(
secondTableGridView.id
)
// Check if Vuex store is updated correctly (default view):
expect(testApp.store.getters['view/defaultId']).toBe(secondTableGridView.id)
// Check if component is rendered:
expect(firstTableComponent.find('div.grid-view').exists()).toBe(true)
expect(firstTableComponent.find('div.gallery-view').exists()).toBe(false)
// Let's switch back to the first table in the database and see if first table's
// default view is appended to the *end* of remembered views array:
await testApp.mount(Table, {
asyncDataParams: {
databaseId: application.id,
tableId: firstTable.id,
},
})
testApp.store.dispatch('table/selectById', {
databaseId: application.id,
tableId: firstTable.id,
})
testApp.store.dispatch('view/selectById', firstTableGridView.id)
const allCookiesAfterSwitchingBack = testApp.store.$cookies
// Check if Vuex store is updated correctly (first view):
expect(testApp.store.getters['view/first'].id).toBe(firstTableGridView.id)
// Check if cookie is updated correctly (default view):
const cookieValueAfterSwitchingBack =
allCookiesAfterSwitchingBack.get('defaultViewId')
expect(cookieValueAfterSwitchingBack.length).toBe(2)
const defaultViewIdObjectAfterSwitchingBack =
cookieValueAfterSwitchingBack.find(
(obj) => obj.table_id === firstTableGridView.table_id
)
expect(defaultViewIdObjectAfterSwitchingBack.table_id).toBe(
firstTableGridView.table_id
)
expect(defaultViewIdObjectAfterSwitchingBack.id).toBe(firstTableGridView.id)
// Check if Vuex store is updated correctly (default view):
expect(testApp.store.getters['view/defaultId']).toBe(firstTableGridView.id)
expect(cookieValueAfterSwitchingBack[cookieValue.length - 1]).toMatchObject(
defaultViewIdObjectAfterSwitchingBack
)
})
test('Default view is being set correctly initially only from cookie', async () => {
// set the cookie, render table without view id passed in, this should render
// the default (Gallery) view
const { application, table, views } =
await givenATableInTheServerWithMultipleViews()
const gridView = views[0]
const galleryView = views[1]
const tableId = gridView.table_id
const allCookies = testApp.store.$cookies
// Set the cookie for defaultView manually:
const defaultViewIdData = []
defaultViewIdData.push({
table_id: galleryView.table_id,
id: galleryView.id,
})
allCookies.set('defaultViewId', defaultViewIdData)
// The first view is the Grid view, the Default view is the Gallery view,
// we're not rendering any view initially and Default view (Gallery view)
// should be picked up from the cookie
const tableComponent = await testApp.mount(Table, {
asyncDataParams: {
databaseId: application.id,
tableId: table.id,
},
})
// Check if Vuex store is updated correctly (first view):
expect(testApp.store.getters['view/first'].id).toBe(gridView.id)
// Check if cookie is updated correctly (default view):
const cookieValue = allCookies.get('defaultViewId')
expect(cookieValue.length).toBe(1)
const defaultViewIdObject = cookieValue.find(
(obj) => obj.table_id === tableId
)
expect(defaultViewIdObject.table_id).toBe(tableId)
expect(defaultViewIdObject.id).toBe(galleryView.id)
// Check if Vuex store is updated correctly (default view):
expect(testApp.store.getters['view/defaultId']).toBe(galleryView.id)
// Check if component is rendered:
expect(tableComponent.find('div.gallery-view').exists()).toBe(true)
expect(tableComponent.find('div.grid-view').exists()).toBe(false)
})
test('Changing default view updates cookies array correctly', async () => {
const { application, table, views } =
await givenATableInTheServerWithMultipleViews()
const gridView = views[0]
const allCookies = testApp.store.$cookies
// Calculate the size needed to exceed 4096 bytes
const targetSize = 10000 // Choose a size greater than 4096
// Generate random data to fill up the cookie
const randomData = []
let totalSize = 0
let i = 0
while (totalSize < targetSize) {
const randomTableId = i++
const randomViewId = i++
const entry = { table_id: randomTableId, id: randomViewId }
const entrySize = utf8ByteSize(encodeURIComponent(JSON.stringify(entry)))
if (totalSize + entrySize <= targetSize) {
randomData.push(entry)
totalSize += entrySize
} else {
break
}
}
allCookies.set('defaultViewId', randomData)
const originalDataLength = randomData.length
// Mount the component, which should update the cookies
await testApp.mount(Table, {
asyncDataParams: {
databaseId: application.id,
tableId: table.id,
viewId: gridView.id,
},
})
// The Default view is the Grid view and it should be set (appended) in the cookie
const cookieValue = allCookies.get('defaultViewId')
expect(cookieValue.length).toBeGreaterThan(0)
const defaultViewIdObject = cookieValue.find(
(obj) => obj.table_id === gridView.table_id
)
expect(defaultViewIdObject.table_id).toBe(gridView.table_id)
expect(defaultViewIdObject.id).toBe(gridView.id)
// Check if gridView is set as the last view in the array
expect(cookieValue[cookieValue.length - 1]).toMatchObject(
defaultViewIdObject
)
// Ensure that the first element is removed from the cookie array
const updatedCookieValue = allCookies.get('defaultViewId')
expect(updatedCookieValue).not.toContainEqual(randomData[0])
expect(updatedCookieValue.length).toBeLessThan(originalDataLength)
})
async function givenATableInTheServerWithMultipleViews() {
const table = mockServer.createTable()
const { application } = await mockServer.createAppAndWorkspace(table)
const gridView = mockServer.createGridView(application, table, {
viewId: 1,
})
const galleryView = mockServer.createGalleryView(application, table, {
viewId: 2,
})
mockServer.mock
.onGet(`/database/views/table/${table.id}/`)
.reply(200, [gridView, galleryView])
const fields = mockServer.createFields(application, table, [
{
name: 'Name',
type: 'text',
primary: true,
},
{
name: 'Last name',
type: 'text',
},
{
name: 'Notes',
type: 'long_text',
},
{
name: 'Active',
type: 'boolean',
},
])
const rows = [
{
id: 1,
order: 0,
field_1: 'name',
field_2: 'last_name',
field_3: 'notes',
field_4: false,
},
]
mockServer.createGridRows(gridView, fields, rows)
mockServer.createFields(application, table, fields)
mockServer.createGalleryRows(galleryView, fields, rows)
const views = []
views.push(gridView)
views.push(galleryView)
return { application, table, views }
}
async function givenATableInTheServerWithMultipleTables() {
const firstTable = mockServer.createTable(1, 'Test Table 1')
const secondTable = mockServer.createTable(2, 'Test Table 2')
const { application } =
await mockServer.createAppAndWorkspaceWithMultipleTables([
firstTable,
secondTable,
])
// First table - 1 view:
const firstTableGridView = mockServer.createGridView(
application,
firstTable,
{
viewId: 1,
}
)
mockServer.mock
.onGet(`/database/views/table/${firstTable.id}/`)
.reply(200, [firstTableGridView])
// Second table - 1 view:
const secondTableGridView = mockServer.createGridView(
application,
secondTable,
{
viewId: 2,
}
)
mockServer.mock
.onGet(`/database/views/table/${secondTable.id}/`)
.reply(200, [secondTableGridView])
const fields = mockServer.createFields(application, firstTable, [
{
name: 'Name',
type: 'text',
primary: true,
},
{
name: 'Last name',
type: 'text',
},
{
name: 'Notes',
type: 'long_text',
},
{
name: 'Active',
type: 'boolean',
},
])
const rows = [
{
id: 1,
order: 0,
field_1: 'name',
field_2: 'last_name',
field_3: 'notes',
field_4: false,
},
]
mockServer.createGridRows(firstTableGridView, fields, rows)
mockServer.createGridRows(secondTableGridView, fields, rows)
mockServer.createFields(application, firstTable, fields)
mockServer.createFields(application, secondTable, fields)
const views = []
views.push(firstTableGridView)
views.push(secondTableGridView)
const tables = []
tables.push(firstTable)
tables.push(secondTable)
return { application, tables, views }
}
})