Users nav (#400)

* Add filters-to-query util

* Add all users string

* Suppot filters in tabular layout

* Sync filters with collection presets

* Support filters in use-items composition

* Render users nav + use skeleton loader

* Add list-item-icon type to skeleton loader

* Fix missing loader indicator on table

* Cleanup has click check

* Dont route based on id

* Fix loading state in table

* Revert "Dont route based on id"

This reverts commit 6de7cbe1b801d5e5e267f09a6e77dc73dcc60a37.

* Fix table loading state

* Fix routing for users module

* Force role field to be fetched

* Add roles store

* Dont render avatar until user is known

* Speed up hydration absurd much

* Rely on roles store to prevent nav from loading

* Fix tests
This commit is contained in:
Rijk van Zanten
2020-04-13 15:18:54 -04:00
committed by GitHub
parent a41824a95a
commit 7cba8a8de1
21 changed files with 314 additions and 35 deletions

View File

@@ -15,7 +15,7 @@ export const basic = () =>
defineComponent({
props: {
type: {
default: select('Type', ['input', 'input-tall'], 'input'),
default: select('Type', ['input', 'input-tall', 'list-item-icon'], 'input'),
},
},
template: `

View File

@@ -1,6 +1,11 @@
<template functional>
<transition name="fade">
<div class="v-skeleton-loader" :class="props.type" />
<div class="v-skeleton-loader" :class="props.type">
<template v-if="props.type === 'list-item-icon'">
<div class="icon" />
<div class="text" />
</template>
</div>
</transition>
</template>
@@ -12,6 +17,7 @@ export default defineComponent({
type: {
type: String,
default: 'input',
validator: (type: string) => ['input', 'input-tall', 'list-item-icon'].includes(type),
},
},
});
@@ -19,10 +25,18 @@ export default defineComponent({
<style lang="scss" scoped>
.v-skeleton-loader {
--v-skeleton-loader-color: var(--background-page);
--v-skeleton-loader-background-color: var(--background-subdued);
position: relative;
overflow: hidden;
background-color: var(--background-subdued);
cursor: progress;
}
@mixin loader {
position: relative;
overflow: hidden;
background-color: var(--v-skeleton-loader-background-color);
&::after {
position: absolute;
@@ -31,8 +45,14 @@ export default defineComponent({
left: 0;
z-index: 1;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
background: linear-gradient(
90deg,
transparent,
var(--v-skeleton-loader-color),
transparent
);
transform: translateX(-100%);
opacity: 0.5;
animation: loading 1.5s infinite;
content: '';
}
@@ -48,14 +68,41 @@ export default defineComponent({
.input-tall {
width: 100%;
height: var(--input-height);
border: var(--border-width) solid var(--background-subdued);
border: var(--border-width) solid var(--v-skeleton-loader-background-color);
border-radius: var(--border-radius);
@include loader;
}
.input-tall {
height: var(--input-height-tall);
}
.list-item-icon {
display: flex;
align-items: center;
width: 100%;
height: 46px;
.icon {
flex-shrink: 0;
width: 24px;
height: 24px;
margin-right: 12px;
border-radius: 50%;
@include loader;
}
.text {
flex-grow: 1;
height: 12px;
border-radius: 6px;
@include loader;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--medium) var(--transition);

View File

@@ -374,7 +374,7 @@ export default defineComponent({
&.sticky th {
position: sticky;
top: 48px;
z-index: +1;
z-index: 2;
}
}

View File

@@ -0,0 +1,6 @@
export type Filter = {
locked?: boolean;
field: string;
operator: string;
value: string | number;
};

View File

@@ -2,6 +2,8 @@ import useCollectionPresetStore from '@/stores/collection-presets';
import { ref, Ref, computed, watch } from '@vue/composition-api';
import { debounce } from 'lodash';
import { Filter } from './types';
export function useCollectionPreset(collection: Ref<string>) {
const collectionPresetsStore = useCollectionPresetStore();
@@ -48,5 +50,18 @@ export function useCollectionPreset(collection: Ref<string>) {
},
});
return { viewOptions, viewQuery };
const filters = computed<Filter[]>({
get() {
return localPreset.value.filters || [];
},
set(val) {
localPreset.value = {
...localPreset.value,
filters: val,
};
savePreset(localPreset.value);
},
});
return { viewOptions, viewQuery, filters };
}

View File

@@ -4,19 +4,22 @@ import useProjectsStore from '@/stores/projects';
import useCollection from '@/compositions/use-collection';
import Vue from 'vue';
import { isEqual } from 'lodash';
import { Filter } from '@/stores/collection-presets/types';
import filtersToQuery from '@/utils/filters-to-query';
type Options = {
limit: Ref<number>;
fields: Ref<readonly string[]>;
sort: Ref<string>;
page: Ref<number>;
filters: Ref<readonly Filter[]>;
};
export function useItems(collection: Ref<string>, options: Options) {
const projectsStore = useProjectsStore();
const { primaryKeyField } = useCollection(collection);
const { limit, fields, sort, page } = options;
const { limit, fields, sort, page, filters } = options;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const items = ref<any>([]);
@@ -61,7 +64,32 @@ export function useItems(collection: Ref<string>, options: Options) {
return;
}
/**
* @NOTE
* Ignore sorting through the API when the full set of items is already loaded. This requires
* layouts to support client side sorting when the itemcount is less than the total items.
*/
if (limit.value > (itemCount.value || 0)) return;
await Vue.nextTick();
if (loading.value === false) {
getItems();
}
});
watch([filters], async (after, before) => {
if (!before || isEqual(after, before)) {
return;
}
/**
* @NOTE
*
* When the filters change, we have to re-calculate the total amount of items too, as the
* total amount of available items is based on the filter query.
*/
itemCount.value = null;
await Vue.nextTick();
if (loading.value === false) {
getItems();
@@ -76,6 +104,7 @@ export function useItems(collection: Ref<string>, options: Options) {
const fieldsToFetch = [...fields.value];
// Make sure the primary key is always fetched
if (
fields.value !== ['*'] &&
primaryKeyField.value &&
@@ -84,6 +113,15 @@ export function useItems(collection: Ref<string>, options: Options) {
fieldsToFetch.push(primaryKeyField.value.field);
}
// Make sure all fields that are used to filter are fetched
if (fields.value !== ['*']) {
filters.value.forEach((filter) => {
if (fieldsToFetch.includes(filter.field) === false) {
fieldsToFetch.push(filter.field);
}
});
}
try {
const endpoint = collection.value.startsWith('directus_')
? `/${currentProjectKey}/${collection.value.substring(9)}`
@@ -95,6 +133,7 @@ export function useItems(collection: Ref<string>, options: Options) {
fields: fieldsToFetch,
sort: sort.value,
page: page.value,
...filtersToQuery(filters.value),
},
});

View File

@@ -8,6 +8,7 @@ import { useSettingsStore } from '@/stores/settings/';
import { useProjectsStore } from '@/stores/projects/';
import { useLatencyStore } from '@/stores/latency';
import { usePermissionsStore } from '@/stores/permissions';
import { useRolesStore } from '@/stores/roles';
type GenericStore = {
id: string;
@@ -28,6 +29,7 @@ export function useStores(
useProjectsStore,
useLatencyStore,
usePermissionsStore,
useRolesStore,
]
) {
return stores.map((useStore) => useStore()) as GenericStore[];
@@ -45,12 +47,15 @@ export async function hydrate(stores = useStores()) {
try {
/**
* @NOTE
* This will fetch the store data sequential. While this does prevent rate limiteres from
* kicking in, we could optimize it by running (some of) the requests in parallel
* Multiple stores rely on the userStore to be set, so they can fetch user specific data. The
* following makes sure that the user store is always fetched first, before we hydrate anything
* else.
*/
for (const store of stores) {
await store.hydrate?.();
}
await useUserStore().hydrate();
await Promise.all(
stores.filter(({ id }) => id !== 'userStore').map((store) => store.hydrate?.())
);
} catch (error) {
appStore.state.error = error;
} finally {

View File

@@ -177,6 +177,8 @@
"create_new_project_copy": "Make sure you have your database information handy, then enter your API's Super-Admin password to continue.",
"super_admin_token": "Super-Admin Token",
"all_users": "All Users",
"about_directus": "About Directus",
"activity_log": "Activity Log",

View File

@@ -101,6 +101,7 @@ import Draggable from 'vuedraggable';
import useCollection from '@/compositions/use-collection';
import useItems from '@/compositions/use-items';
import { render } from 'micromustache';
import { Filter } from '@/stores/collection-presets/types';
type ViewOptions = {
widths?: {
@@ -134,6 +135,10 @@ export default defineComponent({
type: Object as PropType<ViewQuery>,
default: null,
},
filters: {
type: Array as PropType<Filter[]>,
default: () => [],
},
selectMode: {
type: Boolean,
default: false,
@@ -152,6 +157,7 @@ export default defineComponent({
const _selection = useSync(props, 'selection', emit);
const _viewOptions = useSync(props, 'viewOptions', emit);
const _viewQuery = useSync(props, 'viewQuery', emit);
const _filters = useSync(props, 'filters', emit);
const { collection } = toRefs(props);
const { primaryKeyField, fields: fieldsInCollection } = useCollection(collection);
@@ -167,6 +173,7 @@ export default defineComponent({
limit,
page,
fields,
filters: _filters,
});
const {
@@ -369,6 +376,7 @@ export default defineComponent({
project: currentProjectKey.value,
collection: collection.value,
primaryKey,
item,
})
);
}

View File

@@ -1,3 +1,32 @@
<template>
<div>users nav</div>
<v-list nav>
<v-list-item to="/my-project/users/all">
<v-list-item-icon><v-icon name="people" /></v-list-item-icon>
<v-list-item-content>{{ $t('all_users') }}</v-list-item-content>
</v-list-item>
<v-list-item v-for="{ name, id } in roles" :key="id" :to="`/my-project/users/${id}`">
<v-list-item-icon><v-icon name="people" /></v-list-item-icon>
<v-list-item-content>{{ name }}</v-list-item-content>
</v-list-item>
</v-list>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import useRolesStore from '@/stores/roles';
export default defineComponent({
setup() {
const rolesStore = useRolesStore();
return { roles: rolesStore.state.roles };
},
});
</script>
<style lang="scss" scoped>
::v-deep .v-skeleton-loader {
--v-skeleton-loader-background-color: var(--background-normal-alt);
}
</style>

View File

@@ -1,4 +1,5 @@
import { defineModule } from '@/modules/define';
import UsersBrowse from './routes/browse/';
import UsersDetail from './routes/detail/';
@@ -8,14 +9,31 @@ export default defineModule(({ i18n }) => ({
icon: 'people',
routes: [
{
name: 'users-browse',
path: '/',
redirect: '/all',
},
{
name: 'users-browse-all',
path: '/all',
component: UsersBrowse,
},
{
name: 'users-detail-add-new',
path: '/+',
component: UsersDetail,
props: {
primaryKey: '+',
},
},
{
name: 'users-browse-role',
path: '/:role',
component: UsersBrowse,
props: true,
},
{
name: 'users-detail',
path: '/:primaryKey',
path: '/:role/:primaryKey',
component: UsersDetail,
props: true,
},

View File

@@ -56,7 +56,9 @@
:selection.sync="selection"
:view-options.sync="viewOptions"
:view-query.sync="viewQuery"
:detail-route="'/{{project}}/users/{{primaryKey}}'"
:detail-route="'/{{project}}/users/{{item.role}}/{{primaryKey}}'"
:filters="_filters"
@update:filters="filters = $event"
/>
</private-view>
</template>
@@ -78,18 +80,50 @@ type Item = {
export default defineComponent({
name: 'users-browse',
components: { UsersNavigation },
props: {},
setup() {
props: {
role: {
type: String,
default: null,
},
},
setup(props) {
const layout = ref<LayoutComponent>(null);
const projectsStore = useProjectsStore();
const selection = ref<Item[]>([]);
const { viewOptions, viewQuery } = useCollectionPreset(ref('directus_users'));
const { viewOptions, viewQuery, filters } = useCollectionPreset(ref('directus_users'));
const { addNewLink, batchLink } = useLinks();
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
const { breadcrumb } = useBreadcrumb();
const _filters = computed(() => {
if (props.role !== null) {
return [
{
locked: 1,
field: 'role',
operator: 'eq',
value: props.role,
},
...filters.value,
];
}
return [
// This filter is basically a no-op. Every user has a role. However, by filtering on
// this field, we can ensure that the field data is fetched, which is needed to build
// out the navigation links
{
locked: 1,
field: 'role',
operator: 'nnull',
value: 1,
},
...filters.value,
];
});
return {
addNewLink,
batchLink,
@@ -101,6 +135,7 @@ export default defineComponent({
layout,
viewOptions,
viewQuery,
_filters,
};
function useBatchDelete() {

View File

@@ -0,0 +1,4 @@
import { useRolesStore } from './roles';
export { useRolesStore };
export default useRolesStore;

25
src/stores/roles/roles.ts Normal file
View File

@@ -0,0 +1,25 @@
import { createStore } from 'pinia';
import api from '@/api';
import { useProjectsStore } from '@/stores/projects';
import { Role } from './types';
export const useRolesStore = createStore({
id: 'rolesStore',
state: () => ({
roles: [] as Role[],
}),
actions: {
async hydrate() {
const projectsStore = useProjectsStore();
const currentProjectKey = projectsStore.state.currentProjectKey;
const rolesResponse = await api.get(`/${currentProjectKey}/roles`);
this.state.roles = rolesResponse.data.data;
},
async dehydrate() {
this.reset();
},
},
});

10
src/stores/roles/types.ts Normal file
View File

@@ -0,0 +1,10 @@
export type Role = {
id: number;
name: string;
description: string;
collection_listing: null;
module_listing: null;
enforce_2fa: null | boolean;
external_id: null | string;
ip_whitelist: string[];
};

View File

@@ -1,3 +1,5 @@
import { Role } from '@/stores/roles/types';
export type Avatar = {
data: {
thumbnails: Thumbnail[];
@@ -24,16 +26,7 @@ export type User = {
external_id: string;
'2fa_secret': string;
theme: 'auto' | 'dark' | 'light';
role: {
id: number;
name: string;
description: string;
collection_listing: null;
module_listing: null;
enforce_2fa: null | boolean;
external_id: null | string;
ip_whitelist: string[];
};
role: Role;
password_reset_token: string | null;
timezone: string;
locale: string;

View File

@@ -0,0 +1,12 @@
import { Filter } from '@/stores/collection-presets/types';
export default function filtersToQuery(filters: readonly Filter[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const query: Record<string, any> = {};
filters.forEach((filter) => {
query[`filter[${filter.field}][${filter.operator}]`] = filter.value;
});
return query;
}

View File

@@ -0,0 +1,4 @@
import filtersToQuery from './filters-to-query';
export { filtersToQuery };
export default filtersToQuery;

View File

@@ -0,0 +1,27 @@
# Filters to Query
Converts an array of filter objects to an Axios compatible object of query params.
## Usage
```ts
const filters: Filter[] = [
{
field: 'title',
operator: 'contains',
value: 'directus',
},
{
field: 'author',
operator: 'eq',
value: 1,
},
];
filtersToQuery(filters);
// {
// 'filter[title][contains]': 'directus',
// 'filter[author][eq]: 1
// }
```

View File

@@ -127,6 +127,7 @@ describe('Views / Private / Module Bar Avatar', () => {
userStore.state.currentUser = {
id: 1,
avatar: null,
role: { id: 15 },
} as any;
const component = shallowMount(ModuleBarAvatar, {
@@ -137,7 +138,7 @@ describe('Views / Private / Module Bar Avatar', () => {
},
});
expect((component.vm as any).userProfileLink).toBe('/my-project/users/1');
expect((component.vm as any).userProfileLink).toBe('/my-project/users/15/1');
expect((component.vm as any).signOutLink).toBe('/my-project/logout');
});
});

View File

@@ -52,11 +52,10 @@ export default defineComponent({
const userProfileLink = computed<string>(() => {
const project = projectsStore.state.currentProjectKey;
// This is rendered in the private view, which is only accessible as a logged in user
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const id = userStore.state.currentUser!.id;
const id = userStore.state.currentUser?.id;
const role = userStore.state.currentUser?.role?.id;
return `/${project}/users/${id}`;
return `/${project}/users/${role}/${id}`;
});
const signOutLink = computed<string>(() => {