Private view (#41)

* Add file structure

* Add basics of private view

* Add composition api in every test

* Install nanoid

* Add request queue

* Register all global components

* Make bunny run on api queue

* Use private route for debug now

* Move request queue to store

* Remove unused sleep function in hover test

* Use new request queue in private view

* Remove jest pre-test config

* Finish logo + tests

* Add tests for private view

* Fix unhandled promise in api test
This commit is contained in:
Rijk van Zanten
2020-02-17 14:06:04 -05:00
committed by GitHub
parent 7cd1de564a
commit 62bc8663a0
21 changed files with 649 additions and 18 deletions

View File

@@ -18,11 +18,13 @@
"dependencies": {
"@types/debug": "^4.1.5",
"@types/lodash": "^4.14.149",
"@types/nanoid": "^2.1.0",
"@vue/composition-api": "^0.3.4",
"axios": "^0.19.2",
"date-fns": "^2.9.0",
"debug": "^4.1.1",
"lodash": "^4.17.15",
"nanoid": "^2.1.11",
"pinia": "0.0.5",
"v-tooltip": "^2.0.3",
"vue": "^2.6.11",

View File

@@ -1,8 +1,12 @@
import { getRootPath } from './api';
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import { onRequest, onResponse, onError, getRootPath } from './api';
import { useRequestsStore } from '@/stores/requests';
describe('API', () => {
beforeAll(() => {
globalThis.window = Object.create(window);
Vue.use(VueCompositionAPI);
});
it('Calculates the correct API root URL based on window', () => {
@@ -16,4 +20,46 @@ describe('API', () => {
const result = getRootPath();
expect(result).toBe('/api/nested/');
});
it('Calls startRequest on the store on any request', () => {
const store = useRequestsStore({});
const spy = jest.spyOn(store, 'startRequest');
spy.mockImplementation(() => 'abc');
const newRequest = onRequest({});
expect(spy).toHaveBeenCalled();
expect(newRequest.id).toBe('abc');
});
it('Calls endRequest on responses', () => {
const store = useRequestsStore({});
const spy = jest.spyOn(store, 'endRequest');
onResponse({
data: null,
status: 200,
statusText: 'OK',
headers: {},
config: {
id: 'abc'
}
});
expect(spy).toHaveBeenCalledWith('abc');
});
it('Calls endRequest on errors', async () => {
const store = useRequestsStore({});
const spy = jest.spyOn(store, 'endRequest');
try {
await onError({
response: {
config: {
id: 'abc'
}
}
});
} catch (error) {
expect(error).toEqual({ response: { config: { id: 'abc' } } });
}
expect(spy).toHaveBeenCalledWith('abc');
});
});

View File

@@ -1,9 +1,47 @@
import axios from 'axios';
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { useRequestsStore } from '@/stores/requests';
const api = axios.create({
baseURL: getRootPath()
});
interface RequestConfig extends AxiosRequestConfig {
id: string;
}
interface Response extends AxiosResponse {
config: RequestConfig;
}
export const onRequest = (config: AxiosRequestConfig) => {
const requestsStore = useRequestsStore();
const id = requestsStore.startRequest();
const requestConfig: RequestConfig = {
id: id,
...config
};
return requestConfig;
};
export const onResponse = (response: AxiosResponse | Response) => {
const requestsStore = useRequestsStore();
const id = (response.config as RequestConfig).id;
requestsStore.endRequest(id);
return response;
};
export const onError = (error: any) => {
const requestsStore = useRequestsStore();
const id = (error.response.config as RequestConfig).id;
requestsStore.endRequest(id);
return Promise.reject(error);
};
api.interceptors.request.use(onRequest);
api.interceptors.response.use(onResponse, onError);
export function getRootPath(): string {
const path = window.location.pathname;
const parts = path.split('/');

View File

@@ -0,0 +1,31 @@
import Vue from 'vue';
import VAvatar from './v-avatar/';
import VButton from './v-button/';
import VCheckbox from './v-checkbox/';
import VChip from './v-chip/';
import VHover from './v-hover/';
import VIcon from './v-icon/';
import VInput from './v-input/';
import VOverlay from './v-overlay/';
import VProgressLinear from './v-progress/linear/';
import VSheet from './v-sheet/';
import VSlider from './v-slider/';
import VSpinner from './v-spinner/';
import VSwitch from './v-switch/';
import VTable from './v-table/';
Vue.component('v-avatar', VAvatar);
Vue.component('v-button', VButton);
Vue.component('v-checkbox', VCheckbox);
Vue.component('v-chip', VChip);
Vue.component('v-hover', VHover);
Vue.component('v-icon', VIcon);
Vue.component('v-input', VInput);
Vue.component('v-overlay', VOverlay);
Vue.component('v-progress-linear', VProgressLinear);
Vue.component('v-sheet', VSheet);
Vue.component('v-slider', VSlider);
Vue.component('v-spinner', VSpinner);
Vue.component('v-switch', VSwitch);
Vue.component('v-table', VTable);

View File

@@ -5,12 +5,6 @@ import VHover from './v-hover.vue';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
function sleep(ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
describe('Hover', () => {
let component: Wrapper<Vue>;

View File

@@ -1,9 +1,11 @@
import Vue from 'vue';
import router from './router';
import i18n from './lang/';
import './styles/main.scss';
import './plugins';
import './components/register';
import router from './router';
import i18n from './lang/';
Vue.config.productionTip = false;

View File

@@ -1,16 +1,16 @@
<template>
<public-view>
<private-view>
Hello!
</public-view>
</private-view>
</template>
<script lang="ts">
import { createComponent } from '@vue/composition-api';
import PublicView from '@/views/public/';
import PrivateView from '@/views/private/';
export default createComponent({
components: {
PublicView
PrivateView
},
props: {},
setup() {

View File

@@ -2,7 +2,6 @@ import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import api from '@/api';
import { useProjectsStore } from './projects';
import { setActiveReq } from 'pinia';
describe('Stores / Projects', () => {
beforeAll(() => {

View File

@@ -0,0 +1,31 @@
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import { useRequestsStore } from './requests';
describe('Stores / Projects', () => {
beforeAll(() => {
Vue.use(VueCompositionAPI);
});
afterEach(() => {
jest.clearAllMocks();
});
it('Computes the queueHasItems state', () => {
const store = useRequestsStore({});
store.state.queue = ['abc', 'def', 'gh'];
expect(store.queueHasItems.value).toBe(true);
store.state.queue = [];
expect(store.queueHasItems.value).toBe(false);
});
test('Queue management', () => {
const store = useRequestsStore({});
expect(store.state.queue.length).toBe(0);
const id = store.startRequest();
expect(store.state.queue.length).toBe(1);
expect(id).toBe(store.state.queue[0]);
store.endRequest(id);
expect(store.state.queue.length).toBe(0);
});
});

22
src/stores/requests.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createStore } from 'pinia';
import nanoid from 'nanoid';
export const useRequestsStore = createStore({
id: 'requests',
state: () => ({
queue: [] as string[]
}),
getters: {
queueHasItems: state => state.queue.length > 0
},
actions: {
startRequest() {
const id = nanoid();
this.state.queue = [...this.state.queue, id];
return id;
},
endRequest(id: string) {
this.state.queue = this.state.queue.filter((queueID: string) => queueID !== id);
}
}
});

View File

@@ -1,8 +1,8 @@
$breakpoints: (
'small': (min-width: 600px),
'medium': (min-width: 960px),
'large': (min-width: 1264px),
'x-large': (min-width: 1903px)
'large': (min-width: 1260px),
'x-large': (min-width: 1900px)
) !default;
@mixin breakpoint($breakpoint) {

View File

@@ -0,0 +1,104 @@
import { mount, createLocalVue, Wrapper } 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';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
describe('Views / Private / Module Bar / Logo', () => {
let component: Wrapper<Vue>;
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', () => {
expect((component.vm as any).customLogoPath).toBe(null);
});
it('Renders the default rabbit when the current project errored out', () => {
projectsStore.state.projects = [
{
key: 'my-project',
status: 500,
error: {
code: 400,
message: 'Could not connect to the database'
}
}
];
projectsStore.state.currentProjectKey = 'my-project';
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 = [
{
key: 'my-project',
api: {
requires2FA: false,
project_foreground: null,
project_background: null,
project_color: '#abcdef',
project_public_note: '',
default_locale: 'en-US',
telemetry: false,
project_name: 'test',
project_logo: null
}
}
];
projectsStore.state.currentProjectKey = 'my-project';
expect((component.vm as any).customLogoPath).toBe(null);
});
it('Renders the custom logo if set', async () => {
projectsStore.state.projects = [
{
key: 'my-project',
api: {
requires2FA: false,
project_foreground: null,
project_background: null,
project_color: '#abcdef',
project_public_note: '',
default_locale: 'en-US',
telemetry: false,
project_name: 'test',
project_logo: {
full_url: 'abc',
url: 'abc'
}
}
}
];
projectsStore.state.currentProjectKey = 'my-project';
await component.vm.$nextTick();
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();
(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();
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();
(component.vm as any).stopRunningIfQueueIsEmpty();
expect((component.vm as any).isRunning).toBe(false);
});
});

View File

@@ -0,0 +1,89 @@
<template>
<div class="module-bar-logo">
<img class="custom-logo" v-if="customLogoPath" :src="customLogoPath" />
<div
v-else
class="logo"
:class="{ running: isRunning }"
@animationiteration="stopRunningIfQueueIsEmpty"
/>
</div>
</template>
<script lang="ts">
import { createComponent, ref, computed, watch } from '@vue/composition-api';
import { useProjectsStore, ProjectWithKey, ProjectError } from '@/stores/projects';
import { useRequestsStore } from '@/stores/requests';
export default createComponent({
setup() {
const projectsStore = useProjectsStore();
const requestsStore = useRequestsStore();
const customLogoPath = computed<string | null>(() => {
if (projectsStore.currentProject.value === null) return null;
if ((projectsStore.currentProject.value as ProjectError).error !== undefined) {
return null;
}
const currentProject = projectsStore.currentProject.value as ProjectWithKey;
return currentProject.api.project_logo?.full_url || null;
});
const isRunning = ref(false);
const queueHasItems = requestsStore.queueHasItems;
watch(
() => queueHasItems.value,
hasItems => {
if (hasItems) isRunning.value = true;
}
);
return { customLogoPath, isRunning, stopRunningIfQueueIsEmpty };
function stopRunningIfQueueIsEmpty() {
if (queueHasItems.value === false) isRunning.value = false;
}
}
});
</script>
<style lang="scss" scoped>
.module-bar-logo {
position: relative;
width: 64px;
height: 64px;
padding: 12px;
background-color: var(--brand);
.custom-logo {
display: block;
width: 40px;
height: 40px;
object-fit: contain;
}
.logo {
position: absolute;
top: 20px;
left: 12px;
width: 40px;
height: 32px;
margin: 0 auto;
background-image: url('../../assets/sprite.svg');
background-position: 0% 0%;
background-size: 600px 32px;
}
.running {
animation: 560ms run steps(14) infinite;
}
}
@keyframes run {
100% {
background-position: 100%;
}
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<div class="module-bar">
<module-bar-logo />
</div>
</template>
<script lang="ts">
import { createComponent } from '@vue/composition-api';
import ModuleBarLogo from './_module-bar-logo.vue';
export default createComponent({
components: {
ModuleBarLogo
},
setup() {}
});
</script>
<style lang="scss" scoped>
.module-bar {
display: inline-block;
width: 64px;
height: 100%;
font-size: 1rem;
background-color: #263238;
}
</style>

View File

@@ -0,0 +1,4 @@
import PrivateView from './private-view.vue';
export { PrivateView };
export default PrivateView;

View File

@@ -0,0 +1 @@
# Private View

View File

@@ -0,0 +1,19 @@
import Vue from 'vue';
import PrivateView from './private-view.vue';
import markdown from './private-view.readme.md';
Vue.component('private-view', PrivateView);
export default {
title: 'Views / Private',
component: PrivateView,
parameters: {
notes: markdown
}
};
export const basic = () => ({
template: `
<private-view />
`
});

View File

@@ -0,0 +1,62 @@
import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import PrivateView from './private-view.vue';
import VOverlay from '@/components/v-overlay';
import useWindowSize from '@/compositions/window-size';
let mockWidth = 50;
jest.mock('@/compositions/window-size', () =>
jest.fn().mockImplementation(() => {
return {
width: {
value: mockWidth
},
height: {
value: mockWidth
}
};
})
);
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-overlay', VOverlay);
describe('Views / Private', () => {
beforeEach(() => {
(useWindowSize as jest.Mock).mockClear();
});
it('Shows nav with overlay if screen is < 960px', async () => {
mockWidth = 600;
const component = shallowMount(PrivateView, { localVue });
expect((component.vm as any).navWithOverlay).toBe(true);
});
it('Does not render overlay for nav if screen is >= 960px', async () => {
mockWidth = 960;
let component = shallowMount(PrivateView, { localVue });
expect((component.vm as any).navWithOverlay).toBe(false);
mockWidth = 1000;
component = shallowMount(PrivateView, { localVue });
expect((component.vm as any).navWithOverlay).toBe(false);
});
it('Shows drawer with overlay if screen is < 1260px', async () => {
mockWidth = 600;
const component = shallowMount(PrivateView, { localVue });
expect((component.vm as any).drawerWithOverlay).toBe(true);
});
it('Does not render overlay for drawer if screen is >= 1260px', async () => {
mockWidth = 1260;
let component = shallowMount(PrivateView, { localVue });
expect((component.vm as any).drawerWithOverlay).toBe(false);
mockWidth = 1300;
component = shallowMount(PrivateView, { localVue });
expect((component.vm as any).drawerWithOverlay).toBe(false);
});
});

View File

@@ -0,0 +1,145 @@
<template>
<div class="private-view">
<aside class="navigation" :class="{ 'is-open': navOpen }">
<module-bar />
<div class="module-nav"></div>
</aside>
<div class="content">
<header>
<button @click="navOpen = true">Toggle nav</button>
<button @click="drawerOpen = !drawerOpen">Toggle drawer</button>
</header>
<main></main>
</div>
<aside class="drawer" :class="{ 'is-open': drawerOpen }"></aside>
<v-overlay
v-if="navWithOverlay"
class="nav-overlay"
:active="navOpen"
@click="navOpen = false"
/>
<v-overlay
v-if="drawerWithOverlay"
class="drawer-overlay"
:active="drawerOpen"
@click="drawerOpen = false"
/>
</div>
</template>
<script lang="ts">
import { createComponent, ref, computed, watch } from '@vue/composition-api';
import useWindowSize from '@/compositions/window-size';
import ModuleBar from './_module-bar.vue';
import api from '@/api';
// Breakpoints:
// 600, 960, 1260, 1900
export default createComponent({
components: {
ModuleBar
},
props: {},
setup() {
const navOpen = ref<boolean>(false);
const drawerOpen = ref<boolean>(false);
const { width } = useWindowSize();
const navWithOverlay = computed<boolean>(() => width.value < 960);
const drawerWithOverlay = computed<boolean>(() => width.value < 1260);
return {
navOpen,
drawerOpen,
navWithOverlay,
drawerWithOverlay,
width
};
}
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
.private-view {
display: flex;
width: 100%;
height: 100%;
.nav-overlay {
--v-overlay-z-index: 49;
}
.drawer-overlay {
--v-overlay-z-index: 29;
}
.navigation {
position: fixed;
top: 0;
left: 0;
z-index: 50;
height: 100%;
font-size: 0;
transform: translateX(-100%);
transition: transform var(--slow) var(--transition);
&.is-open {
transform: translateX(0);
}
.module-nav {
display: inline-block;
width: 220px;
height: 100%;
font-size: 1rem;
background-color: #eceff1;
}
@include breakpoint(medium) {
position: relative;
transform: none;
}
}
.content {
flex-grow: 1;
}
.drawer {
position: fixed;
top: 0;
right: 0;
z-index: 30;
width: 284px;
height: 100%;
background-color: #eceff1;
transform: translateX(100%);
transition: transform var(--slow) var(--transition);
&.is-open {
transform: translateX(0);
}
@include breakpoint(medium) {
transform: translateX(calc(100% - 64px));
}
@include breakpoint(large) {
position: relative;
flex-basis: 64px;
transform: none;
transition: flex-basis var(--slow) var(--transition);
&.is-open {
flex-basis: 284px;
transform: none;
}
}
}
}
</style>

View File

@@ -1,3 +1,4 @@
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
import { VTooltip } from 'v-tooltip';
@@ -64,7 +65,7 @@ describe('Views / Public', () => {
});
});
it('Uses the project color when the current project key is set, but background image is not', () => {
it('Uses the project color when the current project key is set, but background image is not', async () => {
store.state.projects = [
{
...mockProject,
@@ -76,6 +77,8 @@ describe('Views / Public', () => {
];
store.state.currentProjectKey = 'my-project';
await component.vm.$nextTick();
expect((component.vm as any).artStyles).toEqual({
background: '#4CAF50'
});

View File

@@ -1757,6 +1757,13 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
"@types/nanoid@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@types/nanoid/-/nanoid-2.1.0.tgz#41edfda78986e9127d0dc14de982de766f994020"
integrity sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ==
dependencies:
"@types/node" "*"
"@types/node@*":
version "13.7.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.7.0.tgz#b417deda18cf8400f278733499ad5547ed1abec4"
@@ -9780,6 +9787,11 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
nanoid@^2.1.11:
version "2.1.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==
nanomatch@^1.2.9:
version "1.2.13"
resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"