From 7cba8a8de1f60e05d0eba71de08081f56134b1bf Mon Sep 17 00:00:00 2001 From: Rijk van Zanten Date: Mon, 13 Apr 2020 15:18:54 -0400 Subject: [PATCH] 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 --- .../v-skeleton-loader.story.ts | 2 +- .../v-skeleton-loader/v-skeleton-loader.vue | 55 +++++++++++++++++-- src/components/v-table/v-table.vue | 2 +- .../use-collection-preset/types.ts | 6 ++ .../use-collection-preset.ts | 17 +++++- src/compositions/use-items/use-items.ts | 41 +++++++++++++- src/hydrate.ts | 15 +++-- src/lang/en-US/index.json | 2 + src/layouts/tabular/tabular.vue | 8 +++ .../components/navigation/navigation.vue | 31 ++++++++++- src/modules/users/index.ts | 22 +++++++- src/modules/users/routes/browse/browse.vue | 43 +++++++++++++-- src/stores/roles/index.ts | 4 ++ src/stores/roles/roles.ts | 25 +++++++++ src/stores/roles/types.ts | 10 ++++ src/stores/user/types.ts | 13 +---- .../filters-to-query/filters-to-query.ts | 12 ++++ src/utils/filters-to-query/index.ts | 4 ++ src/utils/filters-to-query/readme.md | 27 +++++++++ .../module-bar-avatar.test.ts | 3 +- .../module-bar-avatar/module-bar-avatar.vue | 7 +-- 21 files changed, 314 insertions(+), 35 deletions(-) create mode 100644 src/compositions/use-collection-preset/types.ts create mode 100644 src/stores/roles/index.ts create mode 100644 src/stores/roles/roles.ts create mode 100644 src/stores/roles/types.ts create mode 100644 src/utils/filters-to-query/filters-to-query.ts create mode 100644 src/utils/filters-to-query/index.ts create mode 100644 src/utils/filters-to-query/readme.md diff --git a/src/components/v-skeleton-loader/v-skeleton-loader.story.ts b/src/components/v-skeleton-loader/v-skeleton-loader.story.ts index 9b58e5a2fc..dad0f09bc3 100644 --- a/src/components/v-skeleton-loader/v-skeleton-loader.story.ts +++ b/src/components/v-skeleton-loader/v-skeleton-loader.story.ts @@ -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: ` diff --git a/src/components/v-skeleton-loader/v-skeleton-loader.vue b/src/components/v-skeleton-loader/v-skeleton-loader.vue index 3056f29425..ccbe5e2c11 100644 --- a/src/components/v-skeleton-loader/v-skeleton-loader.vue +++ b/src/components/v-skeleton-loader/v-skeleton-loader.vue @@ -1,6 +1,11 @@ @@ -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({ diff --git a/src/modules/users/index.ts b/src/modules/users/index.ts index af35eef352..b401a8c5e5 100644 --- a/src/modules/users/index.ts +++ b/src/modules/users/index.ts @@ -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, }, diff --git a/src/modules/users/routes/browse/browse.vue b/src/modules/users/routes/browse/browse.vue index 716e4f67fb..c01b13fe20 100644 --- a/src/modules/users/routes/browse/browse.vue +++ b/src/modules/users/routes/browse/browse.vue @@ -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" /> @@ -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(null); const projectsStore = useProjectsStore(); const selection = ref([]); - 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() { diff --git a/src/stores/roles/index.ts b/src/stores/roles/index.ts new file mode 100644 index 0000000000..3f21615292 --- /dev/null +++ b/src/stores/roles/index.ts @@ -0,0 +1,4 @@ +import { useRolesStore } from './roles'; + +export { useRolesStore }; +export default useRolesStore; diff --git a/src/stores/roles/roles.ts b/src/stores/roles/roles.ts new file mode 100644 index 0000000000..9cfa79fcf6 --- /dev/null +++ b/src/stores/roles/roles.ts @@ -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(); + }, + }, +}); diff --git a/src/stores/roles/types.ts b/src/stores/roles/types.ts new file mode 100644 index 0000000000..0173bc7cd9 --- /dev/null +++ b/src/stores/roles/types.ts @@ -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[]; +}; diff --git a/src/stores/user/types.ts b/src/stores/user/types.ts index 1dcd1b7dc9..4df7cce301 100644 --- a/src/stores/user/types.ts +++ b/src/stores/user/types.ts @@ -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; diff --git a/src/utils/filters-to-query/filters-to-query.ts b/src/utils/filters-to-query/filters-to-query.ts new file mode 100644 index 0000000000..b6375911b1 --- /dev/null +++ b/src/utils/filters-to-query/filters-to-query.ts @@ -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 = {}; + + filters.forEach((filter) => { + query[`filter[${filter.field}][${filter.operator}]`] = filter.value; + }); + + return query; +} diff --git a/src/utils/filters-to-query/index.ts b/src/utils/filters-to-query/index.ts new file mode 100644 index 0000000000..8c8a1b983f --- /dev/null +++ b/src/utils/filters-to-query/index.ts @@ -0,0 +1,4 @@ +import filtersToQuery from './filters-to-query'; + +export { filtersToQuery }; +export default filtersToQuery; diff --git a/src/utils/filters-to-query/readme.md b/src/utils/filters-to-query/readme.md new file mode 100644 index 0000000000..41f63fa46f --- /dev/null +++ b/src/utils/filters-to-query/readme.md @@ -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 +// } +``` diff --git a/src/views/private/components/module-bar-avatar/module-bar-avatar.test.ts b/src/views/private/components/module-bar-avatar/module-bar-avatar.test.ts index 65ef6a120d..d51941d22d 100644 --- a/src/views/private/components/module-bar-avatar/module-bar-avatar.test.ts +++ b/src/views/private/components/module-bar-avatar/module-bar-avatar.test.ts @@ -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'); }); }); diff --git a/src/views/private/components/module-bar-avatar/module-bar-avatar.vue b/src/views/private/components/module-bar-avatar/module-bar-avatar.vue index 62e90d0aed..b641a2e3c6 100644 --- a/src/views/private/components/module-bar-avatar/module-bar-avatar.vue +++ b/src/views/private/components/module-bar-avatar/module-bar-avatar.vue @@ -52,11 +52,10 @@ export default defineComponent({ const userProfileLink = computed(() => { 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(() => {