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 2 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="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.dataExists && this.currentUser.preferences.projects[this.project.id].formTrashCollapsed);
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
},
},
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;
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
12 changes: 10 additions & 2 deletions src/components/project/list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,15 @@ export default {
setup() {
const { currentUser, projects } = useRequestData();

const sortMode = ref('latest');
const sortMode = computed({
get() {
return currentUser.preferences.site.projectSortMode || 'latest';
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
},
set(val) {
currentUser.preferences.site.projectSortMode = val;
},
});
matthew-white marked this conversation as resolved.
Show resolved Hide resolved

const sortFunction = computed(() => sortFunctions[sortMode.value]);

const activeProjects = ref(null);
Expand Down Expand Up @@ -164,7 +172,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');
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
const _abortController = Symbol('abortController');
class Resource extends BaseResource {
constructor(container, name, store) {
Expand Down
114 changes: 112 additions & 2 deletions src/request-data/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,132 @@ 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 { computed, reactive, shallowReactive, watchSyncEffect } from 'vue';
import { computed, reactive, shallowReactive, watchSyncEffect, isReactive } from 'vue';
import { mergeDeepLeft } from 'ramda';

import configDefaults from '../config';
import { computeIfExists, hasVerbs, setupOption, transformForm } from './util';
import { noargs } from '../util/util';
import { apiPaths, withAuth } from '../util/request';
import { _container } from './resource';

export default ({ i18n }, createResource) => {
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
// Resources related to the session
createResource('session');
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
createResource('currentUser', () => ({
createResource('currentUser', (self) => ({
/* eslint-disable no-param-reassign */
transformResponse: ({ data }) => {
data.verbs = new Set(data.verbs);
data.can = hasVerbs;
data.preferences = {
site: new Proxy(
shallowReactive(data.preferences.site),
{
deleteProperty(target, prop) {
const retval = (delete target[prop]);
self.preferenceOps.propagate(prop, null, null); // DELETE to backend
return retval;
},
set(target, prop, value) {
// eslint-disable-next-line no-multi-assign
const retval = (target[prop] = value);
self.preferenceOps.propagate(prop, value, null); // PUT to backend
return retval;
},
},
),
projects: new Proxy(
data.preferences.projects,
{
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) {
const retval = (delete from[prop]);
self.preferenceOps.propagate(prop, null, projectId); // DELETE to backend
return retval;
},
set(from, prop, propval) {
// eslint-disable-next-line no-multi-assign
const retval = (from[prop] = propval);
self.preferenceOps.propagate(prop, propval, projectId); // PUT to backend
return retval;
},
}
);
}
return target[projectId];
},
}
),
};
return shallowReactive(data);
},
preferenceOps: {
self,
_container,
abortControllers: {},
instanceID: crypto.randomUUID(),
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
propagate: (k, v, projectId) => {
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
// 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-${self.instanceID}-keystack-${projectId}-${k}`;
navigator.locks.request(
`userPreferences-${self.instanceID}-lockops`,
() => {
navigator.locks.request(
keyLockName,
{ ifAvailable: true },
(lockForKey) => {
const aborter = new AbortController();
if (!lockForKey) {
// Cancel the preceding request, a new one supersedes it.
self.preferenceOps.abortControllers[k].abort();
return navigator.locks.request(
keyLockName,
() => {
self.preferenceOps.abortControllers[k] = aborter;
return self.preferenceOps.request(k, v, projectId, aborter);
}
);
}
self.preferenceOps.abortControllers[k] = aborter;
return self.preferenceOps.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) => {
const { requestData, http } = self[self.preferenceOps._container];
return 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,
},
requestData.session.token
)
);
},
}
/* eslint-enable no-param-reassign */
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
}));
Expand Down
4 changes: 3 additions & 1 deletion src/util/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
brontolosone marked this conversation as resolved.
Show resolved Hide resolved
};


Expand Down
9 changes: 7 additions & 2 deletions test/data/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ export const extendedUsers = dataStore({
role = 'admin',
verbs = verbsByRole(role),
createdAt = undefined,
deletedAt = undefined
deletedAt = undefined,
preferences = {
site: {},
projects: {},
},
Comment on lines +28 to +31
Copy link
Member

Choose a reason for hiding this comment

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

Rather than expecting the full preferences object here, maybe it would be useful to allow tests to specify a partial object, then merge that partial object with the full object below. So here, it would be:

preferences = {},

And below, it would be:

// mergeDeepLeft() is from ramda
preferences: mergeDeepLeft(preferences, {
  site: {},
  projects: {}
}),

That would allow a test to easily specify a site preference without also specifying projects: {}, e.g., something like:

// test/components/project/list.spec.js
it('uses the projectSortMode preference', () => {
  // Specifying site without projects.
  mockLogin({ preferences: { site: { projectSortMode: 'alphabetical' } } });
  createProjects([
    { name: 'A' },
    { name: 'B', lastSubmission: ago({ days: 5 }).toISO() }
  ]);
  const component = mountComponent();

  const blocks = mountComponent().findAllComponents(ProjectHomeBlock);
  // ['B', 'A'] is the default, as B has the latest data.
  blocks.map(block => block.props().project.name).should.eql(['A', 'B']);

  const select = component.getComponent(ProjectSort);
  select.props().modelValue.should.equal('alphabetical');
});

Just an idea! Could also come later once tests are added.

}) => ({
id,
type: 'user',
Expand All @@ -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)
Expand Down