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

@@ -1,5 +1,5 @@
<template>
<Responsive v-on:widthChanged="width = $event">
<SizeObserver v-on:widthChanged="width = $event">
<!--
<div id="responsivity-debug">
Width: {{ width || 'undefined' }}
@@ -25,86 +25,85 @@
v-bind:key="categoryId"
:categoryId="categoryId"
:activeCategoryId="activeCategoryId"
v-on:selected="onSelected(categoryId, $event)"
v-on:cardExpansionChanged="onSelected(categoryId, $event)"
/>
</div>
<div v-else class="error">Something went bad 😢</div>
</Responsive>
</SizeObserver>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import Responsive from '@/presentation/components/Shared/Responsive.vue';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { ICategory } from '@/domain/ICategory';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import {
defineComponent, ref, onMounted, onUnmounted, computed,
} from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
import { hasDirective } from './NonCollapsingDirective';
import CardListItem from './CardListItem.vue';
@Component({
export default defineComponent({
components: {
CardListItem,
Responsive,
SizeObserver,
},
})
export default class CardList extends StatefulVue {
public width = 0;
setup() {
const { currentState, onStateChange } = useCollectionState();
public categoryIds: number[] = [];
const width = ref<number>(0);
const categoryIds = computed<ReadonlyArray<number>>(() => currentState
.value.collection.actions.map((category) => category.id));
const activeCategoryId = ref<number | undefined>(undefined);
public activeCategoryId?: number = null;
public created() {
document.addEventListener('click', this.outsideClickListener);
}
public destroyed() {
document.removeEventListener('click', this.outsideClickListener);
}
public onSelected(categoryId: number, isExpanded: boolean) {
this.activeCategoryId = isExpanded ? categoryId : undefined;
}
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.setCategories(newState.collection.actions);
this.activeCategoryId = undefined;
}
private setCategories(categories: ReadonlyArray<ICategory>): void {
this.categoryIds = categories.map((category) => category.id);
}
private onOutsideOfActiveCardClicked(clickedElement: Element): void {
if (isClickable(clickedElement) || hasDirective(clickedElement)) {
return;
function onSelected(categoryId: number, isExpanded: boolean) {
activeCategoryId.value = isExpanded ? categoryId : undefined;
}
this.collapseAllCards();
if (hasDirective(clickedElement)) {
return;
}
this.activeCategoryId = null;
}
private outsideClickListener(event: PointerEvent) {
if (this.areAllCardsCollapsed()) {
return;
}
const element = document.querySelector(`[data-category="${this.activeCategoryId}"]`);
const target = event.target as Element;
if (element && !element.contains(target)) {
this.onOutsideOfActiveCardClicked(target);
}
}
onStateChange(() => {
collapseAllCards();
}, { immediate: true });
private collapseAllCards(): void {
this.activeCategoryId = undefined;
}
const outsideClickListener = (event: PointerEvent): void => {
if (areAllCardsCollapsed()) {
return;
}
const element = document.querySelector(`[data-category="${activeCategoryId.value}"]`);
const target = event.target as Element;
if (element && !element.contains(target)) {
onOutsideOfActiveCardClicked(target);
}
};
private areAllCardsCollapsed(): boolean {
return !this.activeCategoryId;
}
}
onMounted(() => {
document.addEventListener('click', outsideClickListener);
});
onUnmounted(() => {
document.removeEventListener('click', outsideClickListener);
});
function onOutsideOfActiveCardClicked(clickedElement: Element): void {
if (isClickable(clickedElement) || hasDirective(clickedElement)) {
return;
}
collapseAllCards();
}
function areAllCardsCollapsed(): boolean {
return !activeCategoryId.value;
}
function collapseAllCards(): void {
activeCategoryId.value = undefined;
}
return {
width,
categoryIds,
activeCategoryId,
onSelected,
};
},
});
function isClickable(element: Element) {
const cursorName = window.getComputedStyle(element).cursor;

View File

@@ -1,7 +1,7 @@
<template>
<div
class="card"
v-on:click="onSelected(!isExpanded)"
v-on:click="isExpanded = !isExpanded"
v-bind:class="{
'is-collapsed': !isExpanded,
'is-inactive': activeCategoryId && activeCategoryId != categoryId,
@@ -40,7 +40,7 @@
<div class="card__expander__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
v-on:click="onSelected(false)"
v-on:click="collapse()"
/>
</div>
</div>
@@ -49,74 +49,97 @@
<script lang="ts">
import {
Component, Prop, Watch, Emit,
} from 'vue-property-decorator';
defineComponent, ref, watch, computed,
} from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
@Component({
export default defineComponent({
components: {
ScriptsTree,
},
})
export default class CardListItem extends StatefulVue {
@Prop() public categoryId!: number;
props: {
categoryId: {
type: Number,
required: true,
},
activeCategoryId: {
type: Number,
default: undefined,
},
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
cardExpansionChanged: (isExpanded: boolean) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(props, { emit }) {
const { events, onStateChange, currentState } = useCollectionState();
@Prop() public activeCategoryId!: number;
const isExpanded = computed({
get: () => {
return props.activeCategoryId === props.categoryId;
},
set: (newValue) => {
if (newValue) {
scrollToCard();
}
emit('cardExpansionChanged', newValue);
},
});
public cardTitle = '';
const isAnyChildSelected = ref(false);
const areAllChildrenSelected = ref(false);
const cardElement = ref<HTMLElement>();
public isExpanded = false;
const cardTitle = computed<string | undefined>(() => {
if (!props.categoryId || !currentState.value) {
return undefined;
}
const category = currentState.value.collection.findCategory(props.categoryId);
return category?.name;
});
public isAnyChildSelected = false;
public areAllChildrenSelected = false;
public async mounted() {
const context = await this.getCurrentContext();
this.events.register(context.state.selection.changed.on(
() => this.updateSelectionIndicators(this.categoryId),
));
await this.updateState(this.categoryId);
}
@Emit('selected')
public onSelected(isExpanded: boolean) {
this.isExpanded = isExpanded;
}
@Watch('activeCategoryId')
public async onActiveCategoryChanged(value?: number) {
this.isExpanded = value === this.categoryId;
}
@Watch('isExpanded')
public async onExpansionChanged(newValue: number, oldValue: number) {
if (!oldValue && newValue) {
await new Promise((resolve) => { setTimeout(resolve, 400); });
const focusElement = this.$refs.cardElement as HTMLElement;
focusElement.scrollIntoView({ behavior: 'smooth' });
function collapse() {
isExpanded.value = false;
}
}
@Watch('categoryId')
public async updateState(value?: number) {
const context = await this.getCurrentContext();
const category = !value ? undefined : context.state.collection.findCategory(value);
this.cardTitle = category ? category.name : undefined;
await this.updateSelectionIndicators(value);
}
onStateChange(async (state) => {
events.unsubscribeAll();
events.register(state.selection.changed.on(
() => updateSelectionIndicators(props.categoryId),
));
await updateSelectionIndicators(props.categoryId);
}, { immediate: true });
protected handleCollectionState(): void { /* do nothing */ }
watch(
() => props.categoryId,
(categoryId) => updateSelectionIndicators(categoryId),
);
private async updateSelectionIndicators(categoryId: number) {
const context = await this.getCurrentContext();
const { selection } = context.state;
const category = context.state.collection.findCategory(categoryId);
this.isAnyChildSelected = category ? selection.isAnySelected(category) : false;
this.areAllChildrenSelected = category ? selection.areAllSelected(category) : false;
}
}
async function scrollToCard() {
await sleep(400); // wait a bit to allow GUI to render the expanded card
cardElement.value.scrollIntoView({ behavior: 'smooth' });
}
async function updateSelectionIndicators(categoryId: number) {
const category = currentState.value.collection.findCategory(categoryId);
const { selection } = currentState.value;
isAnyChildSelected.value = category ? selection.isAnySelected(category) : false;
areAllChildrenSelected.value = category ? selection.areAllSelected(category) : false;
}
return {
cardTitle,
isExpanded,
isAnyChildSelected,
areAllChildrenSelected,
cardElement,
collapse,
};
},
});
</script>

View File

@@ -1,4 +1,4 @@
import { DirectiveOptions } from 'vue';
import { ObjectDirective } from 'vue';
const attributeName = 'data-interaction-does-not-collapse';
@@ -10,8 +10,8 @@ export function hasDirective(el: Element): boolean {
return !!parent;
}
export const NonCollapsing: DirectiveOptions = {
inserted(el: HTMLElement) {
export const NonCollapsing: ObjectDirective<HTMLElement> = {
inserted(el: HTMLElement) { // In Vue 3, use "mounted"
el.setAttribute(attributeName, '');
},
};

View File

@@ -1,15 +1,15 @@
import { ICategory, IScript } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { INode, NodeType } from './SelectableTree/Node/INode';
import { INodeContent, NodeType } from './SelectableTree/Node/INodeContent';
export function parseAllCategories(collection: ICategoryCollection): INode[] | undefined {
export function parseAllCategories(collection: ICategoryCollection): INodeContent[] | undefined {
return createCategoryNodes(collection.actions);
}
export function parseSingleCategory(
categoryId: number,
collection: ICategoryCollection,
): INode[] | undefined {
): INodeContent[] | undefined {
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id ${categoryId} does not exist`);
@@ -34,7 +34,7 @@ export function getCategoryNodeId(category: ICategory): string {
function parseCategoryRecursively(
parentCategory: ICategory,
): INode[] {
): INodeContent[] {
if (!parentCategory) {
throw new Error('parentCategory is undefined');
}
@@ -44,12 +44,12 @@ function parseCategoryRecursively(
];
}
function createScriptNodes(scripts: ReadonlyArray<IScript>): INode[] {
function createScriptNodes(scripts: ReadonlyArray<IScript>): INodeContent[] {
return (scripts || [])
.map((script) => convertScriptToNode(script));
}
function createCategoryNodes(categories: ReadonlyArray<ICategory>): INode[] {
function createCategoryNodes(categories: ReadonlyArray<ICategory>): INodeContent[] {
return (categories || [])
.map((category) => ({ category, children: parseCategoryRecursively(category) }))
.map((data) => convertCategoryToNode(data.category, data.children));
@@ -57,8 +57,8 @@ function createCategoryNodes(categories: ReadonlyArray<ICategory>): INode[] {
function convertCategoryToNode(
category: ICategory,
children: readonly INode[],
): INode {
children: readonly INodeContent[],
): INodeContent {
return {
id: getCategoryNodeId(category),
type: NodeType.Category,
@@ -69,7 +69,7 @@ function convertCategoryToNode(
};
}
function convertScriptToNode(script: IScript): INode {
function convertScriptToNode(script: IScript): INodeContent {
return {
id: getScriptNodeId(script),
type: NodeType.Script,

View File

@@ -14,8 +14,10 @@
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import {
defineComponent, watch, ref,
} from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
@@ -26,96 +28,123 @@ import {
getScriptId,
} from './ScriptNodeParser';
import SelectableTree from './SelectableTree/SelectableTree.vue';
import { INode, NodeType } from './SelectableTree/Node/INode';
import { INodeContent, NodeType } from './SelectableTree/Node/INodeContent';
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
@Component({
export default defineComponent({
props: {
categoryId: {
type: Number,
default: undefined,
},
},
components: {
SelectableTree,
},
})
export default class ScriptsTree extends StatefulVue {
@Prop() public categoryId?: number;
setup(props) {
const {
modifyCurrentState, currentState, onStateChange, events,
} = useCollectionState();
public nodes?: ReadonlyArray<INode> = null;
const nodes = ref<ReadonlyArray<INodeContent>>([]);
const selectedNodeIds = ref<ReadonlyArray<string>>([]);
const filterText = ref<string | undefined>(undefined);
public selectedNodeIds?: ReadonlyArray<string> = [];
let filtered: IFilterResult | undefined;
public filterText?: string = null;
private filtered?: IFilterResult;
public async toggleNodeSelection(event: INodeSelectedEvent) {
const context = await this.getCurrentContext();
switch (event.node.type) {
case NodeType.Category:
toggleCategoryNodeSelection(event, context.state);
break;
case NodeType.Script:
toggleScriptNodeSelection(event, context.state);
break;
default:
throw new Error(`Unknown node type: ${event.node.id}`);
}
}
@Watch('categoryId', { immediate: true })
public async setNodes(categoryId?: number) {
const context = await this.getCurrentContext();
if (categoryId) {
this.nodes = parseSingleCategory(categoryId, context.state.collection);
} else {
this.nodes = parseAllCategories(context.state.collection);
}
this.selectedNodeIds = context.state.selection.selectedScripts
.map((selected) => getScriptNodeId(selected.script));
}
public filterPredicate(node: INode): boolean {
return this.filtered.scriptMatches
.some((script: IScript) => node.id === getScriptNodeId(script))
|| this.filtered.categoryMatches
.some((category: ICategory) => node.id === getCategoryNodeId(category));
}
protected async handleCollectionState(newState: ICategoryCollectionState) {
this.setCurrentFilter(newState.filter.currentFilter);
if (!this.categoryId) {
this.nodes = parseAllCategories(newState.collection);
}
this.events.unsubscribeAll();
this.subscribeState(newState);
}
private subscribeState(state: ICategoryCollectionState) {
this.events.register(
state.selection.changed.on(this.handleSelectionChanged),
state.filter.filterRemoved.on(this.handleFilterRemoved),
state.filter.filtered.on(this.handleFiltered),
watch(
() => props.categoryId,
async (newCategoryId) => { await setNodes(newCategoryId); },
{ immediate: true },
);
}
private setCurrentFilter(currentFilter: IFilterResult | undefined) {
if (!currentFilter) {
this.handleFilterRemoved();
} else {
this.handleFiltered(currentFilter);
onStateChange((state) => {
setCurrentFilter(state.filter.currentFilter);
if (!props.categoryId) {
nodes.value = parseAllCategories(state.collection);
}
events.unsubscribeAll();
modifyCurrentState((mutableState) => {
registerStateMutators(mutableState);
});
}, { immediate: true });
function toggleNodeSelection(event: INodeSelectedEvent) {
modifyCurrentState((state) => {
switch (event.node.type) {
case NodeType.Category:
toggleCategoryNodeSelection(event, state);
break;
case NodeType.Script:
toggleScriptNodeSelection(event, state);
break;
default:
throw new Error(`Unknown node type: ${event.node.id}`);
}
});
}
}
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
this.selectedNodeIds = selectedScripts
.map((node) => node.id);
}
function filterPredicate(node: INodeContent): boolean {
return containsScript(node, filtered.scriptMatches)
|| containsCategory(node, filtered.categoryMatches);
}
private handleFilterRemoved() {
this.filterText = '';
}
async function setNodes(categoryId?: number) {
if (categoryId) {
nodes.value = parseSingleCategory(categoryId, currentState.value.collection);
} else {
nodes.value = parseAllCategories(currentState.value.collection);
}
selectedNodeIds.value = currentState.value.selection.selectedScripts
.map((selected) => getScriptNodeId(selected.script));
}
private handleFiltered(result: IFilterResult) {
this.filterText = result.query;
this.filtered = result;
}
function registerStateMutators(state: ICategoryCollectionState) {
events.register(
state.selection.changed.on((scripts) => handleSelectionChanged(scripts)),
state.filter.filterRemoved.on(() => handleFilterRemoved()),
state.filter.filtered.on((filterResult) => handleFiltered(filterResult)),
);
}
function setCurrentFilter(currentFilter: IFilterResult | undefined) {
if (!currentFilter) {
handleFilterRemoved();
} else {
handleFiltered(currentFilter);
}
}
function handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
selectedNodeIds.value = selectedScripts
.map((node) => node.id);
}
function handleFilterRemoved() {
filterText.value = '';
}
function handleFiltered(result: IFilterResult) {
filterText.value = result.query;
filtered = result;
}
return {
nodes,
selectedNodeIds,
filterText,
toggleNodeSelection,
filterPredicate,
};
},
});
function containsScript(expected: INodeContent, scripts: readonly IScript[]) {
return scripts.some((existing: IScript) => expected.id === getScriptNodeId(existing));
}
function containsCategory(expected: INodeContent, categories: readonly ICategory[]) {
return categories.some((existing: ICategory) => expected.id === getCategoryNodeId(existing));
}
function toggleCategoryNodeSelection(
@@ -144,7 +173,3 @@ function toggleScriptNodeSelection(
}
}
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,6 +1,6 @@
import { INode } from './Node/INode';
import { INodeContent } from './Node/INodeContent';
export interface INodeSelectedEvent {
isSelected: boolean;
node: INode;
node: INodeContent;
}

View File

@@ -1,6 +1,5 @@
declare module 'liquor-tree' {
import { PluginObject } from 'vue';
import { VueClass } from 'vue-class-component/lib/declarations';
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js
export interface ILiquorTree {
@@ -70,6 +69,6 @@ declare module 'liquor-tree' {
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
}
const LiquorTree: PluginObject<Vue> & VueClass<Vue>;
const LiquorTree: PluginObject<Vue>;
export default LiquorTree;
}

View File

@@ -1,11 +1,11 @@
import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
import { INode } from '../../Node/INode';
import { INodeContent } from '../../Node/INodeContent';
import { convertExistingToNode } from './NodeTranslator';
export type FilterPredicate = (node: INode) => boolean;
export type FilterPredicate = (node: INodeContent) => boolean;
export class NodePredicateFilter implements ILiquorTreeFilter {
public emptyText = ''; // Does not matter as a custom mesage is shown
public emptyText = ''; // Does not matter as a custom message is shown
constructor(private readonly filterPredicate: FilterPredicate) {
if (!filterPredicate) {

View File

@@ -1,5 +1,5 @@
import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
import { NodeType } from '../../Node/INode';
import { NodeType } from '../../Node/INodeContent';
export function getNewState(
node: ILiquorTreeNode,

View File

@@ -1,9 +1,9 @@
import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree';
import { INode } from '../../Node/INode';
import { INodeContent } from '../../Node/INodeContent';
// Functions to translate INode to LiqourTree models and vice versa for anti-corruption
export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INode {
export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INodeContent {
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return {
id: liquorTreeNode.id,
@@ -16,7 +16,7 @@ export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode):
};
}
export function toNewLiquorTreeNode(node: INode): ILiquorTreeNewNode {
export function toNewLiquorTreeNode(node: INodeContent): ILiquorTreeNewNode {
if (!node) { throw new Error('node is undefined'); }
return {
id: node.id,

View File

@@ -27,21 +27,29 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { defineComponent, ref, PropType } from 'vue';
import DocumentationText from './DocumentationText.vue';
import ToggleDocumentationButton from './ToggleDocumentationButton.vue';
@Component({
export default defineComponent({
components: {
DocumentationText,
ToggleDocumentationButton,
},
})
export default class Documentation extends Vue {
@Prop() public docs!: readonly string[];
props: {
docs: {
type: Array as PropType<readonly string[]>,
required: true,
},
},
setup() {
const isExpanded = ref(false);
public isExpanded = false;
}
return {
isExpanded,
};
},
});
</script>
<style scoped lang="scss">

View File

@@ -7,27 +7,38 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator';
import { defineComponent, PropType, computed } from 'vue';
import { createRenderer } from './MarkdownRenderer';
@Component
export default class DocumentationText extends Vue {
@Prop() public docs: readonly string[];
export default defineComponent({
props: {
docs: {
type: Array as PropType<ReadonlyArray<string>>,
default: () => [],
},
},
setup(props) {
const renderedText = computed<string>(() => renderText(props.docs));
private readonly renderer = createRenderer();
return {
renderedText,
};
},
});
get renderedText(): string {
if (!this.docs || this.docs.length === 0) {
return '';
}
if (this.docs.length === 1) {
return this.renderer.render(this.docs[0]);
}
const bulletpoints = this.docs
.map((doc) => renderAsMarkdownListItem(doc))
.join('\n');
return this.renderer.render(bulletpoints);
const renderer = createRenderer();
function renderText(docs: readonly string[] | undefined): string {
if (!docs || docs.length === 0) {
return '';
}
if (docs.length === 1) {
return renderer.render(docs[0]);
}
const bulletpoints = docs
.map((doc) => renderAsMarkdownListItem(doc))
.join('\n');
return renderer.render(bulletpoints);
}
function renderAsMarkdownListItem(content: string): string {
@@ -39,7 +50,6 @@ function renderAsMarkdownListItem(content: string): string {
.map((line) => `\n ${line}`)
.join()}`;
}
</script>
<style lang="scss"> /* Not scoped due to element styling such as "a". */
@@ -115,5 +125,4 @@ $text-size: 0.75em; // Lower looks bad on Firefox
list-style: square;
}
}
</style>

View File

@@ -2,7 +2,7 @@
<a
class="button"
target="_blank"
v-bind:class="{ 'button-on': this.isOn }"
v-bind:class="{ 'button-on': isOn }"
v-on:click.stop
v-on:click="toggle()"
>
@@ -11,22 +11,31 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent, ref } from 'vue';
@Component
export default class ToggleDocumentationButton extends Vue {
public isOn = false;
export default defineComponent({
emits: [
'show',
'hide',
],
setup(_, { emit }) {
const isOn = ref(false);
public toggle() {
this.isOn = !this.isOn;
if (this.isOn) {
this.$emit('show');
} else {
this.$emit('hide');
function toggle() {
isOn.value = !isOn.value;
if (isOn.value) {
emit('show');
} else {
emit('hide');
}
}
}
}
return {
isOn,
toggle,
};
},
});
</script>
<style scoped lang="scss">

View File

@@ -3,11 +3,11 @@ export enum NodeType {
Category,
}
export interface INode {
export interface INodeContent {
readonly id: string;
readonly text: string;
readonly isReversible: boolean;
readonly docs: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INode>;
readonly children?: ReadonlyArray<INodeContent>;
readonly type: NodeType;
}

View File

@@ -1,30 +1,33 @@
<template>
<Documentable :docs="this.data.docs">
<DocumentableNode :docs="data.docs">
<div id="node">
<div class="item text">{{ this.data.text }}</div>
<div class="item text">{{ data.text }}</div>
<RevertToggle
class="item"
v-if="data.isReversible"
:node="data" />
</div>
</Documentable>
</DocumentableNode>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { INode } from './INode';
import { defineComponent, PropType } from 'vue';
import { INodeContent } from './INodeContent';
import RevertToggle from './RevertToggle.vue';
import Documentable from './Documentation/Documentable.vue';
import DocumentableNode from './Documentation/DocumentableNode.vue';
@Component({
export default defineComponent({
components: {
RevertToggle,
Documentable,
DocumentableNode,
},
})
export default class Node extends Vue {
@Prop() public data: INode;
}
props: {
data: {
type: Object as PropType<INodeContent>,
required: true,
},
},
});
</script>
<style scoped lang="scss">

View File

@@ -4,7 +4,7 @@
type="checkbox"
class="input-checkbox"
v-model="isReverted"
@change="onRevertToggled()"
@change="toggleRevert()"
v-on:click.stop>
<div class="checkbox-animate">
<span class="checkbox-off">revert</span>
@@ -14,42 +14,64 @@
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import {
PropType, defineComponent, ref, watch,
} from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IReverter } from './Reverter/IReverter';
import { INode } from './INode';
import { INodeContent } from './INodeContent';
import { getReverter } from './Reverter/ReverterFactory';
@Component
export default class RevertToggle extends StatefulVue {
@Prop() public node: INode;
export default defineComponent({
props: {
node: {
type: Object as PropType<INodeContent>,
required: true,
},
},
setup(props) {
const {
currentState, modifyCurrentState, onStateChange, events,
} = useCollectionState();
public isReverted = false;
const isReverted = ref(false);
private handler: IReverter;
let handler: IReverter | undefined;
@Watch('node', { immediate: true }) public async onNodeChanged(node: INode) {
const context = await this.getCurrentContext();
this.handler = getReverter(node, context.state.collection);
}
watch(
() => props.node,
async (node) => { await onNodeChanged(node); },
{ immediate: true },
);
public async onRevertToggled() {
const context = await this.getCurrentContext();
this.handler.selectWithRevertState(this.isReverted, context.state.selection);
}
onStateChange((newState) => {
updateStatus(newState.selection.selectedScripts);
events.unsubscribeAll();
events.register(newState.selection.changed.on((scripts) => updateStatus(scripts)));
}, { immediate: true });
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.updateStatus(newState.selection.selectedScripts);
this.events.unsubscribeAll();
this.events.register(newState.selection.changed.on((scripts) => this.updateStatus(scripts)));
}
async function onNodeChanged(node: INodeContent) {
handler = getReverter(node, currentState.value.collection);
updateStatus(currentState.value.selection.selectedScripts);
}
private updateStatus(scripts: ReadonlyArray<SelectedScript>) {
this.isReverted = this.handler.getState(scripts);
}
}
function toggleRevert() {
modifyCurrentState((state) => {
handler.selectWithRevertState(isReverted.value, state.selection);
});
}
async function updateStatus(scripts: ReadonlyArray<SelectedScript>) {
isReverted.value = handler?.getState(scripts) ?? false;
}
return {
isReverted,
toggleRevert,
};
},
});
</script>
<style scoped lang="scss">
@@ -76,7 +98,6 @@ $size-height : 30px;
border-radius: $size-height;
line-height: $size-height;
font-size: math.div($size-height, 2);
display: inline-block;
input.input-checkbox {
position: absolute;

View File

@@ -1,10 +1,10 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { INode, NodeType } from '../INode';
import { INodeContent, NodeType } from '../INodeContent';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';
import { CategoryReverter } from './CategoryReverter';
export function getReverter(node: INode, collection: ICategoryCollection): IReverter {
export function getReverter(node: INodeContent, collection: ICategoryCollection): IReverter {
switch (node.type) {
case NodeType.Category:
return new CategoryReverter(node.id, collection);

View File

@@ -1,17 +1,17 @@
<template>
<span>
<span v-if="initialLiquorTreeNodes != null && initialLiquorTreeNodes.length > 0">
<tree
<span v-if="initialLiquorTreeNodes?.length > 0">
<LiquorTree
:options="liquorTreeOptions"
:data="initialLiquorTreeNodes"
v-on:node:checked="nodeSelected($event)"
v-on:node:unchecked="nodeSelected($event)"
ref="treeElement"
@node:checked="nodeSelected($event)"
@node:unchecked="nodeSelected($event)"
ref="liquorTree"
>
<span class="tree-text" slot-scope="{ node }">
<Node :data="convertExistingToNode(node)" />
<NodeContent :data="convertExistingToNode(node)" />
</span>
</tree>
</LiquorTree>
</span>
<span v-else>Nooo 😢</span>
</span>
@@ -19,109 +19,139 @@
<script lang="ts">
import {
Component, Prop, Vue, Watch,
} from 'vue-property-decorator';
PropType, defineComponent, ref, watch,
} from 'vue';
import LiquorTree, {
ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeNode, ILiquorTreeNodeState,
} from 'liquor-tree';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import Node from './Node/Node.vue';
import { INode } from './Node/INode';
import NodeContent from './Node/NodeContent.vue';
import { INodeContent } from './Node/INodeContent';
import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
import { INodeSelectedEvent } from './INodeSelectedEvent';
import { getNewState } from './LiquorTree/NodeWrapper/NodeStateUpdater';
import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions';
import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodeWrapper/NodePredicateFilter';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({
/**
* Wrapper for Liquor Tree, reveals only abstracted INode for communication.
* Stateless to make it easier to switch out Liquor Tree to another component.
*/
export default defineComponent({
components: {
LiquorTree,
Node,
NodeContent,
},
})
export default class SelectableTree extends Vue { // Stateless to make it easier to switch out
@Prop() public filterPredicate?: FilterPredicate;
props: {
filterPredicate: {
type: Function as PropType<FilterPredicate>,
default: undefined,
},
filterText: {
type: String,
default: undefined,
},
selectedNodeIds: {
type: Array as PropType<ReadonlyArray<string>>,
default: undefined,
},
initialNodes: {
type: Array as PropType<ReadonlyArray<INodeContent>>,
default: undefined,
},
},
setup(props, { emit }) {
const liquorTree = ref< { tree: ILiquorTree }>();
const initialLiquorTreeNodes = ref<ReadonlyArray<ILiquorTreeNewNode>>();
const liquorTreeOptions = new LiquorTreeOptions(
new NodePredicateFilter((node) => props.filterPredicate(node)),
);
@Prop() public filterText?: string;
@Prop() public selectedNodeIds?: ReadonlyArray<string>;
@Prop() public initialNodes?: ReadonlyArray<INode>;
public initialLiquorTreeNodes?: ILiquorTreeNewNode[] = null;
public liquorTreeOptions = new LiquorTreeOptions(
new NodePredicateFilter((node) => this.filterPredicate(node)),
);
public convertExistingToNode = convertExistingToNode;
public nodeSelected(node: ILiquorTreeExistingNode) {
const event: INodeSelectedEvent = {
node: convertExistingToNode(node),
isSelected: node.states.checked,
};
this.$emit('nodeSelected', event);
}
@Watch('initialNodes', { immediate: true })
public async updateNodes(nodes: readonly INode[]) {
if (!nodes) {
throw new Error('missing initial nodes');
function nodeSelected(node: ILiquorTreeExistingNode) {
const event: INodeSelectedEvent = {
node: convertExistingToNode(node),
isSelected: node.states.checked,
};
emit('nodeSelected', event);
}
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
if (this.selectedNodeIds) {
recurseDown(
initialNodes,
watch(
() => props.initialNodes,
(nodes) => setInitialNodes(nodes),
{ immediate: true },
);
watch(
() => props.filterText,
(filterText) => setFilterText(filterText),
{ immediate: true },
);
watch(
() => props.selectedNodeIds,
(selectedNodeIds) => setSelectedStatus(selectedNodeIds),
);
async function setInitialNodes(nodes: readonly INodeContent[]) {
if (!nodes) {
throw new Error('missing initial nodes');
}
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
if (props.selectedNodeIds) {
recurseDown(
initialNodes,
(node) => {
node.state = updateState(node.state, node, props.selectedNodeIds);
},
);
}
initialLiquorTreeNodes.value = initialNodes;
const api = await getLiquorTreeApi();
api.setModel(initialLiquorTreeNodes.value);
}
async function setFilterText(filterText?: string) {
const api = await getLiquorTreeApi();
if (!filterText) {
api.clearFilter();
} else {
api.filter('filtered'); // text does not matter, it'll trigger the filterPredicate
}
}
async function setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) {
throw new Error('Selected recurseDown nodes are undefined');
}
const tree = await getLiquorTreeApi();
tree.recurseDown(
(node) => {
node.state = updateState(node.state, node, this.selectedNodeIds);
node.states = updateState(node.states, node, selectedNodeIds);
},
);
}
this.initialLiquorTreeNodes = initialNodes;
const api = await this.getLiquorTreeApi();
// We need to set the model manually on each update because liquor tree is not reactive to data
// changes after its initialization.
api.setModel(this.initialLiquorTreeNodes);
}
@Watch('filterText', { immediate: true })
public async updateFilterText(filterText?: string) {
const api = await this.getLiquorTreeApi();
if (!filterText) {
api.clearFilter();
} else {
api.filter('filtered'); // text does not matter, it'll trigger the filterPredicate
async function getLiquorTreeApi(): Promise<ILiquorTree> {
const tree = await tryUntilDefined(
() => liquorTree.value?.tree,
5,
20,
);
if (!tree) {
throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?');
}
return tree;
}
}
@Watch('selectedNodeIds')
public async setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) {
throw new Error('Selected recurseDown nodes are undefined');
}
const tree = await this.getLiquorTreeApi();
tree.recurseDown(
(node) => {
node.states = updateState(node.states, node, selectedNodeIds);
},
);
}
private async getLiquorTreeApi(): Promise<ILiquorTree> {
const accessor = (): ILiquorTree => {
const uiElement = this.$refs.treeElement;
type TreeElement = typeof uiElement & { tree: ILiquorTree };
return uiElement ? (uiElement as TreeElement).tree : undefined;
return {
liquorTreeOptions,
initialLiquorTreeNodes,
convertExistingToNode,
nodeSelected,
liquorTree,
};
const treeElement = await tryUntilDefined(accessor, 5, 20); // Wait for it to render
if (!treeElement) {
throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?');
}
return treeElement;
}
}
},
});
function updateState(
old: ILiquorTreeNodeState,
@@ -162,3 +192,4 @@ async function tryUntilDefined<T>(
return value;
}
</script>
./Node/INodeContent

View File

@@ -9,7 +9,7 @@
<div v-else> <!-- Searching -->
<div class="search">
<div class="search__query">
<div>Searching for "{{this.searchQuery | threeDotsTrim }}"</div>
<div>Searching for "{{ trimmedSearchQuery }}"</div>
<div class="search__query__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
@@ -17,7 +17,7 @@
</div>
</div>
<div v-if="!searchHasMatches" class="search-no-matches">
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim }}" 😞</div>
<div>Sorry, no matches for "{{ trimmedSearchQuery }}" 😞</div>
<div>
Feel free to extend the scripts
<a :href="repositoryUrl" class="child github" target="_blank" rel="noopener noreferrer">here</a>
@@ -32,75 +32,81 @@
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
import TheGrouper from '@/presentation/components/Scripts/Menu/View/TheViewChanger.vue';
import {
defineComponent, PropType, ref, computed,
} from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
/** Shows content of single category or many categories */
@Component({
export default defineComponent({
components: {
TheGrouper,
ScriptsTree,
CardList,
},
filters: {
threeDotsTrim(query: string) {
props: {
currentView: {
type: Number as PropType<ViewType>,
required: true,
},
},
setup() {
const { modifyCurrentState, onStateChange, events } = useCollectionState();
const { info } = useApplication();
const repositoryUrl = computed<string>(() => info.repositoryWebUrl);
const searchQuery = ref<string>();
const isSearching = ref(false);
const searchHasMatches = ref(false);
const trimmedSearchQuery = computed(() => {
const query = searchQuery.value;
const threshold = 30;
if (query.length <= threshold - 3) {
return query;
}
return `${query.substr(0, threshold)}...`;
},
return `${query.substring(0, threshold)}...`;
});
onStateChange((newState) => {
events.unsubscribeAll();
subscribeState(newState);
});
function clearSearchQuery() {
modifyCurrentState((state) => {
const { filter } = state;
filter.removeFilter();
});
}
function subscribeState(state: IReadOnlyCategoryCollectionState) {
events.register(
state.filter.filterRemoved.on(() => {
isSearching.value = false;
}),
state.filter.filtered.on((result: IFilterResult) => {
searchQuery.value = result.query;
isSearching.value = true;
searchHasMatches.value = result.hasAnyMatches();
}),
);
}
return {
repositoryUrl,
trimmedSearchQuery,
isSearching,
searchHasMatches,
clearSearchQuery,
ViewType,
};
},
})
export default class TheScriptsView extends StatefulVue {
public repositoryUrl = '';
public searchQuery = '';
public isSearching = false;
public searchHasMatches = false;
@Prop() public currentView: ViewType;
public ViewType = ViewType; // Make it accessible from the view
public async created() {
const app = await ApplicationFactory.Current.getApp();
this.repositoryUrl = app.info.repositoryWebUrl;
}
public async clearSearchQuery() {
const context = await this.getCurrentContext();
const { filter } = context.state;
filter.removeFilter();
}
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.events.unsubscribeAll();
this.subscribeState(newState);
}
private subscribeState(state: IReadOnlyCategoryCollectionState) {
this.events.register(
state.filter.filterRemoved.on(() => {
this.isSearching = false;
}),
state.filter.filtered.on((result: IFilterResult) => {
this.searchQuery = result.query;
this.isSearching = true;
this.searchHasMatches = result.hasAnyMatches();
}),
);
}
}
});
</script>
<style scoped lang="scss">
@@ -161,5 +167,4 @@ $margin-inner: 4px;
}
}
}
</style>