-
Notifications
You must be signed in to change notification settings - Fork 58
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
base: master
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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> | ||
|
||
|
@@ -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.dataExists && this.currentUser.preferences.projects[this.project.id].formTrashCollapsed); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Backend doesn't enforce that return this.currentUser.preferences.projects[this.project.id].formTrashCollapsed === true; There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think a more thorough and consistent approach could be to have a module that defines possible keys (ticks the bill for having a central registry of what config keys are being used, thus without having to infer that list from grepping around), and their "readers" (that provide a default when data is absent, or invalid), and their validators. Just some functions, basically, called by the proxy object upon value read/write. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That sounds quite nice to me! I think it's also something we could layer on in the future if it ends up feeling complicated to add now. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I also think it'd be OK if the proxy object only validated on read. If it's being written to by other code, that code should be doing its own validation. For example, the component There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's say on write it just validates whether the key is declared in the registry (serves the purpose of having a registry, so we have some grip on what settings are in use, this will also prevent inadvertent collisions), and on read it does the validation where it returns a sensible default if a value doesn't exist or doesn't pass muster. |
||
}, | ||
}, | ||
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; | ||
}, | ||
} | ||
}; | ||
</script> | ||
|
@@ -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; | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I understand the Backend correctly, it's possible for const { projectSortMode } = currentUser.preferences.site;
return typeof projectSortMode === 'string' ? projectSortMode : 'latest'; We could do even more validation, like checking that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Ah I'm realizing that const sortFunction = computed(() => sortFunctions[sortMode.value] ?? sortFunctions.latest); |
||
}, | ||
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); | ||
|
@@ -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> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) => { | ||
// 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; | ||
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) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @matthew-white the other week you mentioned you'd like to see the locking dance factored out, I'll have a shot at that soon. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I know I promised you some comments! I'll try to get those to you ASAP. I'm hoping that we can reuse some of our existing logic around sending and aborting requests in particular. And yeah, if there's a problem around locking, I suspect that will be relevant in places outside this code. It'd be awesome if you could file an issue describing the race condition you're thinking of, and then we can figure out how we want to address it (ideally in a generic way). I think the part I don't understand yet is why locking is necessary if we also have abort logic. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The outer locking serves for the use case you'd expect when you see locking: to protect a critical section (or at least, it looks like a critical section to me, I'm used to reasoning with threads but these are coroutines, so... maybe not? I still need to dive into that.) The inner locking serves as an easy bookkeeping way to determine whether there's another request (for the same grouping key) in flight. The nice thing is that a lock is guaranteed to be released (as soon as the request resolves or throws) with no further work from my side. So it saves me bookkeeping busywork-code. I forgot to say that I started out trying to modify the existing aborter implementation - basically I envisioned I'd adapt it so I could call it with a grouping key for my requests (by default initialized to When we have time we should walk through it together, that'd probably give the best mutual insights! |
||
// 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 */ | ||
})); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than expecting the full 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 // 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', | ||
|
@@ -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) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
currentUser
is guaranteed to exist at this point, so I think thethis.currentUser.dataExists
condition can be removed.