Add users module (#360)

* Install micromustache

* Add useCollectionPreset composition

* Add detailRoute prop to layouts

Allows for overriding where the detail view is located from the parent

* Add locale translations for users/files/activity

* Update collections module to use new composition / layout prop

* Update useItem useItems to allow for directus_ collections

* Add default width to all fields with no width

* Only fetch comment,create,update,delete activity on detail

* Fix out-transition on sign-out button

* Add users module
This commit is contained in:
Rijk van Zanten
2020-04-08 12:35:36 -04:00
committed by GitHub
parent 7aa864c375
commit a9bfa469d9
21 changed files with 539 additions and 114 deletions

View File

@@ -25,6 +25,7 @@
"date-fns": "^2.11.1",
"lodash": "^4.17.15",
"marked": "^0.8.2",
"micromustache": "^7.1.0",
"nanoid": "^3.0.2",
"pinia": "0.0.5",
"portal-vue": "^2.1.7",

View File

@@ -190,6 +190,15 @@ export default defineComponent({
return a.sort > b.sort ? 1 : -1;
});
// Make sure all form fields have a width associated with it
formFields = formFields.map((field) => {
if (!field.width) {
field.width = 'full';
}
return field;
});
// Change the class to half-right if the current element is preceded by another half width field
// this makes them align side by side
formFields = formFields.map((field, index, formFields) => {

View File

@@ -0,0 +1,4 @@
import { useCollectionPreset } from './use-collection-preset';
export { useCollectionPreset };
export default useCollectionPreset;

View File

@@ -0,0 +1,52 @@
import useCollectionPresetStore from '@/stores/collection-presets';
import { ref, Ref, computed, watch } from '@vue/composition-api';
import { debounce } from 'lodash';
export function useCollectionPreset(collection: Ref<string>) {
const collectionPresetsStore = useCollectionPresetStore();
const savePreset = debounce(collectionPresetsStore.savePreset, 450);
const localPreset = ref({
...collectionPresetsStore.getPresetForCollection(collection.value),
});
watch(collection, () => {
localPreset.value = {
...collectionPresetsStore.getPresetForCollection(collection.value),
};
});
const viewOptions = computed({
get() {
return localPreset.value.view_options?.[localPreset.value.view_type] || null;
},
set(val) {
localPreset.value = {
...localPreset.value,
view_options: {
...localPreset.value.view_options,
[localPreset.value.view_type]: val,
},
};
savePreset(localPreset.value);
},
});
const viewQuery = computed({
get() {
return localPreset.value.view_query?.[localPreset.value.view_type] || null;
},
set(val) {
localPreset.value = {
...localPreset.value,
view_query: {
...localPreset.value.view_query,
[localPreset.value.view_type]: val,
},
};
savePreset(localPreset.value);
},
});
return { viewOptions, viewQuery };
}

View File

@@ -20,6 +20,13 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
() => typeof primaryKey.value === 'string' && primaryKey.value.includes(',')
);
const endpoint = computed(() => {
const currentProjectKey = useProjectsStore().state.currentProjectKey;
return collection.value.startsWith('directus_')
? `/${currentProjectKey}/${collection.value.substring(9)}`
: `/${currentProjectKey}/items/${collection.value}`;
});
watch([collection, primaryKey], refresh);
return {
@@ -38,13 +45,10 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
};
async function getItem() {
const currentProjectKey = useProjectsStore().state.currentProjectKey;
loading.value = true;
try {
const response = await api.get(
`/${currentProjectKey}/items/${collection.value}/${primaryKey.value}`
);
const response = await api.get(`${endpoint.value}/${primaryKey.value}`);
setItemValueToResponse(response);
} catch (err) {
@@ -55,17 +59,13 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
}
async function save() {
const currentProjectKey = useProjectsStore().state.currentProjectKey;
saving.value = true;
try {
let response;
if (isNew.value === true) {
response = await api.post(
`/${currentProjectKey}/items/${collection.value}`,
edits.value
);
response = await api.post(endpoint.value, edits.value);
notify({
title: i18n.tc('item_create_success', isBatch.value ? 2 : 1),
@@ -78,10 +78,7 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
type: 'success',
});
} else {
response = await api.patch(
`/${currentProjectKey}/items/${collection.value}/${primaryKey.value}`,
edits.value
);
response = await api.patch(`${endpoint.value}/${primaryKey.value}`, edits.value);
notify({
title: i18n.tc('item_update_success', isBatch.value ? 2 : 1),
@@ -129,7 +126,6 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
}
async function saveAsCopy() {
const currentProjectKey = useProjectsStore().state.currentProjectKey;
saving.value = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -144,10 +140,7 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
}
try {
const response = await api.post(
`/${currentProjectKey}/items/${collection.value}`,
newItem
);
const response = await api.post(endpoint.value, newItem);
notify({
title: i18n.t('item_create_success'),
@@ -180,11 +173,10 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
}
async function remove() {
const currentProjectKey = useProjectsStore().state.currentProjectKey;
deleting.value = true;
try {
await api.delete(`/${currentProjectKey}/items/${collection.value}/${primaryKey.value}`);
await api.delete(`${endpoint.value}/${primaryKey.value}`);
item.value = null;

View File

@@ -85,7 +85,11 @@ export function useItems(collection: Ref<string>, options: Options) {
}
try {
const response = await api.get(`/${currentProjectKey}/items/${collection.value}`, {
const endpoint = collection.value.startsWith('directus_')
? `/${currentProjectKey}/${collection.value.substring(9)}`
: `/${currentProjectKey}/items/${collection.value}`;
const response = await api.get(endpoint, {
params: {
limit: limit.value,
fields: fieldsToFetch,

View File

@@ -118,6 +118,10 @@
"item_count": "No Items | One Item | {count} Items",
"users": "Users",
"files": "Files",
"Activity": "Activity",
"about_directus": "About Directus",
"activity": "Activity",
@@ -165,12 +169,6 @@
"collection_updated": "Collection Updated",
"collections_and_fields": "Collection & Fields",
"collections": {
"directus_activity": "Activity",
"directus_files": "Files",
"directus_users": "Users"
},
"fields": {
"directus_activity": {
"action": "Action",

View File

@@ -100,6 +100,7 @@ import { debounce } from 'lodash';
import Draggable from 'vuedraggable';
import useCollection from '@/compositions/use-collection';
import useItems from '@/compositions/use-items';
import { render } from 'micromustache';
type ViewOptions = {
widths?: {
@@ -125,10 +126,6 @@ export default defineComponent({
type: Array as PropType<Item[]>,
default: () => [],
},
selectMode: {
type: Boolean,
default: false,
},
viewOptions: {
type: Object as PropType<ViewOptions>,
default: null,
@@ -137,9 +134,17 @@ export default defineComponent({
type: Object as PropType<ViewQuery>,
default: null,
},
selectMode: {
type: Boolean,
default: false,
},
detailRoute: {
type: String,
default: `/{{project}}/collections/{{collection}}/{{primaryKey}}`,
},
},
setup(props, { emit }) {
const projectsStore = useProjectsStore();
const { currentProjectKey } = toRefs(useProjectsStore().state);
const table = ref<Vue>(null);
const mainElement = inject('main-element', ref<Element>(null));
@@ -360,7 +365,11 @@ export default defineComponent({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const primaryKey = item[primaryKeyField.value!.field];
router.push(
`/${projectsStore.state.currentProjectKey}/collections/${props.collection}/${primaryKey}`
render(props.detailRoute, {
project: currentProjectKey.value,
collection: collection.value,
primaryKey,
})
);
}
}

View File

@@ -1,5 +1,5 @@
<template>
<v-list nav>
<v-list nav dense>
<v-list-item v-for="navItem in navItems" :key="navItem.to" :to="navItem.to">
<v-list-item-icon><v-icon :name="navItem.icon" /></v-list-item-icon>
<v-list-item-content>

View File

@@ -1,5 +1,6 @@
<template>
<private-view v-if="currentCollection" :title="currentCollection.name">
<collections-not-found v-if="!currentCollection || collection.startsWith('directus_')" />
<private-view v-else :title="currentCollection.name">
<template #title-outer:prepend>
<v-button rounded disabled icon secondary>
<v-icon :name="currentCollection.icon" />
@@ -62,11 +63,10 @@
:view-query.sync="viewQuery"
/>
</private-view>
<collections-not-found v-else />
</template>
<script lang="ts">
import { defineComponent, computed, ref, watch } from '@vue/composition-api';
import { defineComponent, computed, ref, watch, toRefs } from '@vue/composition-api';
import { NavigationGuard } from 'vue-router';
import CollectionsNavigation from '../../components/navigation/';
import useCollectionsStore from '@/stores/collections';
@@ -75,9 +75,9 @@ import useProjectsStore from '@/stores/projects';
import { i18n } from '@/lang';
import api from '@/api';
import { LayoutComponent } from '@/layouts/types';
import useCollectionPresetsStore from '@/stores/collection-presets';
import { debounce } from 'lodash';
import CollectionsNotFound from '../not-found/';
import useCollection from '@/compositions/use-collection';
import useCollectionPreset from '@/compositions/use-collection-preset';
const redirectIfNeeded: NavigationGuard = async (to, from, next) => {
const collectionsStore = useCollectionsStore();
@@ -125,15 +125,14 @@ export default defineComponent({
setup(props) {
const layout = ref<LayoutComponent>(null);
const collectionsStore = useCollectionsStore();
const fieldsStore = useFieldsStore();
const { collection } = toRefs(props);
const projectsStore = useProjectsStore();
const collectionPresetsStore = useCollectionPresetsStore();
const { selection } = useSelection();
const { currentCollection, primaryKeyField } = useCollectionInfo();
const { info: currentCollection, primaryKeyField } = useCollection(collection);
const { addNewLink, batchLink } = useLinks();
const { viewOptions, viewQuery } = useCollectionPreset();
const { viewOptions, viewQuery } = useCollectionPreset(collection);
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
const { breadcrumb } = useBreadcrumb();
@@ -163,17 +162,6 @@ export default defineComponent({
return { selection };
}
function useCollectionInfo() {
const currentCollection = computed(() =>
collectionsStore.getCollection(props.collection)
);
const primaryKeyField = computed(() =>
fieldsStore.getPrimaryKeyFieldForCollection(props.collection)
);
return { currentCollection, primaryKeyField };
}
function useBatchDelete() {
const confirmDelete = ref(false);
const deleting = ref(false);
@@ -188,7 +176,8 @@ export default defineComponent({
confirmDelete.value = false;
const batchPrimaryKeys = selection.value
.map((item) => item[primaryKeyField.value.field])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map((item) => item[primaryKeyField.value!.field])
.join();
await api.delete(
@@ -212,7 +201,8 @@ export default defineComponent({
const batchLink = computed<string>(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;
const batchPrimaryKeys = selection.value
.map((item) => item[primaryKeyField.value.field])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.map((item) => item[primaryKeyField.value!.field])
.join();
return `/${currentProjectKey}/collections/${props.collection}/${batchPrimaryKeys}`;
});
@@ -220,61 +210,6 @@ export default defineComponent({
return { addNewLink, batchLink };
}
function useCollectionPreset() {
const savePreset = debounce(collectionPresetsStore.savePreset, 450);
const localPreset = ref({
...collectionPresetsStore.getPresetForCollection(props.collection),
});
watch(
() => localPreset.value,
(newPreset) => {
savePreset(newPreset);
}
);
watch(
() => props.collection,
() => {
localPreset.value = {
...collectionPresetsStore.getPresetForCollection(props.collection),
};
}
);
const viewOptions = computed({
get() {
return localPreset.value.view_options?.[localPreset.value.view_type] || null;
},
set(val) {
localPreset.value = {
...localPreset.value,
view_options: {
...localPreset.value.view_options,
[localPreset.value.view_type]: val,
},
};
},
});
const viewQuery = computed({
get() {
return localPreset.value.view_query?.[localPreset.value.view_type] || null;
},
set(val) {
localPreset.value = {
...localPreset.value,
view_query: {
...localPreset.value.view_query,
[localPreset.value.view_type]: val,
},
};
},
});
return { viewOptions, viewQuery };
}
function useBreadcrumb() {
const breadcrumb = computed(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;

View File

@@ -1,4 +1,6 @@
import CollectionsModule from './collections/';
import UsersModule from './users/';
import SettingsModule from './settings/';
export const modules = [CollectionsModule, SettingsModule];
export const modules = [CollectionsModule, UsersModule, SettingsModule];
export default modules;

View File

@@ -0,0 +1,4 @@
import UsersNavigation from './navigation.vue';
export { UsersNavigation };
export default UsersNavigation;

View File

@@ -0,0 +1,3 @@
<template>
<div>users nav</div>
</template>

View File

@@ -0,0 +1,23 @@
import { defineModule } from '@/modules/define';
import UsersBrowse from './routes/browse/';
import UsersDetail from './routes/detail/';
export default defineModule(({ i18n }) => ({
id: 'users',
name: i18n.tc('user', 2),
icon: 'people',
routes: [
{
name: 'users-browse',
path: '/',
component: UsersBrowse,
props: true,
},
{
name: 'users-detail',
path: '/:primaryKey',
component: UsersDetail,
props: true,
},
],
}));

View File

@@ -0,0 +1,178 @@
<template>
<private-view :title="$t('users')">
<template #title-outer:prepend>
<v-button rounded disabled icon secondary>
<v-icon name="people" />
</v-button>
</template>
<template #drawer><portal-target name="drawer" /></template>
<template #actions>
<v-dialog v-model="confirmDelete">
<template #activator="{ on }">
<v-button
rounded
icon
class="action-delete"
v-if="selection.length > 0"
@click="on"
>
<v-icon name="delete" />
</v-button>
</template>
<v-card>
<v-card-title>{{ $tc('batch_delete_confirm', selection.length) }}</v-card-title>
<v-card-actions>
<v-button @click="confirmDelete = false" secondary>
{{ $t('cancel') }}
</v-button>
<v-button @click="batchDelete" class="action-delete" :loading="deleting">
{{ $t('delete') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-button rounded icon class="action-batch" v-if="selection.length > 1" :to="batchLink">
<v-icon name="edit" />
</v-button>
<v-button rounded icon :to="addNewLink">
<v-icon name="add" />
</v-button>
</template>
<template #navigation>
<users-navigation />
</template>
<layout-tabular
class="layout"
ref="layout"
collection="directus_users"
:selection.sync="selection"
:view-options.sync="viewOptions"
:view-query.sync="viewQuery"
:detail-route="'/{{project}}/users/{{primaryKey}}'"
/>
</private-view>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from '@vue/composition-api';
import UsersNavigation from '../../components/navigation/';
import useProjectsStore from '@/stores/projects';
import { i18n } from '@/lang';
import api from '@/api';
import { LayoutComponent } from '@/layouts/types';
import useCollectionPreset from '@/compositions/use-collection-preset';
type Item = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[field: string]: any;
};
export default defineComponent({
name: 'users-browse',
components: { UsersNavigation },
props: {},
setup() {
const layout = ref<LayoutComponent>(null);
const projectsStore = useProjectsStore();
const selection = ref<Item[]>([]);
const { viewOptions, viewQuery } = useCollectionPreset(ref('directus_users'));
const { addNewLink, batchLink } = useLinks();
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
const { breadcrumb } = useBreadcrumb();
return {
addNewLink,
batchLink,
selection,
breadcrumb,
confirmDelete,
batchDelete,
deleting,
layout,
viewOptions,
viewQuery,
};
function useBatchDelete() {
const confirmDelete = ref(false);
const deleting = ref(false);
return { confirmDelete, deleting, batchDelete };
async function batchDelete() {
const currentProjectKey = projectsStore.state.currentProjectKey;
deleting.value = true;
confirmDelete.value = false;
const batchPrimaryKeys = selection.value.map((item) => item.id).join();
await api.delete(`/${currentProjectKey}/users/${batchPrimaryKeys}`);
await layout.value?.refresh();
selection.value = [];
deleting.value = false;
confirmDelete.value = false;
}
}
function useLinks() {
const addNewLink = computed<string>(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;
return `/${currentProjectKey}/users/+`;
});
const batchLink = computed<string>(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;
const batchPrimaryKeys = selection.value.map((item) => item.id).join();
return `/${currentProjectKey}/users/${batchPrimaryKeys}`;
});
return { addNewLink, batchLink };
}
function useBreadcrumb() {
const breadcrumb = computed(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;
return [
{
name: i18n.tc('collection', 2),
to: `/${currentProjectKey}/collections`,
},
];
});
return { breadcrumb };
}
},
});
</script>
<style lang="scss" scoped>
.action-delete {
--v-button-background-color: var(--danger);
--v-button-background-color-hover: var(--danger-dark);
}
.action-batch {
--v-button-background-color: var(--warning);
--v-button-background-color-hover: var(--warning-150);
}
.layout {
--layout-offset-top: 64px;
}
</style>

View File

@@ -0,0 +1,4 @@
import UsersBrowse from './browse.vue';
export { UsersBrowse };
export default UsersBrowse;

View File

@@ -0,0 +1,197 @@
<template>
<private-view :title="$t('editing', { collection: $t('users') })">
<template #title-outer:prepend>
<v-button rounded icon secondary exact :to="breadcrumb[0].to">
<v-icon name="arrow_back" />
</v-button>
</template>
<template #headline>
<v-breadcrumb :items="breadcrumb" />
</template>
<template #actions>
<v-dialog v-model="confirmDelete">
<template #activator="{ on }">
<v-button
rounded
icon
class="action-delete"
:disabled="item === null"
@click="on"
>
<v-icon name="delete" />
</v-button>
</template>
<v-card>
<v-card-title>{{ $t('delete_are_you_sure') }}</v-card-title>
<v-card-actions>
<v-button @click="confirmDelete = false" secondary>
{{ $t('cancel') }}
</v-button>
<v-button @click="deleteAndQuit" class="action-delete" :loading="deleting">
{{ $t('delete') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-button
rounded
icon
:loading="saving"
:disabled="hasEdits === false"
@click="saveAndQuit"
>
<v-icon name="check" />
<template #append-outer>
<save-options
:disabled="hasEdits === false"
@save-and-stay="saveAndStay"
@save-and-add-new="saveAndAddNew"
@save-as-copy="saveAsCopyAndNavigate"
/>
</template>
</v-button>
</template>
<template #navigation>
<users-navigation />
</template>
<v-form
:loading="loading"
:initial-values="item"
collection="directus_users"
:batch-mode="isBatch"
v-model="edits"
/>
<template #drawer>
<activity-drawer-detail
v-if="isNew === false"
collection="directus_users"
:primary-key="primaryKey"
/>
</template>
</private-view>
</template>
<script lang="ts">
import { defineComponent, computed, toRefs, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import UsersNavigation from '../../components/navigation/';
import { i18n } from '@/lang';
import router from '@/router';
import ActivityDrawerDetail from '@/views/private/components/activity-drawer-detail';
import useItem from '@/compositions/use-item';
import SaveOptions from '@/views/private/components/save-options';
type Values = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[field: string]: any;
};
export default defineComponent({
name: 'collections-detail',
components: { UsersNavigation, ActivityDrawerDetail, SaveOptions },
props: {
primaryKey: {
type: String,
required: true,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const { currentProjectKey } = toRefs(projectsStore.state);
const { primaryKey } = toRefs(props);
const { breadcrumb } = useBreadcrumb();
const {
isNew,
edits,
item,
saving,
loading,
error,
save,
remove,
deleting,
saveAsCopy,
isBatch,
} = useItem(ref('directus_users'), primaryKey);
const hasEdits = computed<boolean>(() => Object.keys(edits.value).length > 0);
const confirmDelete = ref(false);
return {
item,
loading,
error,
isNew,
breadcrumb,
edits,
hasEdits,
saving,
saveAndQuit,
deleteAndQuit,
confirmDelete,
deleting,
saveAndStay,
saveAndAddNew,
saveAsCopyAndNavigate,
isBatch,
};
function useBreadcrumb() {
const breadcrumb = computed(() => [
{
name: i18n.t('users'),
to: `/${currentProjectKey.value}/users/`,
},
]);
return { breadcrumb };
}
async function saveAndQuit() {
await save();
router.push(`/${currentProjectKey.value}/users`);
}
async function saveAndStay() {
await save();
}
async function saveAndAddNew() {
await save();
router.push(`/${currentProjectKey.value}/users/+`);
}
async function saveAsCopyAndNavigate() {
const newPrimaryKey = await saveAsCopy();
router.push(`/${currentProjectKey.value}/users/${newPrimaryKey}`);
}
async function deleteAndQuit() {
await remove();
router.push(`/${currentProjectKey.value}/users`);
}
},
});
</script>
<style lang="scss" scoped>
.action-delete {
--v-button-background-color: var(--danger);
--v-button-background-color-hover: var(--danger-dark);
}
.v-form {
padding: var(--content-padding);
}
</style>

View File

@@ -0,0 +1,4 @@
import UsersDetail from './detail.vue';
export { UsersDetail };
export default UsersDetail;

View File

@@ -129,6 +129,7 @@ export default defineComponent({
params: {
'filter[collection][eq]': collection,
'filter[item][eq]': primaryKey,
'filter[action][in]': 'comment,create,update,delete',
sort: '-id', // directus_activity has auto increment and is therefore in chronological order
fields: [
'id',

View File

@@ -84,10 +84,10 @@ export default defineComponent({
position: absolute;
top: 0;
left: 0;
transition: transform var(--fast) var(--transition);
&.show {
transform: translateY(-100%);
transition: transform var(--fast) var(--transition);
}
.v-icon {

View File

@@ -9715,6 +9715,11 @@ micromatch@^4.0.0, micromatch@^4.0.2:
braces "^3.0.1"
picomatch "^2.0.5"
micromustache@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/micromustache/-/micromustache-7.1.0.tgz#53eebe9a4fe0ac9b9990535e090f797e2934d5e1"
integrity sha512-DXUYQI8qPsfOx3AkiGzyOx0cn7NgCqFYsV0Asa/ZQUna2Er4mwpAdA9iANA92WYvUowHf+jBsVvIZxiRe1z1Ig==
miller-rabin@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d"