Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user preference persistence framework #1024

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
60 changes: 40 additions & 20 deletions src/components/form/trash-list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@ except according to the terms contained in the LICENSE file.
-->
<template>
<div v-if="count > 0" id="form-trash-list">
<div id="form-trash-list-header">
<span id="form-trash-list-title">
<span class="icon-trash"></span>
<span>{{ $t('title') }}</span>
</span>
<span id="form-trash-list-count">{{ $t('trashCount', { count: $n(count, 'default') }) }}</span>
<span id="form-trash-list-note">{{ $t('message') }}</span>
</div>
<table id="form-trash-list-table" class="table">
<tbody>
<form-trash-row v-for="form of sortedDeletedForms" :key="form.id" :form="form"
@start-restore="restoreForm.show({ form: $event })"/>
</tbody>
</table>
<form-restore v-bind="restoreForm" @hide="restoreForm.hide()"
@success="afterRestore"/>
<details :open="!isFormTrashCollapsed" @toggle="toggleTrashExpansion">
<summary>
<div id="form-trash-list-header">
<span id="form-trash-list-title">
<span id="form-trash-expander" :class="{ 'icon-chevron-right': isFormTrashCollapsed, 'icon-chevron-down': !isFormTrashCollapsed }"></span>
<span class="icon-trash"></span>
<span>{{ $t('title') }}</span>
</span>
<span id="form-trash-list-count">{{ $t('trashCount', { count: $n(count, 'default') }) }}</span>
<span id="form-trash-list-note">{{ $t('message') }}</span>
</div>
</summary>
<table id="form-trash-list-table" class="table">
<tbody>
<form-trash-row v-for="form of sortedDeletedForms" :key="form.id" :form="form"
@start-restore="restoreForm.show({ form: $event })"/>
</tbody>
</table>
<form-restore v-bind="restoreForm" @hide="restoreForm.hide()"
@success="afterRestore"/>
</details>
</div>
</template>

Expand All @@ -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() {
Expand All @@ -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);
Expand All @@ -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;
},
}
};
</script>
Expand All @@ -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;
}
Expand Down
13 changes: 11 additions & 2 deletions src/components/project/list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -164,7 +173,7 @@ export default {
const message = this.$t('alert.create');
this.$router.push(this.projectPath(project.id))
.then(() => { this.alert.success(message); });
}
},
}
};
</script>
Expand Down
2 changes: 1 addition & 1 deletion src/request-data/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class BaseResource {
}
}

const _container = Symbol('container');
export const _container = Symbol('container');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_container is supposed to work like a private property, so it'd be great to avoid exporting it.

const _abortController = Symbol('abortController');
class Resource extends BaseResource {
constructor(container, name, store) {
Expand Down
7 changes: 5 additions & 2 deletions src/request-data/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first argument here, from which i18n is destructured, is the container. You can get http from there as well.

// Resources related to the session
createResource('session');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you assign this resource to a variable, you can reference it below.

Suggested change
createResource('session');
const 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];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above: you should be able to access session and http without referencing _container.

data.preferences = new UserPreferences(data.preferences, requestData.session, http);
return shallowReactive(data);
}
/* eslint-enable no-param-reassign */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be good to re-enable this rule.

}));

// Resources related to the system
Expand Down
208 changes: 208 additions & 0 deletions src/request-data/user-preferences.js
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this file still need to exist? It looks like the code was moved elsewhere.

Original file line number Diff line number Diff line change
@@ -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];
},
}
);
}
}
Loading