Skip to content

Commit

Permalink
Add user preference persistence framework (#1024)
Browse files Browse the repository at this point in the history
Add user preference framework, accessible via the currentUser resource.

Co-authored-by: Matthew White <[email protected]>
  • Loading branch information
brontolosone and matthew-white authored Nov 1, 2024
1 parent 99b11a8 commit e80cbb6
Show file tree
Hide file tree
Showing 8 changed files with 343 additions and 30 deletions.
59 changes: 39 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,12 @@ 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;
},
}
};
</script>
Expand All @@ -94,6 +107,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 */
}));

// Resources related to the system
Expand Down
48 changes: 48 additions & 0 deletions src/request-data/user-preferences/normalizer.js
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.


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;
}
}
Loading

0 comments on commit e80cbb6

Please sign in to comment.