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 3 commits into
base: master
Choose a base branch
from

Conversation

brontolosone
Copy link
Contributor

Towards getodk/central#689
Supersedes #1021 which superseded #1018
Related: getodk/central-backend#1184 (backend)

This introduces persistent user preferences as per getodk/central#689.

They are exposed on the currentUser resource as currentUser.preferences, split up in site and projects objects. These objects are proxied to enable some — hopefully comfortable — abstractions for working with preferences, eg currentUser.preferences.projects[someProjectID].someproperty = "camembert" will work even if projects[someProjectId] doesn't actually exist yet (it will be autocreated).
The PUT and DELETE HTTP requests necessary for propagating values to the backend are implemented as side effects via these proxy objects, too.

The semantics of the preferences values are completely up to the frontend, but the reactiveness enabled is shallow, thus complex preference values (eg objects, arrays) are discouraged — those are inconvenient anyway; more granular key-value pairs are preferred as those will result in less clobbering under concurrent activity from multiple sessions.

Implemented preferences:

  • Project listing sort order
  • Per-project deleted forms hiding (trash collapsed/expanded state)

Todo:

  • fixup existing tests
  • add tests for the new functionality
  • create end-user documentation on potential surprises when one loads preferences which have been merged from multiple sessions

For later:

  • Use named constants as preference keys, and validate the keys (clientside). The file of constants will serve as a versioned registry of what settings are in use (and through git, what settings have been in use over time), this decreases the potential for confusion (such as disparate corners of the frontend using the same preference key for different purposes).

Part of the currentUser resource.
Uses JS proxies to enable some — hopefully comfortable — abstractions
for working with preferences.
_container,
abortControllers: {},
instanceID: crypto.randomUUID(),
propagate: (k, v, projectId) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think the part I don't understand yet is why locking is necessary if we also have abort logic.

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 'default', thus backwards compatible) and then use an object {grouping_key: aborter} for bookkeeping of the aborters, as now you could have N rather than just 0 or 1.
I honestly tried ;-) but it resisted my attemps at malling it (not the code's fault, it's just made for a different use case — there's a big difference between 0|1 and N). And then I thought "hmmm this bookkeeping could be more care-free anyway using locks", plus I thought I saw a race condition, ... and here we are.

When we have time we should walk through it together, that'd probably give the best mutual insights!

Copy link
Member

@matthew-white matthew-white left a comment

Choose a reason for hiding this comment

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

I still haven't had a chance to write up my idea about reusing existing request logic, but I had a few unrelated comments that I thought I could share sooner.

I mentioned this before, but I really like how the currentUser.preferences proxy works. 👏 Sending requests under the hood, using shallow reactivity, and returning a nice default even when the user doesn't have any preferences for a specific project. 👍

const sortMode = ref('latest');
const sortMode = computed({
get() {
return currentUser.preferences.site.projectSortMode || 'latest';
Copy link
Member

Choose a reason for hiding this comment

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

If I understand the Backend correctly, it's possible for projectSortMode to be made something other than a string, right? The ProjectSort component will be expecting a string. What do you think about doing a type check here? Something like:

const { projectSortMode } = currentUser.preferences.site;
return typeof projectSortMode === 'string' ? projectSortMode : 'latest';

We could do even more validation, like checking that projectSortMode is one of 'alphabetical', 'latest', or 'newest'. But I don't think that's needed at this point given how the ProjectSort component uses its modelValue prop. I think the <select> element will just display the first <option> if it's passed a modelValue that isn't among the <option>s.

Copy link
Member

Choose a reason for hiding this comment

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

We could do even more validation. … But I don't think that's needed at this point given how the ProjectSort component uses its modelValue prop.

Ah I'm realizing that sortMode is also used to define sortFunction. If projectSortMode is some unknown/invalid string, then sortFunction.value will be undefined, which I'd guess would cause an error. Maybe sortFunction could also do some fallback?

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

}
},
isFormTrashCollapsed() {
return (this.currentUser.dataExists && this.currentUser.preferences.projects[this.project.id].formTrashCollapsed);
Copy link
Member

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 the this.currentUser.dataExists condition can be removed.

}
},
isFormTrashCollapsed() {
return (this.currentUser.dataExists && this.currentUser.preferences.projects[this.project.id].formTrashCollapsed);
Copy link
Member

Choose a reason for hiding this comment

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

Backend doesn't enforce that formTrashCollapsed is boolean, right? How about converting it to boolean here?

return this.currentUser.preferences.projects[this.project.id].formTrashCollapsed === true;

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.
That would avoid having to write repetitive (and not necessarily consistent) defensive code in each and every usage site.
I'll get on it.

Copy link
Member

@matthew-white matthew-white Oct 1, 2024

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

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

called by the proxy object upon value read/write

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 ProjectSort will change the projectSortMode preference, but the component is limited to a set of <option>s. No strong preference by me, just wanted to throw that out as a potentially simplifying option.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Comment on lines +28 to +31
preferences = {
site: {},
projects: {},
},
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants