From 28531b531b76f810bb5f28525d7aefc328f84c5c Mon Sep 17 00:00:00 2001 From: Rijk van Zanten Date: Fri, 28 Feb 2020 16:21:51 -0500 Subject: [PATCH] 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 --- package.json | 1 + src/api.test.ts | 4 +- src/api.ts | 3 +- src/auth.test.ts | 83 ++++++++++++---- src/auth.ts | 58 ++++++++--- src/components/v-list/v-list.readme.md | 49 +++++----- src/components/v-list/v-list.story.ts | 6 +- src/hydrate.test.ts | 61 ++++++++++++ src/hydrate.ts | 15 +++ src/main.ts | 3 +- src/modules/collections/collections.vue | 24 ----- .../components/collections-navigation.test.ts | 28 ++++++ .../components/collections-navigation.vue | 24 +++++ .../compositions/use-navigation.test.ts | 96 +++++++++++++++++++ .../compositions/use-navigation.ts | 35 +++++++ src/modules/collections/index.ts | 4 +- .../routes/collections-overview.test.ts | 40 ++++++++ .../routes/collections-overview.vue | 61 ++++++++++++ src/router.test.ts | 43 ++++++++- src/router.ts | 17 +++- src/routes/login/login.vue | 8 +- src/stores/collections/collections.ts | 59 ++++++++++++ src/stores/collections/index.ts | 4 + src/stores/collections/types.ts | 21 ++++ src/stores/projects/projects.ts | 7 -- src/views/private/private-view.vue | 51 ++++++++-- yarn.lock | 5 + 27 files changed, 702 insertions(+), 108 deletions(-) create mode 100644 src/hydrate.test.ts create mode 100644 src/hydrate.ts delete mode 100644 src/modules/collections/collections.vue create mode 100644 src/modules/collections/components/collections-navigation.test.ts create mode 100644 src/modules/collections/components/collections-navigation.vue create mode 100644 src/modules/collections/compositions/use-navigation.test.ts create mode 100644 src/modules/collections/compositions/use-navigation.ts create mode 100644 src/modules/collections/routes/collections-overview.test.ts create mode 100644 src/modules/collections/routes/collections-overview.vue create mode 100644 src/stores/collections/collections.ts create mode 100644 src/stores/collections/index.ts create mode 100644 src/stores/collections/types.ts diff --git a/package.json b/package.json index cd6db0080f..4a83ed5b04 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api.test.ts b/src/api.test.ts index 2615c28d09..0bcde9ead8 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -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 + }); } }); diff --git a/src/api.ts b/src/api.ts index a59f360d97..38d942257f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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 }); } } diff --git a/src/auth.test.ts b/src/auth.test.ts index 874ca8c4b0..c540bc8beb 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -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: { diff --git a/src/auth.ts b/src/auth.ts index f1dbd00d0f..48fd3f5b92 100644 --- a/src/auth.ts +++ b/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 = { + 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); + } } diff --git a/src/components/v-list/v-list.readme.md b/src/components/v-list/v-list.readme.md index 2fc1e06484..d7d5f09341 100644 --- a/src/components/v-list/v-list.readme.md +++ b/src/components/v-list/v-list.readme.md @@ -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))` | diff --git a/src/components/v-list/v-list.story.ts b/src/components/v-list/v-list.story.ts index 02b4ee8efb..265a7d7707 100644 --- a/src/components/v-list/v-list.story.ts +++ b/src/components/v-list/v-list.story.ts @@ -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 = () => ({ diff --git a/src/hydrate.test.ts b/src/hydrate.test.ts new file mode 100644 index 0000000000..c9bcb93eff --- /dev/null +++ b/src/hydrate.test.ts @@ -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(); + }); + }); +}); diff --git a/src/hydrate.ts b/src/hydrate.ts new file mode 100644 index 0000000000..22e780227d --- /dev/null +++ b/src/hydrate.ts @@ -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; +} diff --git a/src/main.ts b/src/main.ts index 1a7f7267b6..7d1d39ae3a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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; diff --git a/src/modules/collections/collections.vue b/src/modules/collections/collections.vue deleted file mode 100644 index 16d3bd46ae..0000000000 --- a/src/modules/collections/collections.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/src/modules/collections/components/collections-navigation.test.ts b/src/modules/collections/components/collections-navigation.test.ts new file mode 100644 index 0000000000..a003674ee8 --- /dev/null +++ b/src/modules/collections/components/collections-navigation.test.ts @@ -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(); + }); +}); diff --git a/src/modules/collections/components/collections-navigation.vue b/src/modules/collections/components/collections-navigation.vue new file mode 100644 index 0000000000..7593bcd7ef --- /dev/null +++ b/src/modules/collections/components/collections-navigation.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/modules/collections/compositions/use-navigation.test.ts b/src/modules/collections/compositions/use-navigation.test.ts new file mode 100644 index 0000000000..c67128bd45 --- /dev/null +++ b/src/modules/collections/compositions/use-navigation.test.ts @@ -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'); + }); +}); diff --git a/src/modules/collections/compositions/use-navigation.ts b/src/modules/collections/compositions/use-navigation.ts new file mode 100644 index 0000000000..e1e2057b68 --- /dev/null +++ b/src/modules/collections/compositions/use-navigation.ts @@ -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(() => { + 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 }; +} diff --git a/src/modules/collections/index.ts b/src/modules/collections/index.ts index 947b0bdda8..c40a4d8ba6 100644 --- a/src/modules/collections/index.ts +++ b/src/modules/collections/index.ts @@ -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' diff --git a/src/modules/collections/routes/collections-overview.test.ts b/src/modules/collections/routes/collections-overview.test.ts new file mode 100644 index 0000000000..1ec4dc3817 --- /dev/null +++ b/src/modules/collections/routes/collections-overview.test.ts @@ -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'); + }); +}); diff --git a/src/modules/collections/routes/collections-overview.vue b/src/modules/collections/routes/collections-overview.vue new file mode 100644 index 0000000000..329f861554 --- /dev/null +++ b/src/modules/collections/routes/collections-overview.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/src/router.test.ts b/src/router.test.ts index 8236e7615b..ac4cac3db9 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -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(() => []); diff --git a/src/router.ts b/src/router.ts index 7c14b1079b..bcf4777d12 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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`); } } diff --git a/src/routes/login/login.vue b/src/routes/login/login.vue index 5f0d387959..a9884b9981 100644 --- a/src/routes/login/login.vue +++ b/src/routes/login/login.vue @@ -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 { diff --git a/src/stores/collections/collections.ts b/src/stores/collections/collections.ts new file mode 100644 index 0000000000..05d6396aa6 --- /dev/null +++ b/src/stores/collections/collections.ts @@ -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 + }; + }); + } + } +}); diff --git a/src/stores/collections/index.ts b/src/stores/collections/index.ts new file mode 100644 index 0000000000..c778ca4df2 --- /dev/null +++ b/src/stores/collections/index.ts @@ -0,0 +1,4 @@ +import { useCollectionsStore } from './collections'; + +export { useCollectionsStore }; +export default useCollectionsStore; diff --git a/src/stores/collections/types.ts b/src/stores/collections/types.ts new file mode 100644 index 0000000000..05e273aafb --- /dev/null +++ b/src/stores/collections/types.ts @@ -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; +} diff --git a/src/stores/projects/projects.ts b/src/stores/projects/projects.ts index 927d89a7b4..8cf17ef7e0 100644 --- a/src/stores/projects/projects.ts +++ b/src/stores/projects/projects.ts @@ -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: () => ({ diff --git a/src/views/private/private-view.vue b/src/views/private/private-view.vue index a774e161d7..bf13f25d6a 100644 --- a/src/views/private/private-view.vue +++ b/src/views/private/private-view.vue @@ -3,17 +3,33 @@
- +
-
+
+ +
-