diff --git a/src/components/form/trash-list.vue b/src/components/form/trash-list.vue index 6d0793ccf..deaf297ae 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,13 @@ export default { // tell parent component (ProjectOverview) to refresh regular forms list // (by emitting event to that component's parent) this.$emit('restore'); - } + }, + toggleTrashExpansion(evt) { + const projProps = this.currentUser.preferences.projects[this.project.id]; + if (evt.newState === 'closed') { + projProps.formTrashCollapsed = true; + } else if (projProps.formTrashCollapsed) delete projProps.formTrashCollapsed; + }, } }; @@ -94,6 +108,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/resource.js b/src/request-data/resource.js index fcb37ccf9..dcc7f92d2 100644 --- a/src/request-data/resource.js +++ b/src/request-data/resource.js @@ -51,7 +51,7 @@ class BaseResource { } } -const _container = Symbol('container'); +export const _container = Symbol('container'); const _abortController = Symbol('abortController'); class Resource extends BaseResource { constructor(container, name, store) { diff --git a/src/request-data/resources.js b/src/request-data/resources.js index 1430acf53..7982f0bc9 100644 --- a/src/request-data/resources.js +++ b/src/request-data/resources.js @@ -15,18 +15,21 @@ import { mergeDeepLeft } from 'ramda'; import configDefaults from '../config'; import { computeIfExists, hasVerbs, setupOption, transformForm } from './util'; import { noargs } from '../util/util'; +import { _container } from './resource'; +import UserPreferences from './user-preferences/preferences'; export default ({ i18n }, createResource) => { // Resources related to the session createResource('session'); - createResource('currentUser', () => ({ + createResource('currentUser', (self) => ({ /* eslint-disable no-param-reassign */ transformResponse: ({ data }) => { data.verbs = new Set(data.verbs); data.can = hasVerbs; + const { requestData, http } = self[_container]; + data.preferences = new UserPreferences(data.preferences, requestData.session, http); return shallowReactive(data); } - /* eslint-enable no-param-reassign */ })); // Resources related to the system diff --git a/src/request-data/user-preferences.js b/src/request-data/user-preferences.js new file mode 100644 index 000000000..c0ff36736 --- /dev/null +++ b/src/request-data/user-preferences.js @@ -0,0 +1,208 @@ +/* eslint-disable no-param-reassign */ +import { shallowReactive, isReactive } from 'vue'; +import { apiPaths, withAuth } from '../util/request'; + + +// 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. + + +const VUE_PROPERTY_PREFIX = '__v_'; // Empirically established. I couldn't find documentation on it. + + +class PreferenceNotRegisteredError extends Error { + constructor(prop, whatclass, ...params) { + super(...params); + this.name = 'PreferencesNotRegisteredError'; + this.message = `Property "${prop}" has not been registered in ${whatclass.name}`; + } +} + + +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]; + } +} + + +class SitePreferenceNormalizer extends PreferenceNormalizer { + static projectSortMode(val) { + return ['alphabetical', 'latest', 'newest'].includes(val) ? val : 'latest'; + } +} + + +class ProjectPreferenceNormalizer extends PreferenceNormalizer { + static formTrashCollapsed(val) { + return Boolean(val); + } +} + + +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)}`, + headers: { + 'Content-Type': 'application/json', + }, + data: (v === null) ? undefined : { propertyValue: v }, + signal: aborter.signal, + }, + this.#session.token + ) + ); + } + + #makeSiteProxy(sitePreferenceData) { + const userPreferences = this; + return new Proxy( + shallowReactive(sitePreferenceData), + { + deleteProperty(target, prop) { + SitePreferenceNormalizer.normalizeFn(prop); // throws if prop is not registered + const retval = (delete target[prop]); + userPreferences.#propagate(prop, null, null); // DELETE to backend + return retval; + }, + set(target, prop, value) { + const normalizedValue = SitePreferenceNormalizer.normalize(prop, value); + // eslint-disable-next-line no-multi-assign + const retval = (target[prop] = normalizedValue); + userPreferences.#propagate(prop, normalizedValue, null); // PUT to backend + return retval; + }, + 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 (Number.isNaN(parseInt(projectId, 10))) throw new TypeError(`Not an integer project ID: "${projectId}"`); + const projectProps = target[projectId]; + if (projectProps === undefined || (!isReactive(projectProps))) { // not reentrant (TOCTOU issue) but there's no real way to solve it — as this is supposed to be a synchronous method we can't simply wrap it in a Lock + 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); // throws if prop is not registered + const retval = (delete from[prop]); + userPreferences.#propagate(prop, null, projectId); // DELETE to backend + return retval; + }, + set(from, prop, propval) { + const normalizedValue = ProjectPreferenceNormalizer.normalize(prop, propval); + // eslint-disable-next-line no-multi-assign + const retval = (from[prop] = normalizedValue); + userPreferences.#propagate(prop, normalizedValue, projectId); // PUT to backend + return retval; + }, + get(projectTarget, prop) { + return ProjectPreferenceNormalizer.getProp(projectTarget, prop); + }, + } + ); + } + return target[projectId]; + }, + } + ); + } +} diff --git a/src/request-data/user-preferences/normalizer.js b/src/request-data/user-preferences/normalizer.js new file mode 100644 index 000000000..44c54df30 --- /dev/null +++ b/src/request-data/user-preferences/normalizer.js @@ -0,0 +1,36 @@ +const VUE_PROPERTY_PREFIX = '__v_'; // Empirically established. I couldn't find documentation on it. + + +class PreferenceNotRegisteredError extends Error { + constructor(prop, whatclass, ...params) { + super(...params); + 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..64f33998e --- /dev/null +++ b/src/request-data/user-preferences/normalizers.js @@ -0,0 +1,30 @@ +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 Boolean(val); + } +} diff --git a/src/request-data/user-preferences/preferences.js b/src/request-data/user-preferences/preferences.js new file mode 100644 index 000000000..440aeba9e --- /dev/null +++ b/src/request-data/user-preferences/preferences.js @@ -0,0 +1,140 @@ +/* eslint-disable no-param-reassign */ +import { shallowReactive, isReactive } from 'vue'; +import { apiPaths, withAuth } from '../../util/request'; +import { SitePreferenceNormalizer, ProjectPreferenceNormalizer } from './normalizers'; + + +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)}`, + headers: { + 'Content-Type': 'application/json', + }, + data: (v === null) ? undefined : { propertyValue: v }, + signal: aborter.signal, + }, + this.#session.token + ) + ); + } + + #makeSiteProxy(sitePreferenceData) { + const userPreferences = this; + return new Proxy( + shallowReactive(sitePreferenceData), + { + deleteProperty(target, prop) { + SitePreferenceNormalizer.normalizeFn(prop); // throws if prop is not registered + const retval = (delete target[prop]); + userPreferences.#propagate(prop, null, null); // DELETE to backend + return retval; + }, + set(target, prop, value) { + const normalizedValue = SitePreferenceNormalizer.normalize(prop, value); + // eslint-disable-next-line no-multi-assign + const retval = (target[prop] = normalizedValue); + userPreferences.#propagate(prop, normalizedValue, null); // PUT to backend + return retval; + }, + 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 (Number.isNaN(parseInt(projectId, 10))) throw new TypeError(`Not an integer project ID: "${projectId}"`); + const projectProps = target[projectId]; + if (projectProps === undefined || (!isReactive(projectProps))) { // not reentrant (TOCTOU issue) but there's no real way to solve it — as this is supposed to be a synchronous method we can't simply wrap it in a Lock + 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); // throws if prop is not registered + const retval = (delete from[prop]); + userPreferences.#propagate(prop, null, projectId); // DELETE to backend + return retval; + }, + set(from, prop, propval) { + const normalizedValue = ProjectPreferenceNormalizer.normalize(prop, propval); + // eslint-disable-next-line no-multi-assign + const retval = (from[prop] = normalizedValue); + userPreferences.#propagate(prop, normalizedValue, projectId); // PUT to backend + return retval; + }, + get(projectTarget, prop) { + return ProjectPreferenceNormalizer.getProp(projectTarget, prop); + }, + } + ); + } + return target[projectId]; + }, + } + ); + } +} diff --git a/src/util/request.js b/src/util/request.js index 64db1f539..a0914c8b6 100644 --- a/src/util/request.js +++ b/src/util/request.js @@ -173,7 +173,9 @@ 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)}`, + userSitePreferences: (k) => `/v1/user-preferences/site/${k}`, + userProjectPreferences: (projectId, k) => `/v1/user-preferences/project/${projectId}/${k}`, }; diff --git a/test/data/users.js b/test/data/users.js index 52d321911..cb3e973ea 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,7 +39,8 @@ export const extendedUsers = dataStore({ ? createdAt : (inPast ? fakePastDate([lastCreatedAt]) : new Date().toISOString()), updatedAt: null, - deletedAt + deletedAt, + preferences, }), sort: (administrator1, administrator2) => administrator1.email.localeCompare(administrator2.email)