From a2ba2c8783f939a3222e563cf3e2bb86652aa2a9 Mon Sep 17 00:00:00 2001 From: Rijk van Zanten Date: Wed, 11 Mar 2020 10:36:39 -0400 Subject: [PATCH] Add fields store (#144) * Add fields store * Add test coverage for fields store * Remove hydration tests It doesn't do anything itself, but just calls init / reset methods of stores * Rename store methods to hydrate / dehydrate * DRY that sucker * Move hydration logic into a store * Fix tests for new store * Rename hydrate store to app store, fix tests in auth * Fix tests of router * Fix tests in module-bar-logo * bunch of things * Fix tests in hydrate * Fix router tests * Clean up auth tests * Update tests for collections / fields stores * Use stores instead of mocks in tests * Add test for store getter in collections --- src/auth.test.ts | 48 +++-- src/auth.ts | 2 +- src/hydrate.test.ts | 175 +++++++++++++---- src/hydrate.ts | 56 +++++- src/router.test.ts | 58 +++--- src/stores/app/app.ts | 10 + src/stores/app/index.ts | 4 + src/stores/collections/collections.test.ts | 179 ++++++++++++++++++ src/stores/collections/collections.ts | 9 +- src/stores/fields/fields.test.ts | 120 ++++++++++++ src/stores/fields/fields.ts | 53 ++++++ src/stores/fields/index.ts | 4 + src/stores/fields/types.ts | 39 ++++ src/stores/projects/projects.test.ts | 4 - src/stores/requests/requests.ts | 3 - .../module-bar-logo/module-bar-logo.test.ts | 70 +++---- .../module-bar-logo/module-bar-logo.vue | 4 +- src/views/public/public-view.test.ts | 2 +- 18 files changed, 709 insertions(+), 131 deletions(-) create mode 100644 src/stores/app/app.ts create mode 100644 src/stores/app/index.ts create mode 100644 src/stores/collections/collections.test.ts create mode 100644 src/stores/fields/fields.test.ts create mode 100644 src/stores/fields/fields.ts create mode 100644 src/stores/fields/index.ts create mode 100644 src/stores/fields/types.ts diff --git a/src/auth.test.ts b/src/auth.test.ts index d5e0e9bec7..8fe94d7d79 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -1,10 +1,10 @@ import Vue from 'vue'; import VueCompositionAPI from '@vue/composition-api'; -import * as hydration from '@/hydrate'; import api from '@/api'; import { checkAuth, login, logout, LogoutReason } from './auth'; -import { useProjectsStore } from '@/stores/projects/'; +import { useProjectsStore } from '@/stores/projects'; import router from '@/router'; +import { hydrate, dehydrate } from '@/hydrate'; jest.mock('@/api'); jest.mock('@/router'); @@ -20,13 +20,13 @@ describe('Auth', () => { beforeEach(() => { jest.spyOn(api, 'get'); jest.spyOn(api, 'post'); - jest.spyOn(hydration, 'hydrate'); - jest.spyOn(hydration, 'dehydrate'); }); describe('checkAuth', () => { it('Does not ping the API if the curent project key is null', async () => { - useProjectsStore({}).state.currentProjectKey = null; + const projectsStore = useProjectsStore({}); + projectsStore.state.currentProjectKey = null; + (api.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: { data: { authenticated: true } } }) ); @@ -35,7 +35,9 @@ describe('Auth', () => { }); it('Calls the api with the correct endpoint', async () => { - useProjectsStore({}).state.currentProjectKey = 'test-project'; + const projectsStore = useProjectsStore({}); + projectsStore.state.currentProjectKey = 'test-project'; + (api.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: { data: { authenticated: true } } }) ); @@ -44,7 +46,9 @@ describe('Auth', () => { }); it('Returns true if user is logged in', async () => { - useProjectsStore({}).state.currentProjectKey = 'test-project'; + const projectsStore = useProjectsStore({}); + projectsStore.state.currentProjectKey = 'test-project'; + (api.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: { data: { authenticated: true } } }) ); @@ -53,7 +57,9 @@ describe('Auth', () => { }); it('Returns false if user is logged out', async () => { - useProjectsStore({}).state.currentProjectKey = 'test-project'; + const projectsStore = useProjectsStore({}); + projectsStore.state.currentProjectKey = 'test-project'; + (api.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: { data: { authenticated: false } } }) ); @@ -64,7 +70,9 @@ describe('Auth', () => { describe('login', () => { it('Calls /auth/authenticate with the provided credentials', async () => { - useProjectsStore({}).state.currentProjectKey = 'test-project'; + const projectsStore = useProjectsStore({}); + projectsStore.state.currentProjectKey = 'test-project'; + await login({ email: 'test', password: 'test' }); expect(api.post).toHaveBeenCalledWith('/test-project/auth/authenticate', { mode: 'cookie', @@ -74,9 +82,12 @@ describe('Auth', () => { }); it('Calls hydrate on successful login', async () => { - useProjectsStore({}).state.currentProjectKey = 'test-project'; + const projectsStore = useProjectsStore({}); + projectsStore.state.currentProjectKey = 'test-project'; + await login({ email: 'test', password: 'test' }); - expect(hydration.hydrate).toHaveBeenCalled(); + + expect(hydrate).toHaveBeenCalled(); }); }); @@ -84,17 +95,21 @@ describe('Auth', () => { it('Does not do anything when there is no current project', async () => { useProjectsStore({}); await logout(); - expect(hydration.dehydrate).not.toHaveBeenCalled(); + expect(dehydrate).not.toHaveBeenCalled(); }); it('Calls dehydrate', async () => { - useProjectsStore({}).state.currentProjectKey = 'test-project'; + const projectsStore = useProjectsStore({}); + projectsStore.state.currentProjectKey = 'test-project'; + await logout(); - expect(hydration.dehydrate).toHaveBeenCalled(); + expect(dehydrate).toHaveBeenCalled(); }); it('Posts to /logout on regular sign out', async () => { - useProjectsStore({}).state.currentProjectKey = 'test-project'; + const projectsStore = useProjectsStore({}); + projectsStore.state.currentProjectKey = 'test-project'; + await logout(); expect(api.post).toHaveBeenCalledWith('/test-project/auth/logout'); }); @@ -102,6 +117,7 @@ describe('Auth', () => { it('Navigates to the current projects login page', async () => { const projectsStore = useProjectsStore({}); projectsStore.state.currentProjectKey = 'my-project'; + await logout(); expect(router.push).toHaveBeenCalledWith({ path: '/my-project/login', @@ -114,6 +130,7 @@ describe('Auth', () => { 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(); }); @@ -121,6 +138,7 @@ describe('Auth', () => { 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', diff --git a/src/auth.ts b/src/auth.ts index 48fd3f5b92..37505b1df3 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,7 +1,7 @@ import { RawLocation } from 'vue-router'; import { useProjectsStore } from '@/stores/projects/'; import api from '@/api'; -import { hydrate, dehydrate } from './hydrate'; +import { hydrate, dehydrate } from '@/hydrate'; import router from '@/router'; /** diff --git a/src/hydrate.test.ts b/src/hydrate.test.ts index 21695d015e..9024f34f6a 100644 --- a/src/hydrate.test.ts +++ b/src/hydrate.test.ts @@ -1,61 +1,172 @@ -import { useCollectionsStore } from './stores/collections/'; -import { hydrated, hydrate, dehydrate } from './hydrate'; - import Vue from 'vue'; import VueCompositionAPI from '@vue/composition-api'; +import { useAppStore } from '@/stores/app'; +import { useStores, hydrate, dehydrate } from './hydrate'; -describe('Hydration', () => { +describe('Stores / App', () => { beforeAll(() => { Vue.use(VueCompositionAPI); }); + describe('useStores', () => { + it('Calls all functions', () => { + const mockStores: any = [jest.fn(), jest.fn()]; + useStores(mockStores); + expect(mockStores[0]).toHaveBeenCalled(); + expect(mockStores[1]).toHaveBeenCalled(); + }); + + it('Defaults to a global set of stores', () => { + expect(useStores).not.toThrow(); + }); + }); + describe('Hydrate', () => { - it('Calls the correct stores', async () => { - const collectionsStore = useCollectionsStore({}); - collectionsStore.getCollections = jest.fn(); - await hydrate(); - expect(collectionsStore.getCollections).toHaveBeenCalled(); + it('Sets hydrating state during hydration', async () => { + const appStore = useAppStore({}); + + expect(appStore.state.hydrating).toBe(false); + + const promise = hydrate([ + { + id: 'test1', + hydrate() { + return new Promise(resolve => { + setTimeout(() => resolve(), 15); + }); + } + } + ]); + + expect(appStore.state.hydrating).toBe(true); + + promise.then(() => { + expect(appStore.state.hydrating).toBe(false); + }); }); - it('Sets hydrated let after it is done', async () => { - await hydrate(); - expect(hydrated).toBe(true); + it('Calls the hydrate function for all stores', async () => { + const mockStores: any = [ + { + hydrate: jest.fn(() => Promise.resolve()) + }, + { + dehydrate: jest.fn(() => Promise.resolve()) + }, + { + hydrate: jest.fn(() => Promise.resolve()), + dehydrate: jest.fn(() => Promise.resolve()) + } + ]; + + useAppStore({}); + + await hydrate(mockStores); + + expect(mockStores[0].hydrate).toHaveBeenCalled(); + expect(mockStores[2].hydrate).toHaveBeenCalled(); }); - it('Does not hydrate when already hydrated', async () => { - await hydrate(); + it('Sets the hydrated state to true when done', async () => { + const appStore = useAppStore({}); - const collectionsStore = useCollectionsStore({}); - collectionsStore.getCollections = jest.fn(); + await hydrate([]); - await hydrate(); + expect(appStore.state.hydrated).toBe(true); + }); - expect(collectionsStore.getCollections).not.toHaveBeenCalled(); + it('Does not hydrate when hydrated is true', async () => { + const appStore = useAppStore({}); + appStore.state.hydrated = true; + + const mockStores: any = [ + { + hydrate: jest.fn(() => Promise.resolve()) + } + ]; + + await hydrate([]); + + expect(mockStores[0].hydrate).not.toHaveBeenCalled(); + }); + + it('Does not hydrate when hydrating is true', async () => { + const appStore = useAppStore({}); + appStore.state.hydrating = true; + + const mockStores: any = [ + { + hydrate: jest.fn(() => Promise.resolve()) + } + ]; + + await hydrate(mockStores); + + expect(mockStores[0].hydrate).not.toHaveBeenCalled(); + }); + + it('Sets the error state when one of the hydration functions fails', async () => { + const mockStores: any = [ + { + hydrate: jest.fn(() => { + throw 'test'; + }) + } + ]; + + const appStore = useAppStore({}); + + await hydrate(mockStores); + + expect(appStore.state.error).toBe('test'); }); }); describe('Dehydrate', () => { - it('Calls resets functions of correct stores', async () => { - const collectionsStore = useCollectionsStore({}); - collectionsStore.reset = jest.fn(); - await dehydrate(); - expect(collectionsStore.reset).toHaveBeenCalled(); + it('Calls the dehydrate function for all stores', async () => { + const mockStores: any = [ + { + dehydrate: jest.fn(() => Promise.resolve()) + }, + {}, + { + dehydrate: jest.fn(() => Promise.resolve()) + } + ]; + + const appStore = useAppStore({}); + appStore.state.hydrated = true; + + await dehydrate(mockStores); + + expect(mockStores[0].dehydrate).toHaveBeenCalled(); + expect(mockStores[2].dehydrate).toHaveBeenCalled(); }); - it('Sets hydrated let after it is done', async () => { - await dehydrate(); - expect(hydrated).toBe(false); + it('Sets the hydrated state to false when done', async () => { + const mockStores: any = [{}]; + + const appStore = useAppStore({}); + appStore.state.hydrated = true; + + await dehydrate(mockStores); + + expect(appStore.state.hydrated).toBe(false); }); - it('Does not dehydrate when already dehydrated', async () => { - await dehydrate(); + it('Does not dehydrate when store is already dehydrated', async () => { + const mockStores: any = [ + { + dehydrate: jest.fn(() => Promise.resolve()) + } + ]; - const collectionsStore = useCollectionsStore({}); - collectionsStore.reset = jest.fn(); + const appStore = useAppStore({}); + appStore.state.hydrated = false; - await dehydrate(); + await dehydrate(mockStores); - expect(collectionsStore.reset).not.toHaveBeenCalled(); + expect(mockStores[0].dehydrate).not.toHaveBeenCalled(); }); }); }); diff --git a/src/hydrate.ts b/src/hydrate.ts index 22e780227d..fee332102a 100644 --- a/src/hydrate.ts +++ b/src/hydrate.ts @@ -1,15 +1,51 @@ -import { useCollectionsStore } from '@/stores/collections'; +import { useAppStore } from '@/stores/app/'; +import { useCollectionsStore } from '@/stores/collections/'; +import { useFieldsStore } from '@/stores/fields/'; +import { useRequestsStore } from '@/stores/requests/'; -export let hydrated = false; +type GenericStore = { + id: string; + hydrate?: () => Promise; + dehydrate?: () => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; -export async function hydrate() { - if (hydrated) return; - await useCollectionsStore().getCollections(); - hydrated = true; +export function useStores(stores = [useCollectionsStore, useFieldsStore, useRequestsStore]) { + return stores.map(useStore => useStore()) as GenericStore[]; } -export async function dehydrate() { - if (hydrated === false) return; - useCollectionsStore().reset(); - hydrated = false; +/* istanbul ignore next: useStores has a test already */ +export async function hydrate(stores = useStores()) { + const appStore = useAppStore(); + + if (appStore.state.hydrated) return; + if (appStore.state.hydrating) return; + + appStore.state.hydrating = true; + + try { + for (const store of stores) { + await store.hydrate?.(); + } + } catch (error) { + appStore.state.error = error; + } finally { + appStore.state.hydrating = false; + } + + appStore.state.hydrated = true; +} + +/* istanbul ignore next: useStores has a test already */ +export async function dehydrate(stores = useStores()) { + const appStore = useAppStore(); + + if (appStore.state.hydrated === false) return; + + for (const store of stores) { + await store.dehydrate?.(); + } + + appStore.state.hydrated = false; } diff --git a/src/router.test.ts b/src/router.test.ts index 306e8f7219..85ad906947 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -1,7 +1,6 @@ import Vue from 'vue'; import VueCompositionAPI from '@vue/composition-api'; import { Route } from 'vue-router'; -import * as hydration from '@/hydrate'; import { onBeforeEach, onBeforeEnterProjectChooser, @@ -9,12 +8,13 @@ import { defaultRoutes, onBeforeEnterLogout } from './router'; -import { useProjectsStore } from '@/stores/projects'; import api from '@/api'; import * as auth from '@/auth'; +import { useProjectsStore } from '@/stores/projects'; +import { hydrate } from '@/hydrate'; -jest.mock('@/hydrate'); jest.mock('@/auth'); +jest.mock('@/hydrate'); const route: Route = { name: undefined, @@ -39,16 +39,18 @@ describe('Router', () => { }); it('Fetches the projects using projectsStore on first load', async () => { - const projectsStore = useProjectsStore({}); - projectsStore.getProjects = jest.fn(); - const toRoute = route; + const fromRoute = { ...route, name: null }; + const callback = jest.fn(); + const projectsStore = useProjectsStore({}); + jest.spyOn(projectsStore, 'getProjects'); + await onBeforeEach(toRoute, fromRoute as any, callback); expect(projectsStore.getProjects).toHaveBeenCalled(); @@ -85,7 +87,8 @@ describe('Router', () => { it('Keeps projects store in sync with project in route', async () => { const projectsStore = useProjectsStore({}); - projectsStore.setCurrentProject = jest.fn(); + + jest.spyOn(projectsStore, 'setCurrentProject'); const toRoute = { ...route, @@ -102,8 +105,7 @@ describe('Router', () => { }); it('Redirects to / when trying to open non-existing project', async () => { - const projectsStore = useProjectsStore({}); - projectsStore.setCurrentProject = jest.fn(() => Promise.resolve(false)); + useProjectsStore({}); const toRoute = { ...route, @@ -121,8 +123,7 @@ describe('Router', () => { }); it('Does not redirect to / when trying to open /', async () => { - const projectsStore = useProjectsStore({}); - projectsStore.setCurrentProject = jest.fn(() => Promise.resolve(false)); + useProjectsStore({}); const toRoute = { ...route, @@ -141,7 +142,7 @@ describe('Router', () => { it('Calls next when trying to open public route', async () => { const projectsStore = useProjectsStore({}); - projectsStore.getProjects = jest.fn(); + jest.spyOn(projectsStore, 'getProjects').mockResolvedValue(); const checkAuth = jest.spyOn(auth, 'checkAuth'); const toRoute = { @@ -163,14 +164,16 @@ describe('Router', () => { }); it('Checks if you are authenticated on first load', async () => { - const projectsStore = useProjectsStore({}); - projectsStore.getProjects = jest.fn(); jest.spyOn(auth, 'checkAuth').mockImplementation(() => Promise.resolve(false)); - (projectsStore.state.projects as any) = [ + + const projectsStore = useProjectsStore({}); + jest.spyOn(projectsStore, 'getProjects').mockResolvedValue(); + + projectsStore.state.projects = [ { key: 'my-project' } - ]; + ] as any; const to = { ...route, @@ -185,18 +188,18 @@ describe('Router', () => { await onBeforeEach(to, from as any, next); expect(auth.checkAuth).toHaveBeenCalled(); - 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) = [ + + const projectsStore = useProjectsStore({}); + projectsStore.state.projects = [ { key: 'my-project' } - ]; + ] as any; + jest.spyOn(projectsStore, 'getProjects').mockResolvedValue(); const to = { ...route, @@ -210,18 +213,19 @@ describe('Router', () => { await onBeforeEach(to, from as any, next); - expect(hydration.hydrate).toHaveBeenCalled(); + expect(hydrate).toHaveBeenCalled(); }); it('Calls next when all checks are done', async () => { - const projectsStore = useProjectsStore({}); - projectsStore.getProjects = jest.fn(); jest.spyOn(auth, 'checkAuth').mockImplementation(() => Promise.resolve(true)); - (projectsStore.state.projects as any) = [ + + const projectsStore = useProjectsStore({}); + projectsStore.state.projects = [ { key: 'my-project' } - ]; + ] as any; + jest.spyOn(projectsStore, 'getProjects').mockResolvedValue(); const to = { ...route, @@ -243,6 +247,8 @@ describe('Router', () => { it('Sets the current project to null on open', () => { const projectsStore = useProjectsStore({}); projectsStore.state.currentProjectKey = 'my-project'; + jest.spyOn(projectsStore, 'getProjects').mockResolvedValue(); + const to = { ...route, path: '/' }; const from = route; const next = jest.fn(); diff --git a/src/stores/app/app.ts b/src/stores/app/app.ts new file mode 100644 index 0000000000..a9f0612982 --- /dev/null +++ b/src/stores/app/app.ts @@ -0,0 +1,10 @@ +import { createStore } from 'pinia'; + +export const useAppStore = createStore({ + id: 'app', + state: () => ({ + hydrated: false, + hydrating: false, + error: null + }) +}); diff --git a/src/stores/app/index.ts b/src/stores/app/index.ts new file mode 100644 index 0000000000..ff3c5bf792 --- /dev/null +++ b/src/stores/app/index.ts @@ -0,0 +1,4 @@ +import { useAppStore } from './app'; + +export { useAppStore }; +export default useAppStore; diff --git a/src/stores/collections/collections.test.ts b/src/stores/collections/collections.test.ts new file mode 100644 index 0000000000..5ecaf9e23f --- /dev/null +++ b/src/stores/collections/collections.test.ts @@ -0,0 +1,179 @@ +import api from '@/api'; +import Vue from 'vue'; +import VueCompositionAPI from '@vue/composition-api'; +import formatTitle from '@directus/format-title'; +import i18n from '@/lang'; + +import { useProjectsStore } from '@/stores/projects'; +import { useCollectionsStore } from './collections'; + +jest.mock('@directus/format-title'); +jest.mock('@/api'); +jest.mock('@/lang'); + +describe('Stores / collections', () => { + let req: any = {}; + + beforeAll(() => { + Vue.config.productionTip = false; + Vue.config.devtools = false; + Vue.use(VueCompositionAPI); + }); + + beforeEach(() => { + req = {}; + }); + + describe('Getters / visibleCollections', () => { + it('Filters collections starting with directus_', () => { + const collectionsStore = useCollectionsStore(req); + collectionsStore.state.collections = [ + { + collection: 'test-1' + }, + { + collection: 'test-2' + }, + { + collection: 'directus_test' + }, + { + collection: 'test-3' + } + ] as any; + + expect(collectionsStore.visibleCollections).toEqual([ + { + collection: 'test-1' + }, + { + collection: 'test-2' + }, + { + collection: 'test-3' + } + ]); + }); + + it('Filters collections that have the hidden flag true', () => { + const collectionsStore = useCollectionsStore(req); + collectionsStore.state.collections = [ + { + collection: 'test-1', + hidden: true + }, + { + collection: 'test-2', + hidden: false + }, + { + collection: 'test-3', + hidden: null + } + ] as any; + + expect(collectionsStore.visibleCollections).toEqual([ + { + collection: 'test-2' + }, + { + collection: 'test-3' + } + ]); + }); + }); + + describe('Actions / Hydrate', () => { + it('Calls the right endpoint', async () => { + (api.get as jest.Mock).mockImplementation(() => + Promise.resolve({ + data: { + data: [] + } + }) + ); + + const projectsStore = useProjectsStore(req); + const collectionsStore = useCollectionsStore(req); + + projectsStore.state.currentProjectKey = 'my-project'; + await collectionsStore.hydrate(); + + expect(api.get).toHaveBeenCalledWith('/my-project/collections'); + }); + + it('Formats the title to use as name', async () => { + (api.get as jest.Mock).mockImplementation(() => + Promise.resolve({ + data: { + data: [ + { + collection: 'test_collection' + } + ] + } + }) + ); + + const projectsStore = useProjectsStore(req); + const collectionsStore = useCollectionsStore(req); + + projectsStore.state.currentProjectKey = 'my-project'; + await collectionsStore.hydrate(); + + expect(formatTitle).toHaveBeenCalledWith('test_collection'); + expect(collectionsStore.state.collections[0].hasOwnProperty('name')).toBe(true); + }); + + it('Registers the passed translations to i18n to be registered', async () => { + (api.get as jest.Mock).mockImplementation(() => + Promise.resolve({ + data: { + data: [ + { + collection: 'test_collection', + translation: [ + { + locale: 'en-US', + translation: 'Test collection' + }, + { + locale: 'nl-NL', + translation: 'Test verzameling' + } + ] + } + ] + } + }) + ); + + const projectsStore = useProjectsStore(req); + projectsStore.state.currentProjectKey = 'my-project'; + + const collectionsStore = useCollectionsStore(req); + await collectionsStore.hydrate(); + + expect(i18n.mergeLocaleMessage).toHaveBeenCalledWith('en-US', { + collections: { + test_collection: 'Test collection' + } + }); + + expect(i18n.mergeLocaleMessage).toHaveBeenCalledWith('nl-NL', { + collections: { + test_collection: 'Test verzameling' + } + }); + }); + }); + + describe('Actions/ Dehydrate', () => { + it('Calls reset on dehydrate', async () => { + const collectionsStore = useCollectionsStore(req); + jest.spyOn(collectionsStore, 'reset'); + await collectionsStore.dehydrate(); + expect(collectionsStore.reset).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/stores/collections/collections.ts b/src/stores/collections/collections.ts index 5f2d7201f4..15e51a2832 100644 --- a/src/stores/collections/collections.ts +++ b/src/stores/collections/collections.ts @@ -1,7 +1,7 @@ import { createStore } from 'pinia'; import api from '@/api'; import { Collection, CollectionRaw } from './types'; -import useProjectsStore from '@/stores/projects'; +import { useProjectsStore } from '@/stores/projects'; import i18n from '@/lang/'; import { notEmpty } from '@/utils/is-empty'; import VueI18n from 'vue-i18n'; @@ -20,9 +20,9 @@ export const useCollectionsStore = createStore({ } }, actions: { - async getCollections() { + async hydrate() { const projectsStore = useProjectsStore(); - const { currentProjectKey } = projectsStore.state; + const currentProjectKey = projectsStore.state.currentProjectKey; const response = await api.get(`/${currentProjectKey}/collections`); @@ -54,6 +54,9 @@ export const useCollectionsStore = createStore({ icon }; }); + }, + async dehydrate() { + this.reset(); } } }); diff --git a/src/stores/fields/fields.test.ts b/src/stores/fields/fields.test.ts new file mode 100644 index 0000000000..abccf9adaf --- /dev/null +++ b/src/stores/fields/fields.test.ts @@ -0,0 +1,120 @@ +import api from '@/api'; +import Vue from 'vue'; +import VueCompositionAPI from '@vue/composition-api'; +import formatTitle from '@directus/format-title'; +import i18n from '@/lang'; + +import { useProjectsStore } from '@/stores/projects'; +import { useFieldsStore } from './fields'; + +jest.mock('@directus/format-title'); +jest.mock('@/api'); +jest.mock('@/lang'); + +describe('Stores / Fields', () => { + let req: any = {}; + + beforeAll(() => { + Vue.config.productionTip = false; + Vue.config.devtools = false; + Vue.use(VueCompositionAPI); + }); + + beforeEach(() => { + req = {}; + }); + + describe('Hydrate', () => { + it('Calls the right endpoint', () => { + (api.get as jest.Mock).mockImplementation(() => + Promise.resolve({ + data: { + data: [] + } + }) + ); + + const projectsStore = useProjectsStore(req); + projectsStore.state.currentProjectKey = 'my-project'; + const fieldsStore = useFieldsStore(req); + + fieldsStore.hydrate().then(() => { + expect(api.get).toHaveBeenCalledWith('/my-project/fields'); + }); + }); + + it('Formats the title to use as name', async () => { + (api.get as jest.Mock).mockImplementation(() => + Promise.resolve({ + data: { + data: [ + { + field: 'test_field' + } + ] + } + }) + ); + + const projectsStore = useProjectsStore(req); + projectsStore.state.currentProjectKey = 'my-project'; + const fieldsStore = useFieldsStore(req); + + await fieldsStore.hydrate(); + + expect(formatTitle).toHaveBeenCalledWith('test_field'); + expect(fieldsStore.state.fields[0].hasOwnProperty('name')).toBe(true); + }); + + it('Registers the passed translations to i18n to be registered', async () => { + (api.get as jest.Mock).mockImplementation(() => + Promise.resolve({ + data: { + data: [ + { + field: 'test_field', + translation: [ + { + locale: 'en-US', + translation: 'Test field' + }, + { + locale: 'nl-NL', + translation: 'Test veld' + } + ] + } + ] + } + }) + ); + + const projectsStore = useProjectsStore(req); + projectsStore.state.currentProjectKey = 'my-project'; + const fieldsStore = useFieldsStore(req); + + await fieldsStore.hydrate(); + + expect(i18n.mergeLocaleMessage).toHaveBeenCalledWith('en-US', { + fields: { + test_field: 'Test field' + } + }); + + expect(i18n.mergeLocaleMessage).toHaveBeenCalledWith('nl-NL', { + fields: { + test_field: 'Test veld' + } + }); + }); + }); + + describe('Dehydrate', () => { + it('Calls reset on dehydrate', async () => { + const fieldsStore: any = useFieldsStore(req); + jest.spyOn(fieldsStore, 'reset'); + await fieldsStore.dehydrate(); + expect(fieldsStore.reset).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/stores/fields/fields.ts b/src/stores/fields/fields.ts new file mode 100644 index 0000000000..36657f01e2 --- /dev/null +++ b/src/stores/fields/fields.ts @@ -0,0 +1,53 @@ +import { createStore } from 'pinia'; +import { FieldRaw, Field } from './types'; +import api from '@/api'; +import { useProjectsStore } from '@/stores/projects'; +import VueI18n from 'vue-i18n'; +import { notEmpty } from '@/utils/is-empty'; +import { i18n } from '@/lang'; +import formatTitle from '@directus/format-title'; + +export const useFieldsStore = createStore({ + id: 'fields', + state: () => ({ + fields: [] as Field[] + }), + actions: { + async hydrate() { + const projectsStore = useProjectsStore(); + const currentProjectKey = projectsStore.state.currentProjectKey; + + const response = await api.get(`/${currentProjectKey}/fields`); + + const fields: FieldRaw[] = response.data.data; + + this.state.fields = fields.map(field => { + let name: string | VueI18n.TranslateResult; + + if (notEmpty(field.translation)) { + for (let i = 0; i < field.translation.length; i++) { + const { locale, translation } = field.translation[i]; + + i18n.mergeLocaleMessage(locale, { + fields: { + [field.field]: translation + } + }); + } + + name = i18n.t(`fields.${field.field}`); + } else { + name = formatTitle(field.field); + } + + return { + ...field, + name + }; + }); + }, + async dehydrate() { + this.reset(); + } + } +}); diff --git a/src/stores/fields/index.ts b/src/stores/fields/index.ts new file mode 100644 index 0000000000..a63fe857db --- /dev/null +++ b/src/stores/fields/index.ts @@ -0,0 +1,4 @@ +import { useFieldsStore } from './fields'; + +export { useFieldsStore }; +export default useFieldsStore; diff --git a/src/stores/fields/types.ts b/src/stores/fields/types.ts new file mode 100644 index 0000000000..791be81505 --- /dev/null +++ b/src/stores/fields/types.ts @@ -0,0 +1,39 @@ +import VueI18n from 'vue-i18n'; + +type Translation = { + locale: string; + translation: string; +}; + +type Width = 'half' | 'half-left' | 'half-right' | 'full' | 'fill'; + +export interface FieldRaw { + id: number; + collection: string; + field: string; + datatype: string; + unique: boolean; + primary_key: boolean; + auto_increment: boolean; + default_value: any; // eslint-disable-line @typescript-eslint/no-explicit-any + note: string; + signed: boolean; + type: string; + sort: null | number; + interface: string; + hidden_detail: boolean; + hidden_browse: boolean; + required: boolean; + options: null | { [key: string]: any }; // eslint-disable-line @typescript-eslint/no-explicit-any + locked: boolean; + translation: null | Translation[]; + readonly: boolean; + width: null | Width; + validaton: string; + group: number; + length: string | number; +} + +export interface Field extends FieldRaw { + name: string | VueI18n.TranslateResult; +} diff --git a/src/stores/projects/projects.test.ts b/src/stores/projects/projects.test.ts index 447b3e1398..43130e0235 100644 --- a/src/stores/projects/projects.test.ts +++ b/src/stores/projects/projects.test.ts @@ -8,10 +8,6 @@ describe('Stores / Projects', () => { Vue.use(VueCompositionAPI); }); - afterEach(() => { - jest.clearAllMocks(); - }); - describe('Getters / currentProject', () => { const dummyProject = { key: 'my-project', diff --git a/src/stores/requests/requests.ts b/src/stores/requests/requests.ts index 0395814310..8e0562f04b 100644 --- a/src/stores/requests/requests.ts +++ b/src/stores/requests/requests.ts @@ -10,9 +10,6 @@ export const useRequestsStore = createStore({ queueHasItems: state => state.queue.length > 0 }, actions: { - reset() { - this.state.queue = []; - }, startRequest() { const id = nanoid(); this.state.queue = [...this.state.queue, id]; diff --git a/src/views/private-view/module-bar-logo/module-bar-logo.test.ts b/src/views/private-view/module-bar-logo/module-bar-logo.test.ts index ec0d88358f..28804a6b1f 100644 --- a/src/views/private-view/module-bar-logo/module-bar-logo.test.ts +++ b/src/views/private-view/module-bar-logo/module-bar-logo.test.ts @@ -1,30 +1,22 @@ -import { mount, createLocalVue, Wrapper } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueCompositionAPI from '@vue/composition-api'; import ModuleBarLogo from './module-bar-logo.vue'; -import { useProjectsStore } from '@/stores/projects'; import { useRequestsStore } from '@/stores/requests'; +import { useProjectsStore } from '@/stores/projects'; const localVue = createLocalVue(); localVue.use(VueCompositionAPI); describe('Views / Private / Module Bar / Logo', () => { - let component: Wrapper; - const projectsStore = useProjectsStore(); - const requestsStore = useRequestsStore(); - - beforeEach(() => { - component = mount(ModuleBarLogo, { localVue }); - projectsStore.reset(); - requestsStore.reset(); - }); - - it('Renders the default rabbit when were not in a project', () => { + it('Renders the default rabbit when we are not in a project', () => { + const component = shallowMount(ModuleBarLogo, { localVue }); expect((component.vm as any).customLogoPath).toBe(null); }); it('Renders the default rabbit when the current project errored out', () => { - projectsStore.state.projects = [ - { + const projectsStore = useProjectsStore({}); + projectsStore.currentProject = { + value: { key: 'my-project', status: 500, error: { @@ -32,14 +24,17 @@ describe('Views / Private / Module Bar / Logo', () => { message: 'Could not connect to the database' } } - ]; - projectsStore.state.currentProjectKey = 'my-project'; + }; + + const component = shallowMount(ModuleBarLogo, { localVue }); + expect((component.vm as any).customLogoPath).toBe(null); }); it('Renders the default rabbit when the current project does not have a custom logo', () => { - projectsStore.state.projects = [ - { + const projectsStore = useProjectsStore({}); + projectsStore.currentProject = { + value: { key: 'my-project', api: { requires2FA: false, @@ -53,14 +48,17 @@ describe('Views / Private / Module Bar / Logo', () => { project_logo: null } } - ]; - projectsStore.state.currentProjectKey = 'my-project'; + }; + + const component = shallowMount(ModuleBarLogo, { localVue }); + expect((component.vm as any).customLogoPath).toBe(null); }); - it('Renders the custom logo if set', async () => { - projectsStore.state.projects = [ - { + it('Renders the custom logo if set', () => { + const projectsStore = useProjectsStore({}); + projectsStore.currentProject = { + value: { key: 'my-project', api: { requires2FA: false, @@ -77,27 +75,31 @@ describe('Views / Private / Module Bar / Logo', () => { } } } - ]; - projectsStore.state.currentProjectKey = 'my-project'; - await component.vm.$nextTick(); + }; + + const component = shallowMount(ModuleBarLogo, { localVue }); + expect((component.vm as any).customLogoPath).toBe('abc'); expect(component.find('img').attributes().src).toBe('abc'); }); - it('Only stops running if the queue is empty', async () => { - requestsStore.state.queue = []; - await component.vm.$nextTick(); + it('Only stops running if the queue is empty', () => { + const requestsStore = useRequestsStore({}); + requestsStore.queueHasItems = { value: false }; + + let component = shallowMount(ModuleBarLogo, { localVue }); (component.vm as any).isRunning = true; (component.vm as any).stopRunningIfQueueIsEmpty(); expect((component.vm as any).isRunning).toBe(false); - requestsStore.state.queue = ['abc']; - await component.vm.$nextTick(); + requestsStore.queueHasItems = { value: true }; + component = shallowMount(ModuleBarLogo, { localVue }); expect((component.vm as any).isRunning).toBe(true); (component.vm as any).stopRunningIfQueueIsEmpty(); expect((component.vm as any).isRunning).toBe(true); - requestsStore.state.queue = []; - await component.vm.$nextTick(); + + requestsStore.queueHasItems = { value: false }; + component = shallowMount(ModuleBarLogo, { localVue }); (component.vm as any).stopRunningIfQueueIsEmpty(); expect((component.vm as any).isRunning).toBe(false); }); diff --git a/src/views/private-view/module-bar-logo/module-bar-logo.vue b/src/views/private-view/module-bar-logo/module-bar-logo.vue index 669a087217..78898774b2 100644 --- a/src/views/private-view/module-bar-logo/module-bar-logo.vue +++ b/src/views/private-view/module-bar-logo/module-bar-logo.vue @@ -12,9 +12,9 @@