From e80cbb6efc0b8965cea28f2b94e6f5cf9d168015 Mon Sep 17 00:00:00 2001 From: brontolosone <177225737+brontolosone@users.noreply.github.com> Date: Fri, 1 Nov 2024 11:41:26 +0000 Subject: [PATCH] Add user preference persistence framework (#1024) Add user preference framework, accessible via the currentUser resource. Co-authored-by: Matthew White --- src/components/form/trash-list.vue | 59 ++++-- src/components/project/list.vue | 13 +- src/request-data/resources.js | 10 +- .../user-preferences/normalizer.js | 48 +++++ .../user-preferences/normalizers.js | 42 ++++ .../user-preferences/preferences.js | 183 ++++++++++++++++++ src/util/request.js | 4 +- test/data/users.js | 14 +- 8 files changed, 343 insertions(+), 30 deletions(-) create mode 100644 src/request-data/user-preferences/normalizer.js create mode 100644 src/request-data/user-preferences/normalizers.js create mode 100644 src/request-data/user-preferences/preferences.js diff --git a/src/components/form/trash-list.vue b/src/components/form/trash-list.vue index 6d0793ccf..2cd5ba83c 100644 --- a/src/components/form/trash-list.vue +++ b/src/components/form/trash-list.vue @@ -11,22 +11,27 @@ except according to the terms contained in the LICENSE file. --> @@ -49,8 +54,8 @@ export default { setup() { // The component does not assume that this data will exist when the // component is created. - const { project, deletedForms } = useRequestData(); - return { project, deletedForms, restoreForm: modalData() }; + const { project, deletedForms, currentUser } = useRequestData(); + return { project, deletedForms, currentUser, restoreForm: modalData() }; }, computed: { count() { @@ -59,7 +64,10 @@ export default { sortedDeletedForms() { const sortByDeletedAt = sortWith([ascend(entry => entry.deletedAt)]); return sortByDeletedAt(this.deletedForms.data); - } + }, + isFormTrashCollapsed() { + return this.currentUser.preferences.projects[this.project.id].formTrashCollapsed; + }, }, created() { this.fetchDeletedForms(false); @@ -82,7 +90,12 @@ export default { // tell parent component (ProjectOverview) to refresh regular forms list // (by emitting event to that component's parent) this.$emit('restore'); - } + }, + onToggleTrashExpansion(evt) { + const projProps = this.currentUser.preferences.projects[this.project.id]; + if (evt.newState === 'closed') projProps.formTrashCollapsed = true; + else if (projProps.formTrashCollapsed) projProps.formTrashCollapsed = false; + }, } }; @@ -94,6 +107,12 @@ export default { display: flex; align-items: baseline; + #form-trash-expander { + // Fixate the width as icon-chevron-down and icon-chevron-right have unequal width :-( + display: inline-block; + width: 1em; + } + .icon-trash { padding-right: 8px; } diff --git a/src/components/project/list.vue b/src/components/project/list.vue index 259466763..95b69db16 100644 --- a/src/components/project/list.vue +++ b/src/components/project/list.vue @@ -95,7 +95,16 @@ export default { setup() { const { currentUser, projects } = useRequestData(); - const sortMode = ref('latest'); + const sortMode = computed({ + get() { + // currentUser.preferences goes missing on logout, see https://github.com/getodk/central-frontend/pull/1024#pullrequestreview-2332522640 + return currentUser.preferences?.site?.projectSortMode; + }, + set(val) { + currentUser.preferences.site.projectSortMode = val; + }, + }); + const sortFunction = computed(() => sortFunctions[sortMode.value]); const activeProjects = ref(null); @@ -164,7 +173,7 @@ export default { const message = this.$t('alert.create'); this.$router.push(this.projectPath(project.id)) .then(() => { this.alert.success(message); }); - } + }, } }; diff --git a/src/request-data/resources.js b/src/request-data/resources.js index 1430acf53..b77a3c3ba 100644 --- a/src/request-data/resources.js +++ b/src/request-data/resources.js @@ -15,18 +15,20 @@ import { mergeDeepLeft } from 'ramda'; import configDefaults from '../config'; import { computeIfExists, hasVerbs, setupOption, transformForm } from './util'; import { noargs } from '../util/util'; +import UserPreferences from './user-preferences/preferences'; -export default ({ i18n }, createResource) => { +export default ({ i18n, http }, createResource) => { // Resources related to the session - createResource('session'); + const session = createResource('session'); createResource('currentUser', () => ({ - /* eslint-disable no-param-reassign */ transformResponse: ({ data }) => { + /* eslint-disable no-param-reassign */ data.verbs = new Set(data.verbs); data.can = hasVerbs; + data.preferences = new UserPreferences(data.preferences, session, http); + /* eslint-enable no-param-reassign */ return shallowReactive(data); } - /* eslint-enable no-param-reassign */ })); // Resources related to the system diff --git a/src/request-data/user-preferences/normalizer.js b/src/request-data/user-preferences/normalizer.js new file mode 100644 index 000000000..321620f13 --- /dev/null +++ b/src/request-data/user-preferences/normalizer.js @@ -0,0 +1,48 @@ +/* +Copyright 2024 ODK Central Developers +See the NOTICE file at the top-level directory of this distribution and at +https://github.com/getodk/central-frontend/blob/master/NOTICE. + +This file is part of ODK Central. It is subject to the license terms in +the LICENSE file found in the top-level directory of this distribution and at +https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +including this file, may be copied, modified, propagated, or distributed +except according to the terms contained in the LICENSE file. +*/ + +const VUE_PROPERTY_PREFIX = '__v_'; // Empirically established. I couldn't find documentation on it. + + +class PreferenceNotRegisteredError extends Error { + constructor(prop, whatclass) { + super(); + this.name = 'PreferencesNotRegisteredError'; + this.message = `Property "${prop}" has not been registered in ${whatclass.name}`; + } +} + + +export default class PreferenceNormalizer { + static _normalize(target, prop, val) { + const normalizer = this.normalizeFn(prop); + const theVal = (target === undefined ? val : target[prop]); + return normalizer(theVal); + } + + static normalizeFn(prop) { + const normalizer = Object.prototype.hasOwnProperty.call(this, prop) ? this[prop] : undefined; + if (normalizer !== undefined) return normalizer; + throw new PreferenceNotRegisteredError(prop, this); + } + + static normalize(prop, val) { + return this._normalize(undefined, prop, val); + } + + static getProp(target, prop) { + if (typeof (prop) === 'string' && !prop.startsWith(VUE_PROPERTY_PREFIX)) { + return this._normalize(target, prop); + } + return target[prop]; + } +} diff --git a/src/request-data/user-preferences/normalizers.js b/src/request-data/user-preferences/normalizers.js new file mode 100644 index 000000000..ba6384aaf --- /dev/null +++ b/src/request-data/user-preferences/normalizers.js @@ -0,0 +1,42 @@ +/* +Copyright 2024 ODK Central Developers +See the NOTICE file at the top-level directory of this distribution and at +https://github.com/getodk/central-frontend/blob/master/NOTICE. + +This file is part of ODK Central. It is subject to the license terms in +the LICENSE file found in the top-level directory of this distribution and at +https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +including this file, may be copied, modified, propagated, or distributed +except according to the terms contained in the LICENSE file. +*/ + +import PreferenceNormalizer from './normalizer'; + +// The SitePreferenceNormalizer and ProjectPreferenceNormalizer classes are used to: +// a) verify that the preference key has been declared here. +// Such might seem persnickety, but it allows us to have a central +// registry of which keys are in use. +// b) normalize the value as per the normalization function with the name +// of the preference. This also allows supplying a default. +// Preferences serverside may have been created by some frontend version that +// used different semantics (different values, perhaps differently typed). +// Writing a validator function here makes it so one does not have to be defensive +// for that eventuality in *every single usage site of the setting*. +// +// As such, any newly introduced preference will need a normalization function added +// to one of those classes, even if it's just a straight passthrough. +// Furthermore, the answer to "why can't I set an arbitrary value for a certain preference" +// can be found there. + + +export class SitePreferenceNormalizer extends PreferenceNormalizer { + static projectSortMode(val) { + return ['alphabetical', 'latest', 'newest'].includes(val) ? val : 'latest'; + } +} + +export class ProjectPreferenceNormalizer extends PreferenceNormalizer { + static formTrashCollapsed(val) { + return val === true; + } +} diff --git a/src/request-data/user-preferences/preferences.js b/src/request-data/user-preferences/preferences.js new file mode 100644 index 000000000..2099f1f7e --- /dev/null +++ b/src/request-data/user-preferences/preferences.js @@ -0,0 +1,183 @@ +/* +Copyright 2024 ODK Central Developers +See the NOTICE file at the top-level directory of this distribution and at +https://github.com/getodk/central-frontend/blob/master/NOTICE. + +This file is part of ODK Central. It is subject to the license terms in +the LICENSE file found in the top-level directory of this distribution and at +https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +including this file, may be copied, modified, propagated, or distributed +except according to the terms contained in the LICENSE file. +*/ + +import { shallowReactive, isReactive } from 'vue'; +import { apiPaths, withAuth } from '../../util/request'; +import { noop } from '../../util/util'; +import { SitePreferenceNormalizer, ProjectPreferenceNormalizer } from './normalizers'; + + +/* +UserPreferences - for storing user preferences such as the display sort order of listings, etc. The settings are propagated to the +backend and will be loaded into newly created frontend sessions. As such, it doesn't function as a live-sync mechanism between sessions; +preferences only get loaded from the backend once, at login time; thus concurrent sessions don't see eachother's changes. But a newly +created third session will see the amalgamate of the preferences applied in the former two sessions, which may be lightly surprising to +a user, but it keeps things simple. + +A preference has a key and a value. It's expressed via a JS object: +- `currentUser.preferences.site.myPreference` for sitewide preferences, or +- `currentUser.preferences.projects[someProjectID].someProjectPreference` for per-project preferences. +The value may be anything json-serializable. + +Note that for project preferences, the per-project settings object is autovivicated when referenced. So you don't need to worry about +whether there is already any settings object for a certain project. If there isn't one, it will be generated on the fly when you assign — +for instance, `currentUser.preferences.projects[9000].blooblap = "green"`. + +You can also delete a preference; just do `delete currentUser.preferences.projects[9000].blooblap`. + +All values in the objects are reactive, so the *idea* is that you would be able to simply reference such a value in a Vue template, and be done! + +To set up a preference, there's one thing you need to do: register a "normalizer" for your preference inside `normalizers.js` (more documentation +is to be found there). + +One thing to take into account is that every assignment will result in a PUT to the backend (and similarly, any `delete` will result in a +HTTP DELETE request being sent). Thus if you have a preference situation in which the same value can be set repeatedly, you may want to +intervene to compare the value, and only set it in the Preferences object when it has really changed. + +Another thing: the propagation to the backend is best-effort — there are no retries, nor is there dirtiness tracking. If the request fails, +for instance due to some transient network error, then the preference is not propagated and a newly created session will not incorporate the +mutation. At the backend, the preferences are stored in the `user_site_preferences` and `user_project_preferences` tables. +*/ + +export default class UserPreferences { + #abortControllers; + #instanceID; + #session; + #http; + + constructor(preferenceData, session, http) { + this.#abortControllers = {}; + this.#instanceID = crypto.randomUUID(); + this.site = this.#makeSiteProxy(preferenceData.site); + this.projects = this.#makeProjectsProxy(preferenceData.projects); + this.#session = session; + this.#http = http; + } + + #propagate(k, v, projectId) { + // As we need to be able to have multiple requests in-flight (not canceling eachother), we can't use resource.request() here. + // However, we want to avoid stacking requests for the same key, so we abort preceding requests for the same key, if any. + // Note that because locks are origin-scoped, we use a store instantiation identifier to scope them to this app instance. + const keyLockName = `userPreferences-${this.#instanceID}-keystack-${projectId}-${k}`; + navigator.locks.request( + `userPreferences-${this.instanceID}-lockops`, + () => { + navigator.locks.request( + keyLockName, + { ifAvailable: true }, + (lockForKey) => { + const aborter = new AbortController(); + if (!lockForKey) { + // Cancel the preceding HTTP request, a new one supersedes it. + this.#abortControllers[k].abort(); + return navigator.locks.request( + keyLockName, + () => { + this.#abortControllers[k] = aborter; + return this.#request(k, v, projectId, aborter); + } + ); + } + this.#abortControllers[k] = aborter; + return this.#request(k, v, projectId, aborter); + }, + ); + return Promise.resolve(); // return asap with a resolved promise so the outer lockops lock gets released; we don't wan't to wait here for the inner keylock-enveloped requests. + } + ); + } + + #request(k, v, projectId, aborter) { + return this.#http.request( + withAuth( + { + method: (v === null) ? 'DELETE' : 'PUT', + url: (projectId === null) ? apiPaths.userSitePreferences(k) : apiPaths.userProjectPreferences(projectId, k), + data: (v === null) ? undefined : { propertyValue: v }, + signal: aborter.signal, + }, + this.#session.token + ) + ).catch(noop); // Preference didn't get persisted to the backend. Too bad! We're not doing any retrying. + } + + #makeSiteProxy(sitePreferenceData) { + const userPreferences = this; + return new Proxy( + shallowReactive(sitePreferenceData), + { + /* eslint-disable no-param-reassign */ + deleteProperty(target, prop) { + SitePreferenceNormalizer.normalizeFn(prop); // throws if prop is not registered + delete target[prop]; + userPreferences.#propagate(prop, null, null); // DELETE to backend + return true; + }, + set(target, prop, value) { + const normalizedValue = SitePreferenceNormalizer.normalize(prop, value); + target[prop] = normalizedValue; + userPreferences.#propagate(prop, normalizedValue, null); // PUT to backend + return true; + }, + /* eslint-enable no-param-reassign */ + get(target, prop) { + return SitePreferenceNormalizer.getProp(target, prop); + } + } + ); + } + + #makeProjectsProxy(projectsPreferenceData) { + const userPreferences = this; + return new Proxy( + projectsPreferenceData, + { + deleteProperty() { + throw new Error('Deleting a project\'s whole property collection is not supported. Delete each property individually, eg "delete preferences.projects[3].foo".'); + }, + set() { + throw new Error('Directly setting a project\'s whole property collection is not supported. Set each property individually, eg "preferences.projects[3].foo = \'bar\'"'); + }, + get(target, projectId) { + if (!/^\d+$/.test(projectId)) throw new TypeError(`Not an integer project ID: "${projectId}"`); + const projectProps = target[projectId]; + if (projectProps === undefined || (!isReactive(projectProps))) { + /* eslint-disable no-param-reassign */ + target[projectId] = new Proxy( + // make (potentially autovivicated) props reactive, and front them with a proxy to enable our setters/deleters + shallowReactive(projectProps === undefined ? {} : projectProps), + { + deleteProperty(from, prop) { + ProjectPreferenceNormalizer.normalizeFn(prop); // we're calling it just so that it throws if prop is not registered in the form of a normalization function + delete from[prop]; + userPreferences.#propagate(prop, null, projectId); // DELETE to backend + return true; + }, + set(from, prop, propval) { + const normalizedValue = ProjectPreferenceNormalizer.normalize(prop, propval); + from[prop] = normalizedValue; + userPreferences.#propagate(prop, normalizedValue, projectId); // PUT to backend + return true; + }, + get(projectTarget, prop) { + return ProjectPreferenceNormalizer.getProp(projectTarget, prop); + }, + } + ); + /* eslint-enable no-param-reassign */ + } + return target[projectId]; + }, + } + ); + } +} diff --git a/src/util/request.js b/src/util/request.js index 64db1f539..9593a1a38 100644 --- a/src/util/request.js +++ b/src/util/request.js @@ -72,6 +72,8 @@ export const apiPaths = { user: (id) => `/v1/users/${id}`, password: (id) => `/v1/users/${id}/password`, assignment: (role, actorId) => `/v1/assignments/${role}/${actorId}`, + userSitePreferences: (k) => `/v1/user-preferences/site/${k}`, + userProjectPreferences: (projectId, k) => `/v1/user-preferences/project/${projectId}/${k}`, project: projectPath(''), projectAssignments: projectPath('/assignments'), projectAssignment: (projectId, role, actorId) => @@ -173,7 +175,7 @@ export const apiPaths = { fieldKeys: projectPath('/app-users'), serverUrlForFieldKey: (token, projectId) => `/v1/key/${token}/projects/${projectId}`, - audits: (query) => `/v1/audits${queryString(query)}` + audits: (query) => `/v1/audits${queryString(query)}`, }; diff --git a/test/data/users.js b/test/data/users.js index 52d321911..194f5d367 100644 --- a/test/data/users.js +++ b/test/data/users.js @@ -24,7 +24,11 @@ export const extendedUsers = dataStore({ role = 'admin', verbs = verbsByRole(role), createdAt = undefined, - deletedAt = undefined + deletedAt = undefined, + preferences = { + site: {}, + projects: {}, + }, }) => ({ id, type: 'user', @@ -35,10 +39,14 @@ export const extendedUsers = dataStore({ ? createdAt : (inPast ? fakePastDate([lastCreatedAt]) : new Date().toISOString()), updatedAt: null, - deletedAt + deletedAt, + preferences, }), sort: (administrator1, administrator2) => administrator1.email.localeCompare(administrator2.email) }); -export const standardUsers = view(extendedUsers, omit(['verbs'])); +export const standardUsers = view( + extendedUsers, + omit(['verbs', 'preferences']) +);