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 serverside persisted user preference trashed form list collapsedness #1018

Closed
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
60 changes: 41 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,10 +64,14 @@ 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);
this.userPreferences.fetchOnce();
},
methods: {
fetchDeletedForms(resend) {
Expand All @@ -82,6 +91,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 +110,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: 11 additions & 5 deletions src/components/project/list.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ except according to the terms contained in the LICENSE file.
@click="createModal.show()">
<span class="icon-plus-circle"></span>{{ $t('action.create') }}&hellip;
</button>
<project-sort v-model="sortMode"/>
<project-sort v-model="sortMode" @update:model-value="onSortModeChange"/>
</template>
<template #body>
<div v-if="projects.dataExists">
Expand Down Expand Up @@ -93,9 +93,9 @@ export default {
},
inject: ['alert'],
setup() {
const { currentUser, projects } = useRequestData();
const { currentUser, projects, userPreferences } = useRequestData();

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

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

const { projectPath } = useRoutes();
return {
currentUser, projects,
currentUser, projects, userPreferences,
sortMode, sortFunction,
activeProjects, chunkyProjects,
createModal: modalData(),
Expand Down Expand Up @@ -159,12 +159,18 @@ export default {
return dsShown > 15 && limit > 3 ? limit - 1 : limit;
}
},
created() {
this.userPreferences.fetchOnce();
},
methods: {
afterCreate(project) {
const message = this.$t('alert.create');
this.$router.push(this.projectPath(project.id))
.then(() => { this.alert.success(message); });
}
},
onSortModeChange(sortMode) {
this.userPreferences.set('projectSortMode', sortMode);
},
}
};
</script>
Expand Down
48 changes: 47 additions & 1 deletion src/request-data/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { mergeDeepLeft } from 'ramda';

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

export default ({ i18n }, createResource) => {
// Resources related to the session
Expand Down Expand Up @@ -77,6 +78,51 @@ export default ({ i18n }, createResource) => {
transformResponse: ({ data }) => shallowReactive(data)
}));

createResource('userPreferences', (self) => ({
Copy link
Member Author

Choose a reason for hiding this comment

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

I think the key thing here will be to make userPreferences reactive. Some requestData resources don't need to be reactive at all, and others only need to be shallow-reactive. We want to avoid the overhead of reactivity where possible, so by default, a resource is not reactive: reactivity is opt-in. You can use transformResponse() to make a resource reactive.

By example, above, I don't think session is ever used in a reactive context, so it doesn't need to be reactive (I also don't think session is ever patched, only cleared). On the other hand, data about currentUser can be changed and is shown in a reactive context, so currentUser does need to be reactive.

Copy link
Contributor

Choose a reason for hiding this comment

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

Got it, see 6a864fd

transformResponse: ({ data }) => shallowReactive(data),
set: (k, v) => {
// Avoid posting prefs to the server when they haven't changed.
// This stringify-inequality-test may yield false positives, for instance when object field sort order changes.
// Thus superfluous requests are not 100% guaranteed to be filtered out, but we can live with that (it keeps things simple).
const haschanged = JSON.stringify(self.data[k]) !== JSON.stringify(v);
if (haschanged) {
// eslint-disable-next-line no-param-reassign
self[k] = v;
const headers = { 'Content-Type': 'application/json' };
self.request({
method: 'POST',
url: apiPaths.userPreferences(),
headers,
data: self.data,
alert: false,
patch: noop,
});
}
},
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