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

Serverside-persisted user preferences #1021

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions src/components/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ export default {

const { features } = useFeatureFlags();

const { centralVersion } = useRequestData();
const { centralVersion, userPreferences } = useRequestData();
const { callWait } = useCallWait();
return { visiblyLoggedIn, centralVersion, callWait, features };
return { visiblyLoggedIn, centralVersion, userPreferences, callWait, features };
},
computed: {
routerReady() {
Expand Down
59 changes: 40 additions & 19 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, userPreferences } = useRequestData();
return { project, deletedForms, userPreferences, 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.userPreferences.dataExists && (this.userPreferences.formTrashCollapsed || []).includes(this.project.id));
},
},
created() {
this.fetchDeletedForms(false);
Expand All @@ -82,6 +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) {
this.userPreferences.mutateSet(
'formTrashCollapsed',
this.project.id,
(evt.newState === 'closed') ? 'add' : 'delete',
);
}
}
};
Expand All @@ -94,6 +109,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
16 changes: 12 additions & 4 deletions src/components/project/list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,17 @@ export default {
},
inject: ['alert'],
setup() {
const { currentUser, projects } = useRequestData();
const { currentUser, projects, userPreferences } = useRequestData();

const sortMode = computed({
get() {
return userPreferences.projectSortMode || 'latest';
},
set(val) {
userPreferences.set('projectSortMode', val);
},
});

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

const activeProjects = ref(null);
Expand All @@ -109,7 +117,7 @@ export default {

const { projectPath } = useRoutes();
return {
currentUser, projects,
currentUser, projects, userPreferences,
sortMode, sortFunction,
activeProjects, chunkyProjects,
createModal: modalData(),
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');
const _abortController = Symbol('abortController');
class Resource extends BaseResource {
constructor(container, name, store) {
Expand Down
89 changes: 89 additions & 0 deletions src/request-data/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ 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
Expand Down Expand Up @@ -77,6 +79,93 @@ export default ({ i18n }, createResource) => {
transformResponse: ({ data }) => shallowReactive(data)
}));

createResource('userPreferences', (self) => ({
_container,
abortControllers: {},
instanceID: crypto.randomUUID(),
transformResponse: ({ data }) => shallowReactive(data),
patchServerside: (k, v) => {
// As we need to be able to have multiple requests in-flight, 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-${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.abortControllers[k].abort();
return navigator.locks.request(
keyLockName,
() => {
// eslint-disable-next-line no-param-reassign
self.abortControllers[k] = aborter;
return self.requestPatchServerside(k, v, aborter);
}
);
}
// eslint-disable-next-line no-param-reassign
self.abortControllers[k] = aborter;
return self.requestPatchServerside(k, v, 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.
}
);
},
requestPatchServerside: (k, v, aborter) => {
const { requestData, http } = self[self._container];
return http.request(
withAuth(
{
method: 'PATCH',
url: apiPaths.userPreferences(),
headers: {
'Content-Type': 'application/json',
'X-Extended-Metadata': 'true',
},
data: Object.fromEntries(new Map([[k, v]])),
signal: aborter.signal,
},
requestData.session.token
)
);
},
set: (k, v, propagate = true) => {
// eslint-disable-next-line no-param-reassign
self[k] = v;
if (propagate) return self.patchServerside(k, v);
return null;
},
mutateSet: (k, v, op) => {
const prefSet = new Set(self.data[k] instanceof Array ? self.data[k] : []); // ignore/overwrite set-incompatible data (as may have been left behind by an older version with a different implicit preferences schema)
switch (op) {
case 'add':
prefSet.add(v);
break;
case 'delete':
prefSet.delete(v);
break;
default:
throw new Error(`Unsupported set operation: "${op}"`);
}
self.set(k, Array.from(prefSet).sort());
},
addToSet: (k, v) => self.mutateSet(k, v, 'add'),
deleteFromSet: (k, v) => self.mutateSet(k, v, 'delete'),
fetchOnce: () => {
if (!self.dataExists) self.request({
url: apiPaths.userPreferences(),
resend: false,
});
},
}));

const formDraft = createResource('formDraft', () =>
setupOption(data => shallowReactive(transformForm(data))));

Expand Down
1 change: 1 addition & 0 deletions src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,7 @@ const routesByName = new Map();
currentUser,
config,
requestData.centralVersion,
requestData.userPreferences,
requestData.analyticsConfig,
requestData.roles
]);
Expand Down
3 changes: 2 additions & 1 deletion src/util/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ 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)}`,
userPreferences: () => '/v1/user-preferences/current'
};


Expand Down
25 changes: 15 additions & 10 deletions src/util/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ the request for the current user should result in an error. If there is a logout
during a request to create a session, then the new session will be used. */
export const logIn = (container, newSession) => {
const { requestData, config } = container;
const { session, currentUser, analyticsConfig } = requestData;
const { session, currentUser, analyticsConfig, userPreferences } = requestData;
if (newSession) {
/*
If two tabs submit the login form at the same time, then both will end up
Expand Down Expand Up @@ -277,13 +277,18 @@ export const logIn = (container, newSession) => {
throw error;
});
})
.then(() => {
if (config.showsAnalytics && currentUser.can('config.read')) {
analyticsConfig.request({
url: '/v1/config/analytics',
fulfillProblem: ({ code }) => code === 404.1,
alert: false
}).catch(noop);
}
});
.then(
Promise.all([
userPreferences.fetchOnce(),
() => {
if (config.showsAnalytics && currentUser.can('config.read')) {
analyticsConfig.request({
url: '/v1/config/analytics',
fulfillProblem: ({ code }) => code === 404.1,
alert: false
}).catch(noop);
}
},
])
);
};