mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04:00
Add hydration, collection store, collections module navigation (#125)
* Add hydration functions and logout route * Add tests for hydration * Add collections nav * Structure collections module, add overview route * Fix failing tests * Add test for use-navigation * Add tests for collections-navigation * Add tests for collections-overview * Fix export for use-navigation composition * Update tests
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"build-storybook": "build-storybook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/format-title": "^3.1.1",
|
||||
"@types/debug": "^4.1.5",
|
||||
"@types/lodash": "^4.14.149",
|
||||
"@types/nanoid": "^2.1.0",
|
||||
|
||||
@@ -164,7 +164,9 @@ describe('API', () => {
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
expect(auth.logout).toHaveBeenCalledWith(auth.LogoutReason.ERROR_SESSION_EXPIRED);
|
||||
expect(auth.logout).toHaveBeenCalledWith({
|
||||
reason: auth.LogoutReason.ERROR_SESSION_EXPIRED
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -49,8 +49,9 @@ export const onError = async (error: any) => {
|
||||
const code = error.response?.data?.error?.code;
|
||||
if (status === 401 && code === 3) {
|
||||
const loggedIn = await checkAuth();
|
||||
|
||||
if (loggedIn === false) {
|
||||
logout(LogoutReason.ERROR_SESSION_EXPIRED);
|
||||
logout({ reason: LogoutReason.ERROR_SESSION_EXPIRED });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import Vue from 'vue';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import * as hydration from '@/hydrate';
|
||||
import api from '@/api';
|
||||
import { checkAuth, logout, LogoutReason } from './auth';
|
||||
import { checkAuth, login, logout, LogoutReason } from './auth';
|
||||
import { useProjectsStore } from '@/stores/projects/';
|
||||
import { useRequestsStore } from '@/stores/requests/';
|
||||
import router from '@/router';
|
||||
import { eachMonthOfInterval } from 'date-fns';
|
||||
|
||||
jest.mock('@/api');
|
||||
jest.mock('@/router');
|
||||
jest.mock('@/hydrate');
|
||||
|
||||
describe('Auth', () => {
|
||||
beforeAll(() => {
|
||||
@@ -17,6 +22,8 @@ describe('Auth', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(api, 'get');
|
||||
jest.spyOn(api, 'post');
|
||||
jest.spyOn(hydration, 'hydrate');
|
||||
jest.spyOn(hydration, 'dehydrate');
|
||||
});
|
||||
|
||||
describe('checkAuth', () => {
|
||||
@@ -57,30 +64,66 @@ describe('Auth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('Does not do anything when there is no current project', () => {
|
||||
const requestsStore = useRequestsStore({});
|
||||
const projectsStore = useProjectsStore({});
|
||||
jest.spyOn(requestsStore, 'reset');
|
||||
logout();
|
||||
expect(requestsStore.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Navigates to the current projects login page', async () => {
|
||||
const requestsStore = useRequestsStore({});
|
||||
const projectsStore = useProjectsStore({});
|
||||
projectsStore.state.currentProjectKey = 'my-project';
|
||||
logout();
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
path: '/my-project/login'
|
||||
describe('login', () => {
|
||||
it('Calls /auth/authenticate with the provided credentials', async () => {
|
||||
useProjectsStore({}).state.currentProjectKey = 'test-project';
|
||||
await login({ email: 'test', password: 'test' });
|
||||
expect(api.post).toHaveBeenCalledWith('/test-project/auth/authenticate', {
|
||||
mode: 'cookie',
|
||||
email: 'test',
|
||||
password: 'test'
|
||||
});
|
||||
});
|
||||
|
||||
it('Adds the reason query param if any non-default reason is given', async () => {
|
||||
const requestsStore = useRequestsStore({});
|
||||
it('Calls hydrate on successful login', async () => {
|
||||
useProjectsStore({}).state.currentProjectKey = 'test-project';
|
||||
await login({ email: 'test', password: 'test' });
|
||||
expect(hydration.hydrate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('Does not do anything when there is no current project', async () => {
|
||||
const projectsStore = useProjectsStore({});
|
||||
await logout();
|
||||
expect(hydration.dehydrate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Calls dehydrate', async () => {
|
||||
useProjectsStore({}).state.currentProjectKey = 'test-project';
|
||||
await logout();
|
||||
expect(hydration.dehydrate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Posts to /logout on regular sign out', async () => {
|
||||
useProjectsStore({}).state.currentProjectKey = 'test-project';
|
||||
await logout();
|
||||
expect(api.post).toHaveBeenCalledWith('/test-project/auth/logout');
|
||||
});
|
||||
|
||||
it('Navigates to the current projects login page', async () => {
|
||||
const projectsStore = useProjectsStore({});
|
||||
projectsStore.state.currentProjectKey = 'my-project';
|
||||
logout(LogoutReason.ERROR_SESSION_EXPIRED);
|
||||
await logout();
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
path: '/my-project/login',
|
||||
query: {
|
||||
reason: LogoutReason.SIGN_OUT
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('Does not navigate when the navigate option is false', async () => {
|
||||
const projectsStore = useProjectsStore({});
|
||||
projectsStore.state.currentProjectKey = 'my-project';
|
||||
await logout({ navigate: false });
|
||||
expect(router.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Adds the reason query param if any non-default reason is given', async () => {
|
||||
const projectsStore = useProjectsStore({});
|
||||
projectsStore.state.currentProjectKey = 'my-project';
|
||||
await logout({ reason: LogoutReason.ERROR_SESSION_EXPIRED });
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
path: '/my-project/login',
|
||||
query: {
|
||||
|
||||
58
src/auth.ts
58
src/auth.ts
@@ -1,7 +1,8 @@
|
||||
import { RawLocation } from 'vue-router';
|
||||
import { useProjectsStore } from '@/stores/projects/';
|
||||
import router from '@/router';
|
||||
import api from '@/api';
|
||||
import { hydrate, dehydrate } from './hydrate';
|
||||
import router from '@/router';
|
||||
|
||||
/**
|
||||
* Check if the current user is authenticated to the current project
|
||||
@@ -15,31 +16,66 @@ export async function checkAuth() {
|
||||
return response.data.data.authenticated;
|
||||
}
|
||||
|
||||
export type LoginCredentials = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export async function login(credentials: LoginCredentials) {
|
||||
const projectsStore = useProjectsStore();
|
||||
const { currentProjectKey } = projectsStore.state;
|
||||
|
||||
const { email, password } = credentials;
|
||||
|
||||
await api.post(`/${currentProjectKey}/auth/authenticate`, {
|
||||
mode: 'cookie',
|
||||
email: email,
|
||||
password: password
|
||||
});
|
||||
|
||||
await hydrate();
|
||||
}
|
||||
|
||||
export enum LogoutReason {
|
||||
SIGN_OUT = 'SIGN_OUT',
|
||||
ERROR_SESSION_EXPIRED = 'ERROR_SESSION_EXPIRED'
|
||||
}
|
||||
|
||||
export type LogoutOptions = {
|
||||
navigate?: boolean;
|
||||
reason?: LogoutReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything that should happen when someone logs out, or is logged out through an external factor
|
||||
* @param reason Why the logout occured. Defaults to LogoutReason.SIGN_OUT.
|
||||
*/
|
||||
export function logout(reason: LogoutReason = LogoutReason.SIGN_OUT) {
|
||||
export async function logout(optionsRaw: LogoutOptions = {}) {
|
||||
const defaultOptions: Required<LogoutOptions> = {
|
||||
navigate: true,
|
||||
reason: LogoutReason.SIGN_OUT
|
||||
};
|
||||
|
||||
const options = { ...defaultOptions, ...optionsRaw };
|
||||
|
||||
const projectsStore = useProjectsStore();
|
||||
const { currentProjectKey } = projectsStore.state;
|
||||
|
||||
// You can't logout of a project if you're not in a project
|
||||
if (currentProjectKey === null) return;
|
||||
|
||||
const location: RawLocation = {
|
||||
path: `/${currentProjectKey}/login`
|
||||
};
|
||||
await dehydrate();
|
||||
|
||||
if (reason !== LogoutReason.SIGN_OUT) {
|
||||
location.query = {
|
||||
reason: reason
|
||||
};
|
||||
// Only if the user manually signed out should we kill the session by hitting the logout endpoint
|
||||
if (options.reason === LogoutReason.SIGN_OUT) {
|
||||
await api.post(`/${currentProjectKey}/auth/logout`);
|
||||
}
|
||||
|
||||
router.push(location);
|
||||
if (options.navigate === true) {
|
||||
const location: RawLocation = {
|
||||
path: `/${currentProjectKey}/login`,
|
||||
query: { reason: options.reason }
|
||||
};
|
||||
|
||||
router.push(location);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,17 +52,17 @@ You can set the default, active, and hover colors and background colors with css
|
||||
| `click` | User clicks on button | `MouseEvent` |
|
||||
|
||||
## CSS Variables
|
||||
| Variable | Default |
|
||||
|------------------------------------|----------------------------|
|
||||
| `--v-list-padding` | `8px 0` |
|
||||
| `--v-list-max-height` | `none` |
|
||||
| `--v-list-max-width` | `none` |
|
||||
| `--v-list-min-width` | `none` |
|
||||
| `--v-list-min-height` | `none` |
|
||||
| `--v-list-color` | `var(--foreground-color)` |
|
||||
| `--v-list-color-hover` | `var(--foreground-color)` |
|
||||
| `--v-list-color-active` | `var(--foreground-color)` |
|
||||
| `--v-list-background-color` | `var(--background-color)` |
|
||||
| Variable | Default |
|
||||
|------------------------------------|----------------------------------|
|
||||
| `--v-list-padding` | `8px 0` |
|
||||
| `--v-list-max-height` | `none` |
|
||||
| `--v-list-max-width` | `none` |
|
||||
| `--v-list-min-width` | `none` |
|
||||
| `--v-list-min-height` | `none` |
|
||||
| `--v-list-color` | `var(--foreground-color)` |
|
||||
| `--v-list-color-hover` | `var(--foreground-color)` |
|
||||
| `--v-list-color-active` | `var(--foreground-color)` |
|
||||
| `--v-list-background-color` | `var(--background-color)` |
|
||||
| `--v-list-background-color-hover` | `var(--background-color-hover)` |
|
||||
| `--v-list-background-color-active` | `var(--background-color-active)` |
|
||||
|
||||
@@ -124,19 +124,20 @@ Hover styles will only be set if the list item has a to link or a onClick handle
|
||||
|
||||
## CSS Variables
|
||||
Second values are fallback ones, in case the list item is not inside a list where those vars are set.
|
||||
| Variable | Default |
|
||||
|-----------------------------------------|-----------------------------------------------------------------|
|
||||
| `--v-list-item-padding` | `0 16px` |
|
||||
| `--v-list-item-min-width` | `none` |
|
||||
| `--v-list-item-max-width` | `none` |
|
||||
| `--v-list-item-min-height` | `48px` |
|
||||
| `--v-list-item-max-height` | `auto` |
|
||||
| `--v-list-item-border-radius` | `0` |
|
||||
| `--v-list-item-margin-bottom` | `0` |
|
||||
| `--v-list-item-color` | `var(--v-list-color, var(--foreground-color))` |
|
||||
| `--v-list-item-color-hover` | `var(--v-list-color-hover, var(--foreground-color))` |
|
||||
| `--v-list-item-color-active` | `var(--v-list-color-active, var(--foreground-color))` |
|
||||
| `--v-list-item-background-color` | `var(--v-list-background-color, var(--background-color))` |
|
||||
|
||||
| Variable | Default |
|
||||
|-----------------------------------------|-----------------------------------------------------------------------|
|
||||
| `--v-list-item-padding` | `0 16px` |
|
||||
| `--v-list-item-min-width` | `none` |
|
||||
| `--v-list-item-max-width` | `none` |
|
||||
| `--v-list-item-min-height` | `48px` |
|
||||
| `--v-list-item-max-height` | `auto` |
|
||||
| `--v-list-item-border-radius` | `0` |
|
||||
| `--v-list-item-margin-bottom` | `0` |
|
||||
| `--v-list-item-color` | `var(--v-list-color, var(--foreground-color))` |
|
||||
| `--v-list-item-color-hover` | `var(--v-list-color-hover, var(--foreground-color))` |
|
||||
| `--v-list-item-color-active` | `var(--v-list-color-active, var(--foreground-color))` |
|
||||
| `--v-list-item-background-color` | `var(--v-list-background-color, var(--background-color))` |
|
||||
| `--v-list-item-background-color-hover` | `var(---list-background-color-hover, var(--background-color-hover))` |
|
||||
| `--v-list-item-background-color-active` | `var(--vlist-background-color-active,var(--background-color-active))` |
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import VListItemContent from './v-list-item-content.vue';
|
||||
import VSheet from '../v-sheet';
|
||||
import VueRouter from 'vue-router';
|
||||
import markdown from './v-list.readme.md';
|
||||
|
||||
Vue.component('v-list', VList);
|
||||
Vue.component('v-list-item', VListItem);
|
||||
@@ -19,7 +20,10 @@ const router = new VueRouter();
|
||||
export default {
|
||||
title: 'Components / List',
|
||||
component: VList,
|
||||
decorators: [withKnobs, withPadding]
|
||||
decorators: [withKnobs, withPadding],
|
||||
parameters: {
|
||||
notes: markdown
|
||||
}
|
||||
};
|
||||
|
||||
export const basic = () => ({
|
||||
|
||||
61
src/hydrate.test.ts
Normal file
61
src/hydrate.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCollectionsStore } from './stores/collections/';
|
||||
import { hydrated, hydrate, dehydrate } from './hydrate';
|
||||
|
||||
import Vue from 'vue';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
|
||||
describe('Hydration', () => {
|
||||
beforeAll(() => {
|
||||
Vue.use(VueCompositionAPI);
|
||||
});
|
||||
|
||||
describe('Hydrate', () => {
|
||||
it('Calls the correct stores', async () => {
|
||||
const collectionsStore = useCollectionsStore({});
|
||||
collectionsStore.getCollections = jest.fn();
|
||||
await hydrate();
|
||||
expect(collectionsStore.getCollections).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Sets hydrated let after it is done', async () => {
|
||||
await hydrate();
|
||||
expect(hydrated).toBe(true);
|
||||
});
|
||||
|
||||
it('Does not hydrate when already hydrated', async () => {
|
||||
await hydrate();
|
||||
|
||||
const collectionsStore = useCollectionsStore({});
|
||||
collectionsStore.getCollections = jest.fn();
|
||||
|
||||
await hydrate();
|
||||
|
||||
expect(collectionsStore.getCollections).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dehydrate', () => {
|
||||
it('Calls resets functions of correct stores', async () => {
|
||||
const collectionsStore = useCollectionsStore({});
|
||||
collectionsStore.reset = jest.fn();
|
||||
await dehydrate();
|
||||
expect(collectionsStore.reset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Sets hydrated let after it is done', async () => {
|
||||
await dehydrate();
|
||||
expect(hydrated).toBe(false);
|
||||
});
|
||||
|
||||
it('Does not hydrate when already hydrated', async () => {
|
||||
await dehydrate();
|
||||
|
||||
const collectionsStore = useCollectionsStore({});
|
||||
collectionsStore.reset = jest.fn();
|
||||
|
||||
await dehydrate();
|
||||
|
||||
expect(collectionsStore.reset).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
15
src/hydrate.ts
Normal file
15
src/hydrate.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
|
||||
export let hydrated = false;
|
||||
|
||||
export async function hydrate() {
|
||||
if (hydrated) return;
|
||||
await useCollectionsStore().getCollections();
|
||||
hydrated = true;
|
||||
}
|
||||
|
||||
export async function dehydrate() {
|
||||
if (hydrated === false) return;
|
||||
useCollectionsStore().reset();
|
||||
hydrated = false;
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import './styles/main.scss';
|
||||
import './directives/register';
|
||||
import './components/register';
|
||||
import './views/register';
|
||||
import './extensions/register';
|
||||
import './modules/register';
|
||||
import './layouts/register';
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<private-view>
|
||||
<template #navigation>
|
||||
Nav
|
||||
</template>
|
||||
|
||||
Collections
|
||||
|
||||
<template #drawer>
|
||||
Drawer
|
||||
</template>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { createComponent } from '@vue/composition-api';
|
||||
|
||||
export default createComponent({
|
||||
props: {},
|
||||
setup() {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,28 @@
|
||||
import CollectionsNavigation from './collections-navigation.vue';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import useNavigation from '../compositions/use-navigation';
|
||||
import VList, { VListItem, VListItemContent } from '@/components/v-list';
|
||||
|
||||
jest.mock('../compositions/use-navigation');
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-list', VList);
|
||||
localVue.component('v-list-item', VListItem);
|
||||
localVue.component('v-list-item-content', VListItemContent);
|
||||
|
||||
describe('Modules / Collections / Components / CollectionsNavigation', () => {
|
||||
beforeEach(() => {
|
||||
(useNavigation as jest.Mock).mockImplementation(() => ({
|
||||
navItems: {
|
||||
value: []
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
it('Uses useNavigation to get navigation links', () => {
|
||||
shallowMount(CollectionsNavigation, { localVue });
|
||||
expect(useNavigation).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<v-list nav dense>
|
||||
<v-list-item v-for="navItem in navItems" :key="navItem.to" :to="navItem.to">
|
||||
<v-list-item-content>
|
||||
{{ navItem.name }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { createComponent, computed } from '@vue/composition-api';
|
||||
import VueI18n from 'vue-i18n';
|
||||
import useNavigation from '../compositions/use-navigation';
|
||||
|
||||
export default createComponent({
|
||||
props: {},
|
||||
setup() {
|
||||
const { navItems } = useNavigation();
|
||||
|
||||
return { navItems };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
96
src/modules/collections/compositions/use-navigation.test.ts
Normal file
96
src/modules/collections/compositions/use-navigation.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import mountComposition from '../../../../.jest/mount-composition';
|
||||
import { useProjectsStore } from '@/stores/projects';
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
import useNavigation from './use-navigation';
|
||||
|
||||
describe('Modules / Collections / Compositions / useNavigation', () => {
|
||||
afterEach(() => {
|
||||
useProjectsStore().reset();
|
||||
useCollectionsStore().reset();
|
||||
});
|
||||
|
||||
it('Converts the visible collections to navigation links', () => {
|
||||
const projectsStore = useProjectsStore();
|
||||
const collectionsStore = useCollectionsStore();
|
||||
|
||||
projectsStore.state.currentProjectKey = 'my-project';
|
||||
|
||||
collectionsStore.state.collections = [
|
||||
{
|
||||
collection: 'test',
|
||||
name: 'Test',
|
||||
icon: 'box',
|
||||
note: null,
|
||||
hidden: false,
|
||||
managed: true,
|
||||
single: false,
|
||||
translation: null
|
||||
}
|
||||
];
|
||||
|
||||
let navItems: any;
|
||||
|
||||
mountComposition(() => {
|
||||
navItems = useNavigation().navItems;
|
||||
});
|
||||
|
||||
expect(navItems).toEqual([
|
||||
{
|
||||
collection: 'test',
|
||||
name: 'Test',
|
||||
to: '/my-project/collections/test',
|
||||
icon: 'box'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('Sorts the collections alphabetically by name', () => {
|
||||
const projectsStore = useProjectsStore();
|
||||
const collectionsStore = useCollectionsStore();
|
||||
|
||||
projectsStore.state.currentProjectKey = 'my-project';
|
||||
|
||||
collectionsStore.state.collections = [
|
||||
{
|
||||
collection: 'test',
|
||||
name: 'B Test',
|
||||
icon: 'box',
|
||||
note: null,
|
||||
hidden: false,
|
||||
managed: true,
|
||||
single: false,
|
||||
translation: null
|
||||
},
|
||||
{
|
||||
collection: 'test2',
|
||||
name: 'A Test',
|
||||
icon: 'box',
|
||||
note: null,
|
||||
hidden: false,
|
||||
managed: true,
|
||||
single: false,
|
||||
translation: null
|
||||
},
|
||||
{
|
||||
collection: 'test3',
|
||||
name: 'C Test',
|
||||
icon: 'box',
|
||||
note: null,
|
||||
hidden: false,
|
||||
managed: true,
|
||||
single: false,
|
||||
translation: null
|
||||
}
|
||||
];
|
||||
|
||||
let navItems: any;
|
||||
|
||||
mountComposition(() => {
|
||||
navItems = useNavigation().navItems;
|
||||
});
|
||||
|
||||
expect(navItems[0].name).toBe('A Test');
|
||||
expect(navItems[1].name).toBe('B Test');
|
||||
expect(navItems[2].name).toBe('C Test');
|
||||
});
|
||||
});
|
||||
35
src/modules/collections/compositions/use-navigation.ts
Normal file
35
src/modules/collections/compositions/use-navigation.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { computed } from '@vue/composition-api';
|
||||
import { useProjectsStore } from '@/stores/projects';
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
import VueI18n from 'vue-i18n';
|
||||
|
||||
export type NavItem = {
|
||||
collection: string;
|
||||
name: string | VueI18n.TranslateResult;
|
||||
to: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
export default function useNavigation() {
|
||||
const collectionsStore = useCollectionsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const navItems = computed<NavItem[]>(() => {
|
||||
return collectionsStore.visibleCollections.value
|
||||
.map(collection => {
|
||||
const navItem: NavItem = {
|
||||
collection: collection.collection,
|
||||
name: collection.name,
|
||||
icon: collection.icon,
|
||||
to: `/${projectsStore.state.currentProjectKey}/collections/${collection.collection}`
|
||||
};
|
||||
|
||||
return navItem;
|
||||
})
|
||||
.sort((navA: NavItem, navB: NavItem) => {
|
||||
return navA.name > navB.name ? 1 : -1;
|
||||
});
|
||||
});
|
||||
|
||||
return { navItems: navItems.value };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import Collections from './collections.vue';
|
||||
import CollectionsOverview from './routes/collections-overview.vue';
|
||||
import { createModule } from '@/modules/create';
|
||||
|
||||
export default createModule({
|
||||
@@ -8,7 +8,7 @@ export default createModule({
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: Collections
|
||||
component: CollectionsOverview
|
||||
}
|
||||
],
|
||||
icon: 'box'
|
||||
|
||||
40
src/modules/collections/routes/collections-overview.test.ts
Normal file
40
src/modules/collections/routes/collections-overview.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import CollectionsOverview from './collections-overview.vue';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import useNavigation from '../compositions/use-navigation';
|
||||
import VTable from '@/components/v-table';
|
||||
import PrivateView from '@/views/private/';
|
||||
import router from '@/router';
|
||||
|
||||
jest.mock('../compositions/use-navigation');
|
||||
jest.mock('@/router');
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-table', VTable);
|
||||
localVue.component('private-view', PrivateView);
|
||||
|
||||
describe('Modules / Collections / Routes / CollectionsOverview', () => {
|
||||
beforeEach(() => {
|
||||
(useNavigation as jest.Mock).mockImplementation(() => ({
|
||||
navItems: []
|
||||
}));
|
||||
});
|
||||
|
||||
it('Uses useNavigation to get navigation links', () => {
|
||||
shallowMount(CollectionsOverview, { localVue });
|
||||
expect(useNavigation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Calls router.push on navigation', () => {
|
||||
const component = shallowMount(CollectionsOverview, { localVue });
|
||||
(component.vm as any).navigateToCollection({
|
||||
collection: 'test',
|
||||
name: 'Test',
|
||||
icon: 'box',
|
||||
to: '/test-route'
|
||||
});
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith('/test-route');
|
||||
});
|
||||
});
|
||||
61
src/modules/collections/routes/collections-overview.vue
Normal file
61
src/modules/collections/routes/collections-overview.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<private-view class="collections-overview">
|
||||
<template #navigation>
|
||||
<collections-navigation />
|
||||
</template>
|
||||
|
||||
<v-table :headers="tableHeaders" :items="navItems" @click:row="navigateToCollection">
|
||||
<template #item.icon="{ item }">
|
||||
<v-icon class="icon" :name="item.icon" />
|
||||
</template>
|
||||
</v-table>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { createComponent, computed } from '@vue/composition-api';
|
||||
import CollectionsNavigation from '../components/collections-navigation.vue';
|
||||
import { i18n } from '@/lang';
|
||||
import useNavigation, { NavItem } from '../compositions/use-navigation';
|
||||
import router from '@/router';
|
||||
|
||||
export default createComponent({
|
||||
components: {
|
||||
CollectionsNavigation
|
||||
},
|
||||
props: {},
|
||||
setup() {
|
||||
const tableHeaders = [
|
||||
{
|
||||
text: '',
|
||||
value: 'icon',
|
||||
width: 42,
|
||||
sortable: false
|
||||
},
|
||||
{
|
||||
text: i18n.tc('collection', 1),
|
||||
value: 'name',
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
text: i18n.t('note'),
|
||||
value: 'note'
|
||||
}
|
||||
];
|
||||
|
||||
const { navItems } = useNavigation();
|
||||
|
||||
return { tableHeaders, navItems, navigateToCollection };
|
||||
|
||||
function navigateToCollection(navItem: NavItem) {
|
||||
router.push(navItem.to);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.icon {
|
||||
--v-icon-color: var(--foreground-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,21 @@
|
||||
import Vue from 'vue';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import { Route } from 'vue-router';
|
||||
import * as hydration from '@/hydrate';
|
||||
import router, {
|
||||
onBeforeEach,
|
||||
onBeforeEnterProjectChooser,
|
||||
replaceRoutes,
|
||||
defaultRoutes
|
||||
defaultRoutes,
|
||||
onBeforeEnterLogout
|
||||
} from './router';
|
||||
import { useProjectsStore } from '@/stores/projects';
|
||||
import api from '@/api';
|
||||
import * as auth from '@/auth';
|
||||
|
||||
jest.mock('@/hydrate');
|
||||
jest.mock('@/auth');
|
||||
|
||||
const route: Route = {
|
||||
name: undefined,
|
||||
path: '',
|
||||
@@ -183,6 +188,31 @@ describe('Router', () => {
|
||||
expect(next).toHaveBeenCalledWith('/my-project/login');
|
||||
});
|
||||
|
||||
it('Hydrates the store on first load when logged in', async () => {
|
||||
const projectsStore = useProjectsStore({});
|
||||
projectsStore.getProjects = jest.fn();
|
||||
jest.spyOn(auth, 'checkAuth').mockImplementation(() => Promise.resolve(true));
|
||||
(projectsStore.state.projects as any) = [
|
||||
{
|
||||
key: 'my-project'
|
||||
}
|
||||
];
|
||||
|
||||
const to = {
|
||||
...route,
|
||||
params: {
|
||||
project: 'my-project'
|
||||
}
|
||||
};
|
||||
|
||||
const from = { ...route, name: null };
|
||||
const next = jest.fn();
|
||||
|
||||
await onBeforeEach(to, from as any, next);
|
||||
|
||||
expect(hydration.hydrate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Calls next when all checks are done', async () => {
|
||||
const projectsStore = useProjectsStore({});
|
||||
projectsStore.getProjects = jest.fn();
|
||||
@@ -221,6 +251,17 @@ describe('Router', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onBeforeEnterLogout', () => {
|
||||
it('Calls logout and redirects to login page', async () => {
|
||||
const to = { ...route, path: '/my-project/logout', params: { project: 'my-project' } };
|
||||
const from = route;
|
||||
const next = jest.fn();
|
||||
await onBeforeEnterLogout(to, from, next);
|
||||
expect(auth.logout).toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith('/my-project/login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceRoutes', () => {
|
||||
it('Calls the handler with the default routes', async () => {
|
||||
const handler = jest.fn(() => []);
|
||||
|
||||
@@ -3,7 +3,8 @@ import Debug from '@/routes/debug.vue';
|
||||
import { useProjectsStore } from '@/stores/projects';
|
||||
import LoginRoute from '@/routes/login';
|
||||
import ProjectChooserRoute from '@/routes/project-chooser';
|
||||
import { checkAuth } from '@/auth';
|
||||
import { checkAuth, logout } from '@/auth';
|
||||
import { hydrate } from '@/hydrate';
|
||||
|
||||
export const onBeforeEnterProjectChooser: NavigationGuard = (to, from, next) => {
|
||||
const projectsStore = useProjectsStore();
|
||||
@@ -11,6 +12,12 @@ export const onBeforeEnterProjectChooser: NavigationGuard = (to, from, next) =>
|
||||
next();
|
||||
};
|
||||
|
||||
export const onBeforeEnterLogout: NavigationGuard = async (to, from, next) => {
|
||||
const currentProjectKey = to.params.project;
|
||||
await logout({ navigate: false });
|
||||
next(`/${currentProjectKey}/login`);
|
||||
};
|
||||
|
||||
export const defaultRoutes: RouteConfig[] = [
|
||||
{
|
||||
path: '/',
|
||||
@@ -38,6 +45,10 @@ export const defaultRoutes: RouteConfig[] = [
|
||||
public: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:project/logout',
|
||||
beforeEnter: onBeforeEnterLogout
|
||||
},
|
||||
/**
|
||||
* @NOTE
|
||||
* Dynamic modules need to be inserted here. By default, VueRouter.addRoutes adds the route
|
||||
@@ -113,7 +124,9 @@ export const onBeforeEach: NavigationGuard = async (to, from, next) => {
|
||||
if (firstLoad) {
|
||||
const loggedIn = await checkAuth();
|
||||
|
||||
if (loggedIn === false) {
|
||||
if (loggedIn === true) {
|
||||
await hydrate();
|
||||
} else {
|
||||
return next(`/${projectsStore.state.currentProjectKey}/login`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createComponent, ref } from '@vue/composition-api';
|
||||
import api from '@/api';
|
||||
import { useProjectsStore } from '@/stores/projects';
|
||||
import router from '@/router';
|
||||
import { login } from '@/auth';
|
||||
|
||||
export default createComponent({
|
||||
setup() {
|
||||
@@ -30,18 +31,19 @@ export default createComponent({
|
||||
return { email, password, onSubmit, loggingIn };
|
||||
|
||||
async function onSubmit() {
|
||||
if (email.value === null || password.value === null) return;
|
||||
|
||||
const currentProjectKey = projectsStore.state.currentProjectKey;
|
||||
|
||||
try {
|
||||
loggingIn.value = true;
|
||||
|
||||
await api.post(`/${currentProjectKey}/auth/authenticate`, {
|
||||
mode: 'cookie',
|
||||
await login({
|
||||
email: email.value,
|
||||
password: password.value
|
||||
});
|
||||
|
||||
router.push(`/${currentProjectKey}/`);
|
||||
router.push(`/${currentProjectKey}/collections/`);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
} finally {
|
||||
|
||||
59
src/stores/collections/collections.ts
Normal file
59
src/stores/collections/collections.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createStore } from 'pinia';
|
||||
import api from '@/api';
|
||||
import { Collection, CollectionRaw } from './types';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import i18n from '@/lang/';
|
||||
import { notEmpty } from '@/utils/is-empty';
|
||||
import VueI18n, { LocaleMessages } from 'vue-i18n';
|
||||
import formatTitle from '@directus/format-title';
|
||||
|
||||
export const useCollectionsStore = createStore({
|
||||
id: 'collections',
|
||||
state: () => ({
|
||||
collections: [] as Collection[]
|
||||
}),
|
||||
getters: {
|
||||
visibleCollections: state => {
|
||||
return state.collections
|
||||
.filter(({ collection }) => collection.startsWith('directus_') === false)
|
||||
.filter(({ hidden }) => hidden === false);
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async getCollections() {
|
||||
const projectsStore = useProjectsStore();
|
||||
const { currentProjectKey } = projectsStore.state;
|
||||
|
||||
const response = await api.get(`/${currentProjectKey}/collections`);
|
||||
|
||||
const collections: CollectionRaw[] = response.data.data;
|
||||
|
||||
this.state.collections = collections.map((collection: CollectionRaw) => {
|
||||
let name: string | VueI18n.TranslateResult;
|
||||
const icon = collection.icon || 'box';
|
||||
|
||||
if (notEmpty(collection.translation)) {
|
||||
for (let i = 0; i < collection.translation.length; i++) {
|
||||
const { locale, translation } = collection.translation[i];
|
||||
|
||||
i18n.mergeLocaleMessage(locale, {
|
||||
collections: {
|
||||
[collection.collection]: translation
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
name = i18n.t(`collections.${collection.collection}`);
|
||||
} else {
|
||||
name = formatTitle(collection.collection);
|
||||
}
|
||||
|
||||
return {
|
||||
...collection,
|
||||
name,
|
||||
icon
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
4
src/stores/collections/index.ts
Normal file
4
src/stores/collections/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useCollectionsStore } from './collections';
|
||||
|
||||
export { useCollectionsStore };
|
||||
export default useCollectionsStore;
|
||||
21
src/stores/collections/types.ts
Normal file
21
src/stores/collections/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import VueI18n from 'vue-i18n';
|
||||
|
||||
type Translation = {
|
||||
locale: string;
|
||||
translation: string;
|
||||
};
|
||||
|
||||
export interface CollectionRaw {
|
||||
collection: string;
|
||||
note: string | null;
|
||||
hidden: boolean;
|
||||
single: boolean;
|
||||
managed: boolean;
|
||||
icon: string | null;
|
||||
translation: Translation[] | null;
|
||||
}
|
||||
|
||||
export interface Collection extends CollectionRaw {
|
||||
name: string | VueI18n.TranslateResult;
|
||||
icon: string;
|
||||
}
|
||||
@@ -2,13 +2,6 @@ import { createStore } from 'pinia';
|
||||
import { Projects, ProjectWithKey, ProjectError } from './types';
|
||||
import api from '@/api';
|
||||
|
||||
type ProjectsState = {
|
||||
needsInstall: boolean;
|
||||
error: any;
|
||||
projects: Projects;
|
||||
currentProjectKey: string | null;
|
||||
};
|
||||
|
||||
export const useProjectsStore = createStore({
|
||||
id: 'projects',
|
||||
state: () => ({
|
||||
|
||||
@@ -3,17 +3,33 @@
|
||||
<aside class="navigation" :class="{ 'is-open': navOpen }">
|
||||
<module-bar />
|
||||
<div class="module-nav">
|
||||
<slot name="navigation" />
|
||||
<div
|
||||
style="height: 64px; padding: 20px; color: red; font-family: 'Comic Sans MS', cursive;"
|
||||
>
|
||||
PROJECT CHOOSER
|
||||
</div>
|
||||
<div class="module-nav-content">
|
||||
<slot name="navigation" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="content">
|
||||
<header>
|
||||
<button @click="navOpen = true">Toggle nav</button>
|
||||
<button @click="drawerOpen = !drawerOpen">Toggle drawer</button>
|
||||
<button v-if="drawerHasContent" @click="drawerOpen = !drawerOpen">
|
||||
Toggle drawer
|
||||
</button>
|
||||
</header>
|
||||
<main><slot /></main>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
<aside class="drawer" :class="{ 'is-open': drawerOpen }" @click="drawerOpen = true">
|
||||
<aside
|
||||
v-if="drawerHasContent"
|
||||
class="drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
@click="drawerOpen = true"
|
||||
>
|
||||
<drawer-detail-group :drawer-open="drawerOpen">
|
||||
<slot name="drawer" />
|
||||
</drawer-detail-group>
|
||||
@@ -50,14 +66,16 @@ export default createComponent({
|
||||
DrawerDetailGroup
|
||||
},
|
||||
props: {},
|
||||
setup() {
|
||||
const navOpen = ref<boolean>(false);
|
||||
const drawerOpen = ref<boolean>(false);
|
||||
setup(props, { slots }) {
|
||||
const navOpen = ref(false);
|
||||
const drawerOpen = ref(false);
|
||||
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const navWithOverlay = computed<boolean>(() => width.value < 960);
|
||||
const drawerWithOverlay = computed<boolean>(() => width.value < 1260);
|
||||
const navWithOverlay = computed(() => width.value < 960);
|
||||
const drawerWithOverlay = computed(() => width.value < 1260);
|
||||
|
||||
const drawerHasContent = computed(() => !!slots.drawer);
|
||||
|
||||
provide('drawer-open', drawerOpen);
|
||||
|
||||
@@ -66,7 +84,8 @@ export default createComponent({
|
||||
drawerOpen,
|
||||
navWithOverlay,
|
||||
drawerWithOverlay,
|
||||
width
|
||||
width,
|
||||
drawerHasContent
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -76,6 +95,8 @@ export default createComponent({
|
||||
@import '@/styles/mixins/breakpoint';
|
||||
|
||||
.private-view {
|
||||
--private-view-content-padding: 32px;
|
||||
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -109,6 +130,12 @@ export default createComponent({
|
||||
height: 100%;
|
||||
font-size: 1rem;
|
||||
background-color: #eceff1;
|
||||
|
||||
&-content {
|
||||
height: calc(100% - 64px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint(medium) {
|
||||
@@ -119,6 +146,10 @@ export default createComponent({
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
|
||||
main {
|
||||
padding: var(--private-view-content-padding);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer {
|
||||
|
||||
@@ -858,6 +858,11 @@
|
||||
exec-sh "^0.3.2"
|
||||
minimist "^1.2.0"
|
||||
|
||||
"@directus/format-title@^3.1.0":
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@directus/format-title/-/format-title-3.1.1.tgz#a357171870137a7a39de99f1b8e011482174679b"
|
||||
integrity sha512-9M+6ya2VSPhMhMSJCzYmuVFVFgDWYcuNR+4hEpYx/Ma9nFq/V1rWzqQ1fZDQ3iAxilxhAPuhRTUvKDsckwsGWw==
|
||||
|
||||
"@emotion/cache@^10.0.27", "@emotion/cache@^10.0.9":
|
||||
version "10.0.27"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.27.tgz#7895db204e2c1a991ae33d51262a3a44f6737303"
|
||||
|
||||
Reference in New Issue
Block a user