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:
undergroundwires
2023-08-07 13:16:39 +02:00
parent 3a594ac7fd
commit 1b9be8fe2d
67 changed files with 2135 additions and 1267 deletions

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

View File

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

View File

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

View File

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

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

View File

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