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:
Rijk van Zanten
2020-02-28 16:21:51 -05:00
committed by GitHub
parent 3a76455776
commit 28531b531b
27 changed files with 702 additions and 108 deletions

View File

@@ -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",

View File

@@ -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
});
}
});

View File

@@ -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 });
}
}

View File

@@ -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: {

View File

@@ -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);
}
}

View File

@@ -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))` |

View File

@@ -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
View 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
View 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;
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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>

View 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');
});
});

View 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 };
}

View File

@@ -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'

View 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');
});
});

View 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>

View File

@@ -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(() => []);

View File

@@ -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`);
}
}

View File

@@ -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 {

View 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
};
});
}
}
});

View File

@@ -0,0 +1,4 @@
import { useCollectionsStore } from './collections';
export { useCollectionsStore };
export default useCollectionsStore;

View 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;
}

View File

@@ -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: () => ({

View File

@@ -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 {

View File

@@ -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"