mirror of
https://github.com/directus/directus.git
synced 2026-01-28 11:18:03 -05:00
Project switching (#206)
* Register notice component globally * Render button as flex in full width * Add buttons to / route * Rename block->full-width * Add hyrate overlay / project chooser placeholder * Make routes named * Dehydrate / hydrate when switching projects * Add choose project buttons to / route * Add main app component and hydration loader effect * Improve routing flow * Remove unused import statement * Fix test
This commit is contained in:
50
src/app.vue
Normal file
50
src/app.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<transition name="fade">
|
||||
<div class="hydrating" v-show="hydrating">
|
||||
<v-progress-circular indeterminate />
|
||||
</div>
|
||||
</transition>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs } from '@vue/composition-api';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const appStore = useAppStore();
|
||||
const { hydrating } = toRefs(appStore.state);
|
||||
|
||||
return { hydrating };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hydrating {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--medium) var(--transition);
|
||||
}
|
||||
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -12,8 +12,12 @@ export async function checkAuth() {
|
||||
|
||||
if (!currentProjectKey) return false;
|
||||
|
||||
const response = await api.get(`/${currentProjectKey}/auth/check`);
|
||||
return response.data.data.authenticated;
|
||||
try {
|
||||
const response = await api.get(`/${currentProjectKey}/auth/check`);
|
||||
return response.data.data.authenticated;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type LoginCredentials = {
|
||||
|
||||
@@ -17,6 +17,7 @@ import VList, {
|
||||
VListItemTitle,
|
||||
VListGroup
|
||||
} from './v-list/';
|
||||
import VNotice from './v-notice/';
|
||||
import VOverlay from './v-overlay/';
|
||||
import VProgressLinear from './v-progress/linear/';
|
||||
import VProgressCircular from './v-progress/circular/';
|
||||
@@ -42,6 +43,7 @@ Vue.component('v-list-item-icon', VListItemIcon);
|
||||
Vue.component('v-list-item-subtitle', VListItemSubtitle);
|
||||
Vue.component('v-list-item-title', VListItemTitle);
|
||||
Vue.component('v-list-group', VListGroup);
|
||||
Vue.component('v-notice', VNotice);
|
||||
Vue.component('v-overlay', VOverlay);
|
||||
Vue.component('v-progress-linear', VProgressLinear);
|
||||
Vue.component('v-progress-circular', VProgressCircular);
|
||||
|
||||
@@ -33,15 +33,15 @@ describe('Button', () => {
|
||||
expect(component.classes()).toContain('outlined');
|
||||
});
|
||||
|
||||
it('Adds the block class for block buttons', () => {
|
||||
it('Adds the full-width class for full-width buttons', () => {
|
||||
const component = mount(VButton, {
|
||||
localVue,
|
||||
propsData: {
|
||||
block: true
|
||||
fullWidth: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(component.classes()).toContain('block');
|
||||
expect(component.classes()).toContain('full-width');
|
||||
});
|
||||
|
||||
it('Adds the rounded class for rounded buttons', () => {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
:is="component"
|
||||
active-class="activated"
|
||||
class="v-button"
|
||||
:class="[sizeClass, { block, rounded, icon, outlined, loading, secondary }]"
|
||||
:class="[
|
||||
sizeClass,
|
||||
{ 'full-width': fullWidth, rounded, icon, outlined, loading, secondary }
|
||||
]"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:to="to"
|
||||
@@ -25,7 +28,7 @@ import useSizeClass, { sizeProps } from '@/compositions/size-class';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
block: {
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
@@ -140,8 +143,8 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
&.block {
|
||||
display: block;
|
||||
&.full-width {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,12 @@ import './modules/register';
|
||||
import './layouts/register';
|
||||
import './interfaces/register';
|
||||
|
||||
import App from './app.vue';
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
|
||||
new Vue({
|
||||
render: h => h('router-view'),
|
||||
render: h => h(App),
|
||||
router,
|
||||
i18n
|
||||
}).$mount('#app');
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Modules / Collections / Compositions / useNavigation', () => {
|
||||
navItems = useNavigation().navItems;
|
||||
});
|
||||
|
||||
expect(navItems).toEqual([
|
||||
expect(navItems.value).toEqual([
|
||||
{
|
||||
collection: 'test',
|
||||
name: 'Test',
|
||||
@@ -89,8 +89,8 @@ describe('Modules / Collections / Compositions / useNavigation', () => {
|
||||
navItems = useNavigation().navItems;
|
||||
});
|
||||
|
||||
expect(navItems[0].name).toBe('A Test');
|
||||
expect(navItems[1].name).toBe('B Test');
|
||||
expect(navItems[2].name).toBe('C Test');
|
||||
expect(navItems.value[0].name).toBe('A Test');
|
||||
expect(navItems.value[1].name).toBe('B Test');
|
||||
expect(navItems.value[2].name).toBe('C Test');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,5 +31,5 @@ export default function useNavigation() {
|
||||
});
|
||||
});
|
||||
|
||||
return { navItems: navItems.value };
|
||||
return { navItems: navItems };
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Collection } from '@/stores/collections/types';
|
||||
import CollectionsNavigation from '../../components/navigation/';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'collections-browse',
|
||||
components: { CollectionsNavigation },
|
||||
props: {
|
||||
collection: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import api from '@/api';
|
||||
import CollectionsNavigation from '../../components/navigation/';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'collections-detail',
|
||||
components: { CollectionsNavigation },
|
||||
props: {
|
||||
collection: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import useNavigation, { NavItem } from '../../compositions/use-navigation';
|
||||
import router from '@/router';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'collections-overview',
|
||||
components: {
|
||||
CollectionsNavigation
|
||||
},
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useProjectsStore } from '@/stores/projects';
|
||||
import LoginRoute from '@/routes/login';
|
||||
import ProjectChooserRoute from '@/routes/project-chooser';
|
||||
import { checkAuth, logout } from '@/auth';
|
||||
import { hydrate } from '@/hydrate';
|
||||
import { hydrate, dehydrate } from '@/hydrate';
|
||||
import useAppStore from '@/stores/app';
|
||||
|
||||
export const onBeforeEnterProjectChooser: NavigationGuard = (to, from, next) => {
|
||||
const projectsStore = useProjectsStore();
|
||||
@@ -94,13 +95,10 @@ export function replaceRoutes(routeFilter: (routes: RouteConfig[]) => RouteConfi
|
||||
|
||||
export const onBeforeEach: NavigationGuard = async (to, from, next) => {
|
||||
const projectsStore = useProjectsStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
// Only on first load is from.name null. On subsequent requests, from.name is undefined | string
|
||||
const firstLoad = from.name === null;
|
||||
|
||||
// Before we do anything, we have to make sure we're aware of the projects that exist in the
|
||||
// platform. We can also use this to (async) register all the globally available modules
|
||||
if (firstLoad) {
|
||||
// Make sure the projects store is aware of all projects that exist
|
||||
if (projectsStore.state.projects === null) {
|
||||
await projectsStore.getProjects();
|
||||
}
|
||||
|
||||
@@ -112,26 +110,28 @@ export const onBeforeEach: NavigationGuard = async (to, from, next) => {
|
||||
|
||||
// Keep the projects store currentProjectKey in sync with the route
|
||||
if (to.params.project && projectsStore.state.currentProjectKey !== to.params.project) {
|
||||
// If the store is hydrated for the current project, make sure to dehydrate it
|
||||
if (appStore.state.hydrated === true) {
|
||||
await dehydrate();
|
||||
}
|
||||
|
||||
const projectExists = await projectsStore.setCurrentProject(to.params.project);
|
||||
|
||||
// If the project you're trying to access doesn't exist, redirect to `/`
|
||||
if (to.path !== '/' && projectExists === false) {
|
||||
return next('/');
|
||||
}
|
||||
}
|
||||
|
||||
// If you're trying to load a full URL (including) project, redirect to the login page of that
|
||||
// project if you're not logged in yet. Otherwise, redirect to the
|
||||
if (firstLoad) {
|
||||
const loggedIn = await checkAuth();
|
||||
// The store can only be hydrated if you're an authenticated user. If the store is hydrated, we
|
||||
// can safely assume you're logged in
|
||||
if (appStore.state.hydrated === false) {
|
||||
const authenticated = await checkAuth();
|
||||
|
||||
if (loggedIn === true) {
|
||||
if (authenticated === true) {
|
||||
await hydrate();
|
||||
} else {
|
||||
if (to.meta?.public === true) {
|
||||
return next();
|
||||
} else {
|
||||
return next(`/${projectsStore.state.currentProjectKey}/login`);
|
||||
}
|
||||
} else if (to.meta?.public !== true) {
|
||||
return next(`/${to.params.project}/login`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<v-input autofocus type="email" v-model="email" :placeholder="$t('email')" full-width />
|
||||
<v-input type="password" v-model="password" :placeholder="$t('password')" full-width />
|
||||
<v-input
|
||||
autofocus
|
||||
autocomplete="username"
|
||||
type="email"
|
||||
v-model="email"
|
||||
:placeholder="$t('email')"
|
||||
full-width
|
||||
/>
|
||||
<v-input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
v-model="password"
|
||||
:placeholder="$t('password')"
|
||||
full-width
|
||||
/>
|
||||
<v-button type="submit" :loading="loggingIn" x-large>{{ $t('sign_in') }}</v-button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
4
src/routes/login/components/project-error/index.ts
Normal file
4
src/routes/login/components/project-error/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import ProjectError from './project-error.vue';
|
||||
|
||||
export { ProjectError };
|
||||
export default ProjectError;
|
||||
20
src/routes/login/components/project-error/project-error.vue
Normal file
20
src/routes/login/components/project-error/project-error.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template functional>
|
||||
<v-notice danger>[{{ props.status }}] {{ props.error }}</v-notice>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
error: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -3,6 +3,11 @@
|
||||
<h1 class="type-heading-large">{{ $t('sign_in') }}</h1>
|
||||
|
||||
<continue-as v-if="alreadyAuthenticated" />
|
||||
<project-error
|
||||
v-else-if="currentProject.error"
|
||||
:error="currentProject.error"
|
||||
:status="currentProject.status"
|
||||
/>
|
||||
<login-form v-else />
|
||||
|
||||
<template #notice>
|
||||
@@ -18,16 +23,21 @@ import { useUserStore } from '@/stores/user';
|
||||
import { notEmpty } from '@/utils/is-empty';
|
||||
import LoginForm from './components/login-form/';
|
||||
import ContinueAs from './components/continue-as/';
|
||||
import ProjectError from './components/project-error/';
|
||||
import useProjectsStore from '../../stores/projects';
|
||||
|
||||
export default defineComponent({
|
||||
components: { LoginForm, ContinueAs },
|
||||
components: { LoginForm, ContinueAs, ProjectError },
|
||||
setup() {
|
||||
const userStore = useUserStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const currentProject = projectsStore.currentProject;
|
||||
|
||||
const alreadyAuthenticated = computed<boolean>(() =>
|
||||
notEmpty(userStore.state.currentUser?.id)
|
||||
);
|
||||
|
||||
return { alreadyAuthenticated };
|
||||
return { alreadyAuthenticated, currentProject };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
<template>
|
||||
<public-view>
|
||||
<p>
|
||||
This is where you would be asked to choose the project you're trying to login to, or
|
||||
enter the project key if the project is private
|
||||
</p>
|
||||
<h1 class="type-heading-large">{{ $t('choose_project') }}</h1>
|
||||
|
||||
<v-button v-for="project in projects" :to="project.link" full-width :key="project.key">
|
||||
{{ (project.api && project.api.project_name) || project.key }}
|
||||
</v-button>
|
||||
</public-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
import { useProjectsStore } from '@/stores/projects';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'project-chooser',
|
||||
props: {},
|
||||
setup() {
|
||||
return {};
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const projects = projectsStore.state.projects?.map(project => ({
|
||||
...project,
|
||||
link: `/${project.key}/login`
|
||||
}));
|
||||
|
||||
return { projects };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
p {
|
||||
color: red;
|
||||
.v-button:not(:last-child) {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('Stores / Projects', () => {
|
||||
.mockImplementation(() => Promise.resolve({ data: { data: {} } }));
|
||||
await projectsStore.setCurrentProject('my-project');
|
||||
expect(spy).toHaveBeenCalledWith('/my-project/');
|
||||
expect(projectsStore.state.projects[0]).toEqual({ key: 'my-project' });
|
||||
expect(projectsStore.state.projects?.[0]).toEqual({ key: 'my-project' });
|
||||
});
|
||||
|
||||
it('Returns true if the project exists', async () => {
|
||||
|
||||
@@ -12,12 +12,12 @@ export const useProjectsStore = createStore({
|
||||
state: () => ({
|
||||
needsInstall: false,
|
||||
error: null as LoadingError,
|
||||
projects: [] as Projects,
|
||||
projects: null as Projects | null,
|
||||
currentProjectKey: null as string | null
|
||||
}),
|
||||
getters: {
|
||||
currentProject: (state): ProjectWithKey | ProjectError | null => {
|
||||
return state.projects.find(({ key }) => key === state.currentProjectKey) || null;
|
||||
return state.projects?.find(({ key }) => key === state.currentProjectKey) || null;
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
@@ -29,7 +29,8 @@ export const useProjectsStore = createStore({
|
||||
* Returns a boolean if the operation succeeded or not.
|
||||
*/
|
||||
async setCurrentProject(key: string): Promise<boolean> {
|
||||
const projectKeys = this.state.projects.map(project => project.key);
|
||||
const projects = this.state.projects || ([] as Projects);
|
||||
const projectKeys = projects.map(project => project.key);
|
||||
|
||||
if (projectKeys.includes(key) === false) {
|
||||
try {
|
||||
@@ -38,7 +39,7 @@ export const useProjectsStore = createStore({
|
||||
key: key,
|
||||
...projectInfoResponse.data.data
|
||||
};
|
||||
this.state.projects = [...this.state.projects, project];
|
||||
this.state.projects = [...projects, project];
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
4
src/views/private/components/project-chooser/index.ts
Normal file
4
src/views/private/components/project-chooser/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import ProjectChooser from './project-chooser.vue';
|
||||
|
||||
export { ProjectChooser };
|
||||
export default ProjectChooser;
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="project-chooser">
|
||||
<span>{{ currentProjectKey }}</span>
|
||||
<select :value="currentProjectKey" @change="navigateToProject">
|
||||
<option v-for="project in projects" :key="project.key" :value="project.key">
|
||||
{{ (project.api && project.api.project_name) || project.key }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs } from '@vue/composition-api';
|
||||
import { useProjectsStore } from '@/stores/projects';
|
||||
import router from '@/router';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const projectsStore = useProjectsStore();
|
||||
const { projects, currentProjectKey } = toRefs(projectsStore.state);
|
||||
|
||||
return {
|
||||
projects,
|
||||
currentProjectKey,
|
||||
navigateToProject,
|
||||
projectsStore
|
||||
};
|
||||
|
||||
function navigateToProject(event: InputEvent) {
|
||||
router
|
||||
.push(`/${(event.target as HTMLSelectElement).value}/collections`)
|
||||
/** @NOTE
|
||||
* Vue Router considers a navigation change _in_ the navigation guard a rejection
|
||||
* so when this push goes from /collections to /login, it will throw.
|
||||
* In order to prevent a useless uncaught exception to show up in the console,
|
||||
* we have to catch it here with a no-op. See
|
||||
* https://github.com/vuejs/vue-router/issues/2881#issuecomment-520554378
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-chooser {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 64px;
|
||||
background-color: var(--highlight);
|
||||
|
||||
select {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,10 +2,12 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import PrivateView from './private-view.vue';
|
||||
import VOverlay from '@/components/v-overlay';
|
||||
import VProgressCircular from '@/components/v-progress/circular';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-overlay', VOverlay);
|
||||
localVue.component('v-progress-circular', VProgressCircular);
|
||||
|
||||
describe('Views / Private', () => {
|
||||
it('Adds the is-open class to the nav', async () => {
|
||||
|
||||
@@ -8,11 +8,8 @@
|
||||
>
|
||||
<module-bar />
|
||||
<div class="module-nav alt-colors">
|
||||
<div
|
||||
style="height: 64px; padding: 20px; color: red; font-family: 'Comic Sans MS', cursive;"
|
||||
>
|
||||
PROJECT CHOOSER
|
||||
</div>
|
||||
<project-chooser />
|
||||
|
||||
<div class="module-nav-content">
|
||||
<slot name="navigation" />
|
||||
</div>
|
||||
@@ -58,13 +55,15 @@ import { defineComponent, ref, provide, computed } from '@vue/composition-api';
|
||||
import ModuleBar from './components/module-bar/';
|
||||
import DrawerDetailGroup from './components/drawer-detail-group/';
|
||||
import HeaderBar from './components/header-bar';
|
||||
import ProjectChooser from './components/project-chooser';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ModuleBar,
|
||||
DrawerDetailGroup,
|
||||
HeaderBar
|
||||
HeaderBar,
|
||||
ProjectChooser
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
|
||||
Reference in New Issue
Block a user