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:
Rijk van Zanten
2020-03-17 16:25:43 -04:00
committed by GitHub
parent b26b91f785
commit 346e6f95ce
23 changed files with 252 additions and 55 deletions

50
src/app.vue Normal file
View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,5 +31,5 @@ export default function useNavigation() {
});
});
return { navItems: navItems.value };
return { navItems: navItems };
}

View File

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

View File

@@ -19,6 +19,7 @@ import api from '@/api';
import CollectionsNavigation from '../../components/navigation/';
export default defineComponent({
name: 'collections-detail',
components: { CollectionsNavigation },
props: {
collection: {

View File

@@ -24,6 +24,7 @@ import useNavigation, { NavItem } from '../../compositions/use-navigation';
import router from '@/router';
export default defineComponent({
name: 'collections-overview',
components: {
CollectionsNavigation
},

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
import ProjectError from './project-error.vue';
export { ProjectError };
export default ProjectError;

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
import ProjectChooser from './project-chooser.vue';
export { ProjectChooser };
export default ProjectChooser;

View File

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

View File

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

View File

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