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

Merged
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
edb8b8e
Add user preference framework
brontolosone Aug 8, 2024
53d422e
add empty user preferences to user testdata
brontolosone Sep 19, 2024
58c09ad
account for potentially missing preferences
brontolosone Oct 1, 2024
4467c8b
Merge branch 'master' into central_689-userpreferences-rb-02
brontolosone Oct 7, 2024
4bb37a6
factor out UserPreferences
brontolosone Oct 7, 2024
35b03d6
add centralized preference registration/validation/defaultization, an…
brontolosone Oct 7, 2024
d1b6cfa
remove superfluous check
brontolosone Oct 7, 2024
69bafe5
remove V8-specific stack trace fiddling
brontolosone Oct 10, 2024
0f8404b
modularize normalizers for easy maintenance and mutation tracking
brontolosone Oct 10, 2024
2022da7
post-PR fixups & cleanups
brontolosone Oct 28, 2024
ed97d9e
Omit preferences from testData.standardUsers
matthew-white Oct 29, 2024
24b1a3d
post-PR fixups & cleanups 2
brontolosone Oct 30, 2024
781068f
naming
brontolosone Oct 31, 2024
392ab0d
add © headers
brontolosone Oct 31, 2024
2f81fcc
cleanup
brontolosone Oct 31, 2024
5820632
post-PR fixups & cleanups 3
brontolosone Oct 31, 2024
d5ca856
Merge remote-tracking branch 'refs/remotes/origin/central_689-userpre…
brontolosone Oct 31, 2024
827891d
sweep it under the rug
brontolosone Oct 31, 2024
f1ad9ac
stricter int check
brontolosone Oct 31, 2024
01dbdab
caller doesn't get to instrument the superclass constructor.
brontolosone Oct 31, 2024
56c3802
Merge remote-tracking branch 'refs/remotes/origin/central_689-userpre…
brontolosone Oct 31, 2024
636061e
unbrace yourselves
brontolosone Oct 31, 2024
5469c1d
last stray
brontolosone Nov 1, 2024
31e7319
document user preferences mechanism
brontolosone Nov 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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="onToggleTrashExpansion">
<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');
}
},
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;
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
},
}
};
</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
10 changes: 6 additions & 4 deletions src/request-data/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
}));

// Resources related to the system
Expand Down
48 changes: 48 additions & 0 deletions src/request-data/user-preferences/normalizer.js
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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.
matthew-white marked this conversation as resolved.
Show resolved Hide resolved


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];
}
}
42 changes: 42 additions & 0 deletions src/request-data/user-preferences/normalizers.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
151 changes: 151 additions & 0 deletions src/request-data/user-preferences/preferences.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
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';


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
const retval = (delete target[prop]);
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
userPreferences.#propagate(prop, null, null); // DELETE to backend
return retval;
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
},
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];
},
}
);
}
}
Loading