Refactor Vue components using Composition API #230
- Migrate `StatefulVue`:
- Introduce `UseCollectionState` that replaces its behavior and acts
as a shared state store.
- Add more encapsulated, granular functions based on read or write
access to state in CollectionState.
- Some linting rules get activates due to new code-base compability to
modern parses, fix linting errors.
- Rename Dialog to ModalDialog as after refactoring,
eslintvue/no-reserved-component-names does not allow name Dialog.
- To comply with `vue/multi-word-component-names`, rename:
- `Code` -> `CodeInstruction`
- `Handle` -> `SliderHandle`
- `Documentable` -> `DocumentableNode`
- `Node` -> `NodeContent`
- `INode` -> `INodeContent`
- `Responsive` -> `SizeObserver`
- Remove `vue-property-decorator` and `vue-class-component`
dependencies.
- Refactor `watch` with computed properties when possible for cleaner
code.
- Introduce `UseApplication` to reduce repeated code in new components
that use `computed` more heavily than before.
- Change TypeScript target to `es2017` to allow top level async calls
for getting application context/state/instance to simplify the code by
removing async calls. However, mocha (unit and integration) tests do
not run with top level awaits, so a workaround is used.
This commit is contained in:
18
src/presentation/components/Shared/Hooks/UseApplication.ts
Normal file
18
src/presentation/components/Shared/Hooks/UseApplication.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import { IApplication } from '@/domain/IApplication';
|
||||
|
||||
/* Application is always static */
|
||||
let cachedApplication: IApplication;
|
||||
|
||||
// Running tests through Vue CLI throws 'Top-level-await is only supported in EcmaScript Modules'
|
||||
// This is a temporary workaround until migrating to Vite
|
||||
ApplicationFactory.Current.getApp().then((app) => {
|
||||
cachedApplication = app;
|
||||
});
|
||||
|
||||
export function useApplication(application: IApplication = cachedApplication) {
|
||||
return {
|
||||
application,
|
||||
info: application.info,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ref, computed, readonly } from 'vue';
|
||||
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
|
||||
|
||||
let singletonContext: IApplicationContext;
|
||||
|
||||
// Running tests through Vue CLI throws 'Top-level-await is only supported in EcmaScript Modules'
|
||||
// This is a temporary workaround until migrating to Vite
|
||||
buildContext().then((context) => {
|
||||
singletonContext = context;
|
||||
});
|
||||
|
||||
export function useCollectionState(context: IApplicationContext = singletonContext) {
|
||||
const events = new EventSubscriptionCollection();
|
||||
const ownEvents = new EventSubscriptionCollection();
|
||||
|
||||
const currentState = ref<ICategoryCollectionState>(context.state);
|
||||
ownEvents.register(
|
||||
context.contextChanged.on((event) => {
|
||||
currentState.value = event.newState;
|
||||
}),
|
||||
);
|
||||
|
||||
type NewStateEventHandler = (
|
||||
newState: IReadOnlyCategoryCollectionState,
|
||||
oldState: IReadOnlyCategoryCollectionState | undefined,
|
||||
) => void;
|
||||
interface IStateCallbackSettings {
|
||||
readonly immediate: boolean;
|
||||
}
|
||||
const defaultSettings: IStateCallbackSettings = {
|
||||
immediate: false,
|
||||
};
|
||||
function onStateChange(
|
||||
handler: NewStateEventHandler,
|
||||
settings: Partial<IStateCallbackSettings> = defaultSettings,
|
||||
) {
|
||||
if (!handler) {
|
||||
throw new Error('missing state handler');
|
||||
}
|
||||
ownEvents.register(
|
||||
context.contextChanged.on((event) => {
|
||||
handler(event.newState, event.oldState);
|
||||
}),
|
||||
);
|
||||
const defaultedSettings: IStateCallbackSettings = {
|
||||
...defaultSettings,
|
||||
...settings,
|
||||
};
|
||||
if (defaultedSettings.immediate) {
|
||||
handler(context.state, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
type StateModifier = (
|
||||
state: ICategoryCollectionState,
|
||||
) => void;
|
||||
function modifyCurrentState(mutator: StateModifier) {
|
||||
if (!mutator) {
|
||||
throw new Error('missing state mutator');
|
||||
}
|
||||
mutator(context.state);
|
||||
}
|
||||
|
||||
type ContextModifier = (
|
||||
state: IApplicationContext,
|
||||
) => void;
|
||||
function modifyCurrentContext(mutator: ContextModifier) {
|
||||
if (!mutator) {
|
||||
throw new Error('missing context mutator');
|
||||
}
|
||||
mutator(context);
|
||||
}
|
||||
|
||||
return {
|
||||
modifyCurrentState,
|
||||
modifyCurrentContext,
|
||||
onStateChange,
|
||||
currentContext: context as IReadOnlyApplicationContext,
|
||||
currentState: readonly(computed<IReadOnlyCategoryCollectionState>(() => currentState.value)),
|
||||
events,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="dialog__close-button">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
@click="$modal.hide(name)"
|
||||
@click="hide"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,18 +18,41 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, onMounted } from 'vue';
|
||||
|
||||
@Component
|
||||
export default class Dialog extends Vue {
|
||||
private static idCounter = 0;
|
||||
let idCounter = 0;
|
||||
|
||||
public name = (++Dialog.idCounter).toString();
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const name = (++idCounter).toString();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let modal: any;
|
||||
|
||||
onMounted(async () => {
|
||||
// Hack until Vue 3, so we can use vue-js-modal
|
||||
const main = await import('@/presentation/main');
|
||||
const { getVue } = main;
|
||||
modal = getVue().$modal;
|
||||
});
|
||||
|
||||
function show(): void {
|
||||
modal.show(name);
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
modal.hide();
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
modal,
|
||||
hide,
|
||||
show,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
public show(): void {
|
||||
this.$modal.show(this.name);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<div ref="containerElement" class="container">
|
||||
<slot ref="containerElement" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Emit } from 'vue-property-decorator';
|
||||
import { throttle } from './Throttle';
|
||||
|
||||
@Component
|
||||
export default class Responsive extends Vue {
|
||||
private width: number;
|
||||
|
||||
private height: number;
|
||||
|
||||
private observer: ResizeObserver;
|
||||
|
||||
private get container(): HTMLElement { return this.$refs.containerElement as HTMLElement; }
|
||||
|
||||
public async mounted() {
|
||||
this.width = this.container.offsetWidth;
|
||||
this.height = this.container.offsetHeight;
|
||||
const resizeCallback = throttle(() => this.updateSize(), 200);
|
||||
|
||||
if ('ResizeObserver' in window === false) {
|
||||
const module = await import('@juggle/resize-observer');
|
||||
window.ResizeObserver = module.ResizeObserver;
|
||||
}
|
||||
|
||||
this.observer = new window.ResizeObserver(resizeCallback);
|
||||
this.observer.observe(this.container);
|
||||
this.fireChangeEvents();
|
||||
}
|
||||
|
||||
public updateSize() {
|
||||
let sizeChanged = false;
|
||||
if (this.isWidthChanged()) {
|
||||
this.updateWidth(this.container.offsetWidth);
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (this.isHeightChanged()) {
|
||||
this.updateHeight(this.container.offsetHeight);
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (sizeChanged) {
|
||||
this.$emit('sizeChanged');
|
||||
}
|
||||
}
|
||||
|
||||
@Emit('widthChanged') public updateWidth(width: number) {
|
||||
this.width = width;
|
||||
}
|
||||
|
||||
@Emit('heightChanged') public updateHeight(height: number) {
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
public destroyed() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private fireChangeEvents() {
|
||||
this.updateWidth(this.container.offsetWidth);
|
||||
this.updateHeight(this.container.offsetHeight);
|
||||
this.$emit('sizeChanged');
|
||||
}
|
||||
|
||||
private isWidthChanged(): boolean {
|
||||
return this.width !== this.container.offsetWidth;
|
||||
}
|
||||
|
||||
private isHeightChanged(): boolean {
|
||||
return this.height !== this.container.offsetHeight;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: inline-block; // if inline then it has no height or weight
|
||||
}
|
||||
</style>
|
||||
103
src/presentation/components/Shared/SizeObserver.vue
Normal file
103
src/presentation/components/Shared/SizeObserver.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div ref="containerElement" class="container">
|
||||
<slot ref="containerElement" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, onMounted, onBeforeUnmount,
|
||||
} from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
sizeChanged: () => true,
|
||||
widthChanged: (width: number) => true,
|
||||
heightChanged: (height: number) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const containerElement = ref<HTMLElement>();
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let observer: ResizeObserver;
|
||||
|
||||
onMounted(async () => {
|
||||
width = containerElement.value.offsetWidth;
|
||||
height = containerElement.value.offsetHeight;
|
||||
|
||||
observer = await initializeResizeObserver(updateSize);
|
||||
observer.observe(containerElement.value);
|
||||
|
||||
fireChangeEvents();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
async function initializeResizeObserver(
|
||||
callback: ResizeObserverCallback,
|
||||
): Promise<ResizeObserver> {
|
||||
if ('ResizeObserver' in window) {
|
||||
return new window.ResizeObserver(callback);
|
||||
}
|
||||
const module = await import('@juggle/resize-observer');
|
||||
return new module.ResizeObserver(callback);
|
||||
}
|
||||
|
||||
function updateSize() {
|
||||
let sizeChanged = false;
|
||||
if (isWidthChanged()) {
|
||||
updateWidth(containerElement.value.offsetWidth);
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (isHeightChanged()) {
|
||||
updateHeight(containerElement.value.offsetHeight);
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (sizeChanged) {
|
||||
emit('sizeChanged');
|
||||
}
|
||||
}
|
||||
|
||||
function updateWidth(newWidth: number) {
|
||||
width = newWidth;
|
||||
emit('widthChanged', newWidth);
|
||||
}
|
||||
|
||||
function updateHeight(newHeight: number) {
|
||||
height = newHeight;
|
||||
emit('heightChanged', newHeight);
|
||||
}
|
||||
|
||||
function fireChangeEvents() {
|
||||
updateWidth(containerElement.value.offsetWidth);
|
||||
updateHeight(containerElement.value.offsetHeight);
|
||||
emit('sizeChanged');
|
||||
}
|
||||
|
||||
function isWidthChanged(): boolean {
|
||||
return width !== containerElement.value.offsetWidth;
|
||||
}
|
||||
|
||||
function isHeightChanged(): boolean {
|
||||
return height !== containerElement.value.offsetHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
containerElement,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: inline-block; // if inline then it has no height or weight
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
|
||||
import { IApplicationContext, IApplicationContextChangedEvent } from '@/application/Context/IApplicationContext';
|
||||
import { buildContext } from '@/application/Context/ApplicationContextFactory';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore because https://github.com/vuejs/vue-class-component/issues/91
|
||||
@Component
|
||||
export abstract class StatefulVue extends Vue {
|
||||
private static readonly instance = new AsyncLazy<IApplicationContext>(() => buildContext());
|
||||
|
||||
protected readonly events = new EventSubscriptionCollection();
|
||||
|
||||
private readonly ownEvents = new EventSubscriptionCollection();
|
||||
|
||||
public async mounted() {
|
||||
const context = await this.getCurrentContext();
|
||||
this.ownEvents.register(
|
||||
context.contextChanged.on((event) => this.handleStateChangedEvent(event)),
|
||||
);
|
||||
this.handleCollectionState(context.state, undefined);
|
||||
}
|
||||
|
||||
protected abstract handleCollectionState(
|
||||
newState: IReadOnlyCategoryCollectionState,
|
||||
oldState: IReadOnlyCategoryCollectionState | undefined): void;
|
||||
|
||||
protected getCurrentContext(): Promise<IApplicationContext> {
|
||||
return StatefulVue.instance.getValue();
|
||||
}
|
||||
|
||||
private handleStateChangedEvent(event: IApplicationContextChangedEvent) {
|
||||
this.handleCollectionState(event.newState, event.oldState);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user