Refactor code to comply with ESLint rules

Major refactoring using ESLint with rules from AirBnb and Vue.

Enable most of the ESLint rules and do necessary linting in the code.
Also add more information for rules that are disabled to describe what
they are and why they are disabled.

Allow logging (`console.log`) in test files, and in development mode
(e.g. when working with `npm run serve`), but disable it when
environment is production (as pre-configured by Vue). Also add flag
(`--mode production`) in `lint:eslint` command so production linting is
executed earlier in lifecycle.

Disable rules that requires a separate work. Such as ESLint rules that
are broken in TypeScript: no-useless-constructor (eslint/eslint#14118)
and no-shadow (eslint/eslint#13014).
This commit is contained in:
undergroundwires
2022-01-02 18:20:14 +01:00
parent 96265b75de
commit 5b1fbe1e2f
341 changed files with 16126 additions and 15101 deletions

View File

@@ -1,10 +1,10 @@
<template>
<div class="list">
<div v-if="label">{{ label }}:</div>
<div class="items">
<slot />
</div>
<div class="list">
<div v-if="label">{{ label }}:</div>
<div class="items">
<slot />
</div>
</div>
</template>
<script lang="ts">
@@ -26,13 +26,13 @@ $gap: 0.25rem;
align-items: center;
.items {
* + *::before {
content: '|';
padding-right: $gap;
padding-left: $gap;
content: '|';
padding-right: $gap;
padding-left: $gap;
}
}
> *:not(:last-child) {
margin-right: $gap;
}
}
</style>
</style>

View File

@@ -1,23 +1,27 @@
<template>
<span> <!-- Parent wrapper allows adding content inside with CSS without making it clickable -->
<span
v-bind:class="{ 'disabled': !enabled, 'enabled': enabled}"
v-non-collapsing
@click="enabled && onClicked()">{{label}}</span>
</span>
<span> <!-- Parent wrapper allows adding content inside with CSS without making it clickable -->
<span
v-bind:class="{ 'disabled': !enabled, 'enabled': enabled}"
v-non-collapsing
@click="enabled && onClicked()">{{label}}</span>
</span>
</template>
<script lang="ts">
import { Component, Prop, Emit, Vue } from 'vue-property-decorator';
import {
Component, Prop, Emit, Vue,
} from 'vue-property-decorator';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
@Component({
directives: { NonCollapsing },
directives: { NonCollapsing },
})
export default class MenuOptionListItem extends Vue {
@Prop() public enabled: boolean;
@Prop() public label: string;
@Emit('click') public onClicked() { return; }
@Emit('click') public onClicked() { /* do nothing except firing event */ }
}
</script>
@@ -27,11 +31,11 @@ export default class MenuOptionListItem extends Vue {
.enabled {
cursor: pointer;
&:hover {
font-weight:bold;
text-decoration:underline;
}
font-weight:bold;
text-decoration:underline;
}
}
.disabled {
color: $color-primary-light;
color: $color-primary-light;
}
</style>
</style>

View File

@@ -5,82 +5,85 @@ import { scrambledEqual } from '@/application/Common/Array';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
export enum SelectionType {
Standard,
Strict,
All,
None,
Custom,
Standard,
Strict,
All,
None,
Custom,
}
export class SelectionTypeHandler {
constructor(private readonly state: ICategoryCollectionState) {
if (!state) { throw new Error('undefined state'); }
constructor(private readonly state: ICategoryCollectionState) {
if (!state) { throw new Error('undefined state'); }
}
public selectType(type: SelectionType) {
if (type === SelectionType.Custom) {
throw new Error('cannot select custom type');
}
public selectType(type: SelectionType) {
if (type === SelectionType.Custom) {
throw new Error('cannot select custom type');
}
const selector = selectors.get(type);
selector.select(this.state);
}
public getCurrentSelectionType(): SelectionType {
for (const [type, selector] of Array.from(selectors.entries())) {
if (selector.isSelected(this.state)) {
return type;
}
}
return SelectionType.Custom;
const selector = selectors.get(type);
selector.select(this.state);
}
public getCurrentSelectionType(): SelectionType {
for (const [type, selector] of Array.from(selectors.entries())) {
if (selector.isSelected(this.state)) {
return type;
}
}
return SelectionType.Custom;
}
}
interface ISingleTypeHandler {
isSelected: (state: IReadOnlyCategoryCollectionState) => boolean;
select: (state: ICategoryCollectionState) => void;
isSelected: (state: IReadOnlyCategoryCollectionState) => boolean;
select: (state: ICategoryCollectionState) => void;
}
const selectors = new Map<SelectionType, ISingleTypeHandler>([
[SelectionType.None, {
select: (state) =>
state.selection.deselectAll(),
isSelected: (state) =>
state.selection.selectedScripts.length === 0,
}],
[SelectionType.Standard, getRecommendationLevelSelector(RecommendationLevel.Standard)],
[SelectionType.Strict, getRecommendationLevelSelector(RecommendationLevel.Strict)],
[SelectionType.All, {
select: (state) =>
state.selection.selectAll(),
isSelected: (state) =>
state.selection.selectedScripts.length === state.collection.totalScripts,
}],
[SelectionType.None, {
select: (state) => state.selection.deselectAll(),
isSelected: (state) => state.selection.selectedScripts.length === 0,
}],
[SelectionType.Standard, getRecommendationLevelSelector(RecommendationLevel.Standard)],
[SelectionType.Strict, getRecommendationLevelSelector(RecommendationLevel.Strict)],
[SelectionType.All, {
select: (state) => state.selection.selectAll(),
isSelected: (state) => state.selection.selectedScripts.length === state.collection.totalScripts,
}],
]);
function getRecommendationLevelSelector(level: RecommendationLevel): ISingleTypeHandler {
return {
select: (state) => selectOnly(level, state),
isSelected: (state) => hasAllSelectedLevelOf(level, state),
};
return {
select: (state) => selectOnly(level, state),
isSelected: (state) => hasAllSelectedLevelOf(level, state),
};
}
function hasAllSelectedLevelOf(level: RecommendationLevel, state: IReadOnlyCategoryCollectionState) {
const scripts = state.collection.getScriptsByLevel(level);
const selectedScripts = state.selection.selectedScripts;
return areAllSelected(scripts, selectedScripts);
function hasAllSelectedLevelOf(
level: RecommendationLevel,
state: IReadOnlyCategoryCollectionState,
) {
const scripts = state.collection.getScriptsByLevel(level);
const { selectedScripts } = state.selection;
return areAllSelected(scripts, selectedScripts);
}
function selectOnly(level: RecommendationLevel, state: ICategoryCollectionState) {
const scripts = state.collection.getScriptsByLevel(level);
state.selection.selectOnly(scripts);
const scripts = state.collection.getScriptsByLevel(level);
state.selection.selectOnly(scripts);
}
function areAllSelected(
expectedScripts: ReadonlyArray<IScript>,
selection: ReadonlyArray<SelectedScript>): boolean {
selection = selection.filter((selected) => !selected.revert);
if (expectedScripts.length < selection.length) {
return false;
}
const selectedScriptIds = selection.map((script) => script.id);
const expectedScriptIds = expectedScripts.map((script) => script.id);
return scrambledEqual(selectedScriptIds, expectedScriptIds);
expectedScripts: ReadonlyArray<IScript>,
selection: ReadonlyArray<SelectedScript>,
): boolean {
const selectedScriptIds = selection
.filter((selected) => !selected.revert)
.map((script) => script.id);
if (expectedScripts.length < selectedScriptIds.length) {
return false;
}
const expectedScriptIds = expectedScripts.map((script) => script.id);
return scrambledEqual(selectedScriptIds, expectedScriptIds);
}

View File

@@ -1,46 +1,46 @@
<template>
<MenuOptionList label="Select">
<MenuOptionListItem
label="None"
:enabled="this.currentSelection !== SelectionType.None"
@click="selectType(SelectionType.None)"
v-tooltip=" 'Deselect all selected scripts.<br/>' +
'💡 Good start to dive deeper into tweaks and select only what you want.'"
/>
<MenuOptionListItem
label="Standard"
:enabled="this.currentSelection !== SelectionType.Standard"
@click="selectType(SelectionType.Standard)"
v-tooltip=" '🛡️ Balanced for privacy and functionality.<br/>' +
'OS and applications will function normally.<br/>' +
'💡 Recommended for everyone'"
/>
<MenuOptionListItem
label="Strict"
:enabled="this.currentSelection !== SelectionType.Strict"
@click="selectType(SelectionType.Strict)"
v-tooltip=" '🚫 Stronger privacy, disables risky functions that may leak your data.<br/>' +
'⚠️ Double check to remove scripts where you would trade functionality for privacy<br/>' +
'💡 Recommended for daily users that prefers more privacy over non-essential functions'"
/>
<MenuOptionListItem
label="All"
:enabled="this.currentSelection !== SelectionType.All"
@click="selectType(SelectionType.All)"
v-tooltip=" '🔒 Strongest privacy, disabling any functionality that may leak your data.<br/>' +
'🛑 Not designed for daily users, it will break important functionalities.<br/>' +
'💡 Only recommended for extreme use-cases like crime labs where no leak is acceptable'"
/>
</MenuOptionList>
<MenuOptionList label="Select">
<MenuOptionListItem
label="None"
:enabled="this.currentSelection !== SelectionType.None"
@click="selectType(SelectionType.None)"
v-tooltip=" 'Deselect all selected scripts.<br/>' +
'💡 Good start to dive deeper into tweaks and select only what you want.'"
/>
<MenuOptionListItem
label="Standard"
:enabled="this.currentSelection !== SelectionType.Standard"
@click="selectType(SelectionType.Standard)"
v-tooltip=" '🛡️ Balanced for privacy and functionality.<br/>' +
'OS and applications will function normally.<br/>' +
'💡 Recommended for everyone'"
/>
<MenuOptionListItem
label="Strict"
:enabled="this.currentSelection !== SelectionType.Strict"
@click="selectType(SelectionType.Strict)"
v-tooltip=" '🚫 Stronger privacy, disables risky functions that may leak your data.<br/>' +
+ '⚠️ Double check to remove scripts where you would trade functionality for privacy<br/>'
+ '💡 Recommended for daily users that prefers more privacy over non-essential functions'"
/>
<MenuOptionListItem
label="All"
:enabled="this.currentSelection !== SelectionType.All"
@click="selectType(SelectionType.All)"
v-tooltip=" '🔒 Strongest privacy, disabling any functionality that may leak your data.<br/>'
+ '🛑 Not designed for daily users, it will break important functionalities.<br/>'
+ '💡 Only recommended for extreme use-cases like crime labs where no leak is acceptable'"
/>
</MenuOptionList>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
import MenuOptionList from './../MenuOptionList.vue';
import MenuOptionList from '../MenuOptionList.vue';
import MenuOptionListItem from '../MenuOptionListItem.vue';
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
@Component({
components: {
@@ -49,27 +49,29 @@ import MenuOptionListItem from '../MenuOptionListItem.vue';
},
})
export default class TheSelector extends StatefulVue {
public SelectionType = SelectionType;
public currentSelection = SelectionType.None;
private selectionTypeHandler: SelectionTypeHandler;
public SelectionType = SelectionType;
public async selectType(type: SelectionType) {
if (this.currentSelection === type) {
return;
}
this.selectionTypeHandler.selectType(type);
}
public currentSelection = SelectionType.None;
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.events.unsubscribeAll();
this.selectionTypeHandler = new SelectionTypeHandler(newState);
this.updateSelections();
this.events.register(newState.selection.changed.on(() => this.updateSelections()));
}
private selectionTypeHandler: SelectionTypeHandler;
private updateSelections() {
this.currentSelection = this.selectionTypeHandler.getCurrentSelectionType();
public async selectType(type: SelectionType) {
if (this.currentSelection === type) {
return;
}
this.selectionTypeHandler.selectType(type);
}
protected handleCollectionState(newState: ICategoryCollectionState): void {
this.events.unsubscribeAll();
this.selectionTypeHandler = new SelectionTypeHandler(newState);
this.updateSelections();
this.events.register(newState.selection.changed.on(() => this.updateSelections()));
}
private updateSelections() {
this.currentSelection = this.selectionTypeHandler.getCurrentSelectionType();
}
}
</script>

View File

@@ -1,11 +1,11 @@
<template>
<MenuOptionList>
<MenuOptionListItem
v-for="os in this.allOses" :key="os.name"
:enabled="currentOs !== os.os"
@click="changeOs(os.os)"
:label="os.name"
/>
<MenuOptionListItem
v-for="os in this.allOses" :key="os.name"
:enabled="currentOs !== os.os"
@click="changeOs(os.os)"
:label="os.name"
/>
</MenuOptionList>
</template>
@@ -26,6 +26,7 @@ import MenuOptionListItem from './MenuOptionListItem.vue';
})
export default class TheOsChanger extends StatefulVue {
public allOses: Array<{ name: string, os: OperatingSystem }> = [];
public currentOs?: OperatingSystem = null;
public async created() {
@@ -33,6 +34,7 @@ export default class TheOsChanger extends StatefulVue {
this.allOses = app.getSupportedOsList()
.map((os) => ({ os, name: renderOsName(os) }));
}
public async changeOs(newOs: OperatingSystem) {
const context = await this.getCurrentContext();
context.changeContext(newOs);
@@ -45,11 +47,11 @@ export default class TheOsChanger extends StatefulVue {
}
function renderOsName(os: OperatingSystem): string {
switch (os) {
case OperatingSystem.Windows: return 'Windows';
case OperatingSystem.macOS: return 'macOS';
default: throw new RangeError(`Cannot render os name: ${OperatingSystem[os]}`);
}
switch (os) {
case OperatingSystem.Windows: return 'Windows';
case OperatingSystem.macOS: return 'macOS';
default: throw new RangeError(`Cannot render os name: ${OperatingSystem[os]}`);
}
}
</script>

View File

@@ -1,22 +1,22 @@
<template>
<div id="container">
<TheSelector class="item" />
<TheOsChanger class="item" />
<TheViewChanger
class="item"
v-on:viewChanged="$emit('viewChanged', $event)"
v-if="!this.isSearching" />
</div>
<div id="container">
<TheSelector class="item" />
<TheOsChanger class="item" />
<TheViewChanger
class="item"
v-on:viewChanged="$emit('viewChanged', $event)"
v-if="!this.isSearching" />
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import TheOsChanger from './TheOsChanger.vue';
import TheSelector from './Selector/TheSelector.vue';
import TheViewChanger from './View/TheViewChanger.vue';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import TheOsChanger from './TheOsChanger.vue';
import TheSelector from './Selector/TheSelector.vue';
import TheViewChanger from './View/TheViewChanger.vue';
@Component({
components: {
@@ -34,24 +34,22 @@ export default class TheScriptsMenu extends StatefulVue {
this.unsubscribeAll();
}
protected initialize(): void {
return;
}
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.subscribe(newState);
}
private subscribe(state: IReadOnlyCategoryCollectionState) {
this.listeners.push(state.filter.filterRemoved.on(() => {
this.isSearching = false;
}));
state.filter.filtered.on(() => {
this.isSearching = true;
});
this.listeners.push(state.filter.filterRemoved.on(() => {
this.isSearching = false;
}));
state.filter.filtered.on(() => {
this.isSearching = true;
});
}
private unsubscribeAll() {
this.listeners.forEach((listener) => listener.unsubscribe());
this.listeners.splice(0, this.listeners.length);
this.listeners.forEach((listener) => listener.unsubscribe());
this.listeners.splice(0, this.listeners.length);
}
}
</script>
@@ -63,18 +61,18 @@ $margin-between-lines: 7px;
flex-wrap: wrap;
margin-top: -$margin-between-lines;
.item {
flex: 1;
white-space: nowrap;
display: flex;
justify-content: center;
align-items: center;
margin: $margin-between-lines 5px 0 5px;
&:first-child {
justify-content: flex-start;
}
&:last-child {
justify-content: flex-end;
}
flex: 1;
white-space: nowrap;
display: flex;
justify-content: center;
align-items: center;
margin: $margin-between-lines 5px 0 5px;
&:first-child {
justify-content: flex-start;
}
&:last-child {
justify-content: flex-end;
}
}
}
</style>

View File

@@ -1,21 +1,21 @@
<template>
<MenuOptionList
label="View"
class="part">
<MenuOptionListItem
v-for="view in this.viewOptions" :key="view.type"
:label="view.displayName"
:enabled="currentView !== view.type"
@click="setView(view.type)"
/>
</MenuOptionList>
<MenuOptionList
label="View"
class="part">
<MenuOptionListItem
v-for="view in this.viewOptions" :key="view.type"
:label="view.displayName"
:enabled="currentView !== view.type"
@click="setView(view.type)"
/>
</MenuOptionList>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import MenuOptionList from '../MenuOptionList.vue';
import MenuOptionListItem from '../MenuOptionListItem.vue';
import { ViewType } from './ViewType';
import MenuOptionList from './../MenuOptionList.vue';
import MenuOptionListItem from './../MenuOptionListItem.vue';
const DefaultView = ViewType.Cards;
@@ -26,32 +26,35 @@ const DefaultView = ViewType.Cards;
},
})
export default class TheViewChanger extends Vue {
public readonly viewOptions: IViewOption[] = [
{ type: ViewType.Cards, displayName: 'Cards' },
{ type: ViewType.Tree, displayName: 'Tree' },
];
public ViewType = ViewType;
public currentView?: ViewType = null;
public readonly viewOptions: IViewOption[] = [
{ type: ViewType.Cards, displayName: 'Cards' },
{ type: ViewType.Tree, displayName: 'Tree' },
];
public mounted() {
this.setView(DefaultView);
}
public groupBy(type: ViewType) {
this.setView(type);
}
public ViewType = ViewType;
private setView(view: ViewType) {
if (this.currentView === view) {
throw new Error(`View is already "${ViewType[view]}"`);
}
this.currentView = view;
this.$emit('viewChanged', this.currentView);
public currentView?: ViewType = null;
public mounted() {
this.setView(DefaultView);
}
public groupBy(type: ViewType) {
this.setView(type);
}
private setView(view: ViewType) {
if (this.currentView === view) {
throw new Error(`View is already "${ViewType[view]}"`);
}
this.currentView = view;
this.$emit('viewChanged', this.currentView);
}
}
interface IViewOption {
readonly type: ViewType;
readonly displayName: string;
readonly type: ViewType;
readonly displayName: string;
}
</script>

View File

@@ -1,4 +1,4 @@
export enum ViewType {
Cards = 1,
Tree = 0,
Cards = 1,
Tree = 0,
}

View File

@@ -1,15 +1,15 @@
<template>
<div
class="handle"
:style="{ cursor: cursorCssValue }"
@mousedown="startResize">
<div class="line" />
<font-awesome-icon
class="icon"
:icon="['fas', 'arrows-alt-h']"
/>
<div class="line" />
</div>
<div
class="handle"
:style="{ cursor: cursorCssValue }"
@mousedown="startResize">
<div class="line" />
<font-awesome-icon
class="icon"
:icon="['fas', 'arrows-alt-h']"
/>
<div class="line" />
</div>
</template>
<script lang="ts">
@@ -17,27 +17,30 @@ import { Component, Vue } from 'vue-property-decorator';
@Component
export default class Handle extends Vue {
public readonly cursorCssValue = 'ew-resize';
private initialX: number = undefined;
public readonly cursorCssValue = 'ew-resize';
public startResize(event: MouseEvent): void {
this.initialX = event.clientX;
document.body.style.setProperty('cursor', this.cursorCssValue);
document.addEventListener('mousemove', this.resize);
window.addEventListener('mouseup', this.stopResize);
event.stopPropagation();
event.preventDefault();
}
public resize(event: MouseEvent): void {
const displacementX = event.clientX - this.initialX;
this.$emit('resized', displacementX);
this.initialX = event.clientX;
}
public stopResize(): void {
document.body.style.removeProperty('cursor');
document.removeEventListener('mousemove', this.resize);
window.removeEventListener('mouseup', this.stopResize);
}
private initialX: number = undefined;
public startResize(event: MouseEvent): void {
this.initialX = event.clientX;
document.body.style.setProperty('cursor', this.cursorCssValue);
document.addEventListener('mousemove', this.resize);
window.addEventListener('mouseup', this.stopResize);
event.stopPropagation();
event.preventDefault();
}
public resize(event: MouseEvent): void {
const displacementX = event.clientX - this.initialX;
this.$emit('resized', displacementX);
this.initialX = event.clientX;
}
public stopResize(): void {
document.body.style.removeProperty('cursor');
document.removeEventListener('mousemove', this.resize);
window.removeEventListener('mouseup', this.stopResize);
}
}
</script>
@@ -49,27 +52,27 @@ $color : $color-primary-dark;
$color-hover : $color-primary;
.handle {
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
&:hover {
.line {
background: $color-hover;
}
.image {
color: $color-hover;
}
}
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
&:hover {
.line {
flex: 1;
background: $color;
width: 3px;
background: $color-hover;
}
.icon {
color: $color;
.image {
color: $color-hover;
}
margin-right: 5px;
margin-left: 5px;
}
.line {
flex: 1;
background: $color;
width: 3px;
}
.icon {
color: $color;
}
margin-right: 5px;
margin-left: 5px;
}
</style>

View File

@@ -1,18 +1,18 @@
<template>
<div class="slider" v-bind:style="{
'--vertical-margin': this.verticalMargin,
'--first-min-width': this.firstMinWidth,
'--first-initial-width': this.firstInitialWidth,
'--second-min-width': this.secondMinWidth,
}">
<div class="first" ref="firstElement">
<slot name="first"></slot>
</div>
<Handle class="handle" @resized="onResize($event)" />
<div class="second">
<slot name="second"></slot>
</div>
<div class="slider" v-bind:style="{
'--vertical-margin': this.verticalMargin,
'--first-min-width': this.firstMinWidth,
'--first-initial-width': this.firstInitialWidth,
'--second-min-width': this.secondMinWidth,
}">
<div class="first" ref="firstElement">
<slot name="first"></slot>
</div>
<Handle class="handle" @resized="onResize($event)" />
<div class="second">
<slot name="second"></slot>
</div>
</div>
</template>
<script lang="ts">
@@ -25,17 +25,20 @@ import Handle from './Handle.vue';
},
})
export default class HorizontalResizeSlider extends Vue {
@Prop() public verticalMargin: string;
@Prop() public firstMinWidth: string;
@Prop() public firstInitialWidth: string;
@Prop() public secondMinWidth: string;
@Prop() public verticalMargin: string;
private get left(): HTMLElement { return this.$refs.firstElement as HTMLElement; }
@Prop() public firstMinWidth: string;
public onResize(displacementX: number): void {
const leftWidth = this.left.offsetWidth + displacementX;
this.left.style.width = `${leftWidth}px`;
}
@Prop() public firstInitialWidth: string;
@Prop() public secondMinWidth: string;
private get left(): HTMLElement { return this.$refs.firstElement as HTMLElement; }
public onResize(displacementX: number): void {
const leftWidth = this.left.offsetWidth + displacementX;
this.left.style.width = `${leftWidth}px`;
}
}
</script>
@@ -43,27 +46,27 @@ export default class HorizontalResizeSlider extends Vue {
@use "@/presentation/assets/styles/main" as *;
.slider {
display: flex;
flex-direction: row;
display: flex;
flex-direction: row;
.first {
min-width: var(--first-min-width);
width: var(--first-initial-width);
}
.second {
flex: 1;
min-width: var(--second-min-width);
}
@media screen and (max-width: $media-vertical-view-breakpoint) {
flex-direction: column;
.first {
min-width: var(--first-min-width);
width: var(--first-initial-width);
width: auto !important;
}
.second {
flex: 1;
min-width: var(--second-min-width);
margin-top: var(--vertical-margin);
}
@media screen and (max-width: $media-vertical-view-breakpoint) {
flex-direction: column;
.first {
width: auto !important;
}
.second {
margin-top: var(--vertical-margin);
}
.handle {
display: none;
}
.handle {
display: none;
}
}
}
</style>

View File

@@ -1,17 +1,17 @@
<template>
<div class="scripts">
<div class="scripts">
<TheScriptsMenu v-on:viewChanged="currentView = $event" />
<HorizontalResizeSlider class="row"
verticalMargin="15px" firstInitialWidth="55%"
firstMinWidth="20%" secondMinWidth="20%">
<template v-slot:first>
<TheScriptsView :currentView="currentView" />
</template>
<template v-slot:second>
<TheCodeArea theme="xcode" />
</template>
verticalMargin="15px" firstInitialWidth="55%"
firstMinWidth="20%" secondMinWidth="20%">
<template v-slot:first>
<TheScriptsView :currentView="currentView" />
</template>
<template v-slot:second>
<TheCodeArea theme="xcode" />
</template>
</HorizontalResizeSlider>
</div>
</div>
</template>
<script lang="ts">
@@ -31,14 +31,14 @@ import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
},
})
export default class TheScriptArea extends Vue {
public currentView = ViewType.Cards;
public currentView = ViewType.Cards;
}
</script>
<style scoped lang="scss">
.scripts {
> * + * {
margin-top: 15px;
}
> * + * {
margin-top: 15px;
}
}
</style>

View File

@@ -1,16 +1,21 @@
<template>
<Responsive v-on:widthChanged="width = $event">
<!-- <div id="responsivity-debug">
Width: {{ width || 'undefined' }}
Size: <span v-if="width <= 500">small</span><span v-if="width > 500 && width < 750">medium</span><span v-if="width >= 750">big</span>
</div> -->
<!--
<div id="responsivity-debug">
Width: {{ width || 'undefined' }}
Size:
<span v-if="width <= 500">small</span>
<span v-if="width > 500 && width < 750">medium</span>
<span v-if="width >= 750">big</span>
</div>
-->
<div v-if="categoryIds != null && categoryIds.length > 0" class="cards">
<CardListItem
class="card"
v-bind:class="{
v-bind:class="{
'small-screen': width <= 500,
'medium-screen': width > 500 && width < 750,
'big-screen': width >= 750
'big-screen': width >= 750
}"
v-for="categoryId of categoryIds"
:data-category="categoryId"
@@ -25,13 +30,13 @@
</template>
<script lang="ts">
import CardListItem from './CardListItem.vue';
import Responsive from '@/presentation/components/Shared/Responsive.vue';
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 { hasDirective } from './NonCollapsingDirective';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { hasDirective } from './NonCollapsingDirective';
import CardListItem from './CardListItem.vue';
@Component({
components: {
@@ -40,8 +45,10 @@ import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/IC
},
})
export default class CardList extends StatefulVue {
public width: number = 0;
public width = 0;
public categoryIds: number[] = [];
public activeCategoryId?: number = null;
public created() {
@@ -75,9 +82,10 @@ export default class CardList extends StatefulVue {
}
this.activeCategoryId = null;
}
private outsideClickListener(event: PointerEvent) {
if (this.areAllCardsCollapsed()) {
return;
return;
}
const element = document.querySelector(`[data-category="${this.activeCategoryId}"]`);
const target = event.target as Element;
@@ -89,6 +97,7 @@ export default class CardList extends StatefulVue {
private collapseAllCards(): void {
this.activeCategoryId = undefined;
}
private areAllCardsCollapsed(): boolean {
return !this.activeCategoryId;
}
@@ -96,10 +105,10 @@ export default class CardList extends StatefulVue {
function isClickable(element: Element) {
const cursorName = window.getComputedStyle(element).cursor;
return [ 'pointer', 'move', 'grab'].some((name) => cursorName === name)
return ['pointer', 'move', 'grab'].some((name) => cursorName === name)
|| cursorName.includes('resize')
|| [ 'onclick', 'href'].some((attributeName) => element.hasAttribute(attributeName))
|| [ 'a', 'button'].some((tagName) => element.closest(`.${tagName}`));
|| ['onclick', 'href'].some((attributeName) => element.hasAttribute(attributeName))
|| ['a', 'button'].some((tagName) => element.closest(`.${tagName}`));
}
</script>
@@ -112,9 +121,10 @@ function isClickable(element: Element) {
flex-flow: row wrap;
font-family: $font-main;
gap: $card-gap;
/*
/*
Padding is used to allow scale animation (growing size) for cards on hover.
It ensures that there's room to grow, so the animation is shown without overflowing with scrollbars.
It ensures that there's room to grow, so the animation is shown without overflowing
with scrollbars.
*/
padding: 10px;
}
@@ -125,4 +135,4 @@ function isClickable(element: Element) {
font-size: 3.5em;
font-family: $font-normal;
}
</style>
</style>

View File

@@ -1,38 +1,49 @@
<template>
<div class="card"
v-on:click="onSelected(!isExpanded)"
v-bind:class="{
'is-collapsed': !isExpanded,
'is-inactive': activeCategoryId && activeCategoryId != categoryId,
'is-expanded': isExpanded
}"
ref="cardElement">
<div class="card__inner">
<span v-if="cardTitle && cardTitle.length > 0">
<span>{{cardTitle}}</span>
</span>
<span v-else>Oh no 😢</span>
<!-- Expand icon -->
<font-awesome-icon :icon="['far', isExpanded ? 'folder-open' : 'folder']" class="card__inner__expand-icon" />
<!-- Indeterminate and full states -->
<div class="card__inner__state-icons">
<font-awesome-icon v-if="isAnyChildSelected && !areAllChildrenSelected" :icon="['fa', 'battery-half']" />
<font-awesome-icon v-if="areAllChildrenSelected" :icon="['fa', 'battery-full']" />
</div>
<div class="card"
v-on:click="onSelected(!isExpanded)"
v-bind:class="{
'is-collapsed': !isExpanded,
'is-inactive': activeCategoryId && activeCategoryId != categoryId,
'is-expanded': isExpanded
}"
ref="cardElement">
<div class="card__inner">
<span v-if="cardTitle && cardTitle.length > 0">
<span>{{cardTitle}}</span>
</span>
<span v-else>Oh no 😢</span>
<!-- Expand icon -->
<font-awesome-icon
class="card__inner__expand-icon"
:icon="['far', isExpanded ? 'folder-open' : 'folder']"
/>
<!-- Indeterminate and full states -->
<div class="card__inner__state-icons">
<font-awesome-icon
:icon="['fa', 'battery-half']"
v-if="isAnyChildSelected && !areAllChildrenSelected"
/>
<font-awesome-icon
:icon="['fa', 'battery-full']"
v-if="areAllChildrenSelected"
/>
</div>
<div class="card__expander" v-on:click.stop>
<div class="card__expander__content">
<ScriptsTree :categoryId="categoryId"></ScriptsTree>
</div>
<div class="card__expander__close-button">
<font-awesome-icon :icon="['fas', 'times']" v-on:click="onSelected(false)"/>
</div>
</div>
<div class="card__expander" v-on:click.stop>
<div class="card__expander__content">
<ScriptsTree :categoryId="categoryId"></ScriptsTree>
</div>
<div class="card__expander__close-button">
<font-awesome-icon :icon="['fas', 'times']" v-on:click="onSelected(false)"/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Watch, Emit } from 'vue-property-decorator';
import {
Component, Prop, Watch, Emit,
} from 'vue-property-decorator';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
@@ -43,34 +54,44 @@ import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
})
export default class CardListItem extends StatefulVue {
@Prop() public categoryId!: number;
@Prop() public activeCategoryId!: number;
public cardTitle = '';
public isExpanded = false;
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)));
() => 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((r) => setTimeout(r, 400));
await new Promise((resolve) => { setTimeout(resolve, 400); });
const focusElement = this.$refs.cardElement as HTMLElement;
(focusElement as HTMLElement).scrollIntoView({behavior: 'smooth'});
focusElement.scrollIntoView({ behavior: 'smooth' });
}
}
@Watch('categoryId')
public async updateState(value: |number) {
const context = await this.getCurrentContext();
@@ -79,13 +100,11 @@ export default class CardListItem extends StatefulVue {
await this.updateSelectionIndicators(value);
}
protected handleCollectionState(): void {
return;
}
protected handleCollectionState(): void { /* do nothing */ }
private async updateSelectionIndicators(categoryId: number) {
const context = await this.getCurrentContext();
const selection = context.state.selection;
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;
@@ -105,8 +124,11 @@ $card-horizontal-gap : $card-gap;
.card {
transition: all 0.2s ease-in-out;
&__inner {
padding: /*top:*/ $card-inner-padding /*right:*/ $card-inner-padding /*bottom:*/ 0 /*left:*/ $card-inner-padding;
&__inner {
padding-top: $card-inner-padding;
padding-right: $card-inner-padding;
padding-bottom: 0;
padding-left: $card-inner-padding;
position: relative;
cursor: pointer;
background-color: $color-primary;
@@ -138,7 +160,7 @@ $card-horizontal-gap : $card-gap;
display: flex;
justify-content: flex-end;
}
&__expand-icon {
width: 100%;
margin-top: .25em;
@@ -159,7 +181,7 @@ $card-horizontal-gap : $card-gap;
justify-content: center;
word-break: break-word;
}
&__close-button {
width: auto;
font-size: 1.5em;
@@ -220,7 +242,7 @@ $card-horizontal-gap : $card-gap;
}
}
}
&.is-inactive {
.card__inner {
pointer-events: none;
@@ -228,7 +250,7 @@ $card-horizontal-gap : $card-gap;
background-color: $color-primary-light;
transform: scale(0.95);
}
&:hover {
.card__inner {
background-color: $color-primary;
@@ -269,4 +291,4 @@ $card-horizontal-gap : $card-gap;
.big-screen { @include adaptive-card(3); }
.medium-screen { @include adaptive-card(2); }
.small-screen { @include adaptive-card(1); }
</style>
</style>

View File

@@ -3,15 +3,15 @@ import { DirectiveOptions } from 'vue';
const attributeName = 'data-interaction-does-not-collapse';
export function hasDirective(el: Element): boolean {
if (el.hasAttribute(attributeName)) {
return true;
}
const parent = el.closest(`[${attributeName}]`);
return !!parent;
if (el.hasAttribute(attributeName)) {
return true;
}
const parent = el.closest(`[${attributeName}]`);
return !!parent;
}
export const NonCollapsing: DirectiveOptions = {
inserted(el: HTMLElement) {
el.setAttribute(attributeName, '');
},
inserted(el: HTMLElement) {
el.setAttribute(attributeName, '');
},
};

View File

@@ -1,6 +1,6 @@
import { ICategory, IScript } from '@/domain/ICategory';
import { INode, NodeType } from './SelectableTree/Node/INode';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { INode, NodeType } from './SelectableTree/Node/INode';
export function parseAllCategories(collection: ICategoryCollection): INode[] | undefined {
const nodes = new Array<INode>();
@@ -11,7 +11,10 @@ export function parseAllCategories(collection: ICategoryCollection): INode[] | u
return nodes;
}
export function parseSingleCategory(categoryId: number, collection: ICategoryCollection): INode[] | undefined {
export function parseSingleCategory(
categoryId: number,
collection: ICategoryCollection,
): INode[] | undefined {
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id ${categoryId} does not exist`);
@@ -35,7 +38,8 @@ export function getCategoryNodeId(category: ICategory): string {
}
function parseCategoryRecursively(
parentCategory: ICategory): INode[] {
parentCategory: ICategory,
): INode[] {
if (!parentCategory) {
throw new Error('parentCategory is undefined');
}
@@ -67,7 +71,9 @@ function addCategories(categories: ReadonlyArray<ICategory>, nodes: INode[]): IN
}
function convertCategoryToNode(
category: ICategory, children: readonly INode[]): INode {
category: ICategory,
children: readonly INode[],
): INode {
return {
id: getCategoryNodeId(category),
type: NodeType.Category,

View File

@@ -1,17 +1,17 @@
<template>
<span id="container">
<span v-if="nodes != null && nodes.length > 0">
<SelectableTree
:initialNodes="nodes"
:selectedNodeIds="selectedNodeIds"
:filterPredicate="filterPredicate"
:filterText="filterText"
v-on:nodeSelected="toggleNodeSelection($event)"
>
</SelectableTree>
</span>
<span v-else>Nooo 😢</span>
<span id="container">
<span v-if="nodes != null && nodes.length > 0">
<SelectableTree
:initialNodes="nodes"
:selectedNodeIds="selectedNodeIds"
:filterPredicate="filterPredicate"
:filterText="filterText"
v-on:nodeSelected="toggleNodeSelection($event)"
>
</SelectableTree>
</span>
<span v-else>Nooo 😢</span>
</span>
</template>
<script lang="ts">
@@ -21,11 +21,13 @@ import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { parseAllCategories, parseSingleCategory, getScriptNodeId,
getCategoryNodeId, getCategoryId, getScriptId } from './ScriptNodeParser';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import {
parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId,
getScriptId,
} from './ScriptNodeParser';
import SelectableTree from './SelectableTree/SelectableTree.vue';
import { INode, NodeType } from './SelectableTree/Node/INode';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
@Component({
@@ -37,24 +39,27 @@ export default class ScriptsTree extends StatefulVue {
@Prop() public categoryId?: number;
public nodes?: ReadonlyArray<INode> = null;
public selectedNodeIds?: ReadonlyArray<string> = [];
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}`);
}
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();
@@ -66,11 +71,12 @@ export default class ScriptsTree extends StatefulVue {
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));
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) {
@@ -97,20 +103,26 @@ export default class ScriptsTree extends StatefulVue {
this.handleFiltered(currentFilter);
}
}
private handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
this.selectedNodeIds = selectedScripts
.map((node) => node.id);
.map((node) => node.id);
}
private handleFilterRemoved() {
this.filterText = '';
}
private handleFiltered(result: IFilterResult) {
this.filterText = result.query;
this.filtered = result;
}
}
function toggleCategoryNodeSelection(event: INodeSelectedEvent, state: ICategoryCollectionState): void {
function toggleCategoryNodeSelection(
event: INodeSelectedEvent,
state: ICategoryCollectionState,
): void {
const categoryId = getCategoryId(event.node.id);
if (event.isSelected) {
state.selection.addOrUpdateAllInCategory(categoryId, false);
@@ -118,7 +130,11 @@ function toggleCategoryNodeSelection(event: INodeSelectedEvent, state: ICategory
state.selection.removeAllInCategory(categoryId);
}
}
function toggleScriptNodeSelection(event: INodeSelectedEvent, state: ICategoryCollectionState): void {
function toggleScriptNodeSelection(
event: INodeSelectedEvent,
state: ICategoryCollectionState,
): void {
const scriptId = getScriptId(event.node.id);
const actualToggleState = state.selection.isSelected(scriptId);
const targetToggleState = event.isSelected;

View File

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

View File

@@ -1,76 +1,75 @@
declare module 'liquor-tree' {
import { PluginObject } from 'vue';
import { VueClass } from 'vue-class-component/lib/declarations';
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 {
readonly model: ReadonlyArray<ILiquorTreeExistingNode>;
filter(query: string): void;
clearFilter(): void;
setModel(nodes: ReadonlyArray<ILiquorTreeNewNode>): void;
// getNodeById(id: string): ILiquorTreeExistingNode;
recurseDown(fn: (node: ILiquorTreeExistingNode) => void): void;
}
export interface ICustomLiquorTreeData {
type: number;
documentationUrls: ReadonlyArray<string>;
isReversible: boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
export interface ILiquorTreeNodeState {
checked: boolean;
indeterminate: boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js
export interface ILiquorTree {
readonly model: ReadonlyArray<ILiquorTreeExistingNode>;
filter(query: string): void;
clearFilter(): void;
setModel(nodes: ReadonlyArray<ILiquorTreeNewNode>): void;
// getNodeById(id: string): ILiquorTreeExistingNode;
recurseDown(fn: (node: ILiquorTreeExistingNode) => void): void;
}
export interface ICustomLiquorTreeData {
type: number;
documentationUrls: ReadonlyArray<string>;
isReversible: boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
export interface ILiquorTreeNodeState {
checked: boolean;
indeterminate: boolean;
}
export interface ILiquorTreeNode {
id: string;
data: ICustomLiquorTreeData;
children: ReadonlyArray<ILiquorTreeNode> | undefined;
}
/**
* Returned from Node tree view events.
* See constructor in https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeExistingNode extends ILiquorTreeNode {
data: ILiquorTreeNodeData;
states: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeExistingNode> | undefined;
// expand(): void;
}
export interface ILiquorTreeNode {
id: string;
data: ICustomLiquorTreeData;
children: ReadonlyArray<ILiquorTreeNode> | undefined;
}
/**
Returned from Node tree view events.
See constructor in https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeExistingNode extends ILiquorTreeNode {
data: ILiquorTreeNodeData;
states: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeExistingNode> | undefined;
// expand(): void;
}
/**
* Sent to liquor tree to define of new nodes.
* https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeNewNode extends ILiquorTreeNode {
text: string;
state: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeNewNode> | undefined;
}
/**
Sent to liquor tree to define of new nodes.
https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeNewNode extends ILiquorTreeNode {
text: string;
state: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeNewNode> | undefined;
}
// https://amsik.github.io/liquor-tree/#Component-Options
export interface ILiquorTreeOptions {
multiple: boolean;
checkbox: boolean;
checkOnSelect: boolean;
autoCheckChildren: boolean;
parentSelect: boolean;
keyboardNavigation: boolean;
filter: ILiquorTreeFilter;
deletion(node: ILiquorTreeNode): boolean;
}
// https://amsik.github.io/liquor-tree/#Component-Options
export interface ILiquorTreeOptions {
multiple: boolean;
checkbox: boolean;
checkOnSelect: boolean;
autoCheckChildren: boolean;
parentSelect: boolean;
keyboardNavigation: boolean;
filter: ILiquorTreeFilter;
deletion(node: ILiquorTreeNode): boolean;
}
export interface ILiquorTreeNodeData extends ICustomLiquorTreeData {
text: string;
}
export interface ILiquorTreeNodeData extends ICustomLiquorTreeData {
text: string;
}
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
export interface ILiquorTreeFilter {
emptyText: string;
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
export interface ILiquorTreeFilter {
emptyText: string;
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
}
const LiquorTree: PluginObject<any> & VueClass<any>;
export default LiquorTree;
const LiquorTree: PluginObject<Vue> & VueClass<Vue>;
export default LiquorTree;
}

View File

@@ -1,23 +1,38 @@
import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeNode, ILiquorTreeExistingNode } from 'liquor-tree';
import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
export class LiquorTreeOptions implements ILiquorTreeOptions {
public readonly multiple = true;
public readonly checkbox = true;
public readonly checkOnSelect = true;
/* For checkbox mode only. Children will have the same checked state as their parent.
⚠️ Setting this false, does not update indeterminate state of nodes.
This is false as it's handled manually to be able to batch select for performance + highlighting */
public readonly autoCheckChildren = false;
public readonly parentSelect = true;
public readonly keyboardNavigation = true;
public readonly filter = { // Wrap this in an arrow function as setting filter directly does not work JS APIs
emptyText: this.liquorTreeFilter.emptyText,
matcher: (query: string, node: ILiquorTreeExistingNode) => {
return this.liquorTreeFilter.matcher(query, node);
},
};
constructor(private readonly liquorTreeFilter: ILiquorTreeFilter) { }
public deletion(node: ILiquorTreeNode): boolean {
return false; // no op
}
public readonly multiple = true;
public readonly checkbox = true;
public readonly checkOnSelect = true;
/*
For checkbox mode only. Children will have the same checked state as their parent.
⚠️ Setting this false does not prevent updating indeterminate state of nodes.
It's set to false anyway because state is handled manually, and this way batch selections can
be done in more performant way.
*/
public readonly autoCheckChildren = false;
public readonly parentSelect = true;
public readonly keyboardNavigation = true;
/*
Filter is wrapped in an arrow function because setting filter directly does not work with
underling JavaScript APIs.
*/
public readonly filter = {
emptyText: this.liquorTreeFilter.emptyText,
matcher: (query: string, node: ILiquorTreeExistingNode) => {
return this.liquorTreeFilter.matcher(query, node);
},
};
constructor(private readonly liquorTreeFilter: ILiquorTreeFilter) { }
public deletion(): boolean {
return false; // no op
}
}

View File

@@ -1,17 +1,19 @@
import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
import { convertExistingToNode } from './NodeTranslator';
import { INode } from '../../Node/INode';
import { convertExistingToNode } from './NodeTranslator';
export type FilterPredicate = (node: INode) => boolean;
export class NodePredicateFilter implements ILiquorTreeFilter {
public emptyText = ''; // Does not matter as a custom mesage is shown
constructor(private readonly filterPredicate: FilterPredicate) {
if (!filterPredicate) {
throw new Error('filterPredicate is undefined');
}
}
public matcher(query: string, node: ILiquorTreeExistingNode): boolean {
return this.filterPredicate(convertExistingToNode(node));
public emptyText = ''; // Does not matter as a custom mesage is shown
constructor(private readonly filterPredicate: FilterPredicate) {
if (!filterPredicate) {
throw new Error('filterPredicate is undefined');
}
}
public matcher(query: string, node: ILiquorTreeExistingNode): boolean {
return this.filterPredicate(convertExistingToNode(node));
}
}

View File

@@ -2,60 +2,63 @@ import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
import { NodeType } from '../../Node/INode';
export function getNewState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): ILiquorTreeNodeState {
const checked = getNewCheckedState(node, selectedNodeIds);
const indeterminate = !checked && getNewIndeterminateState(node, selectedNodeIds);
return {
checked, indeterminate,
};
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>,
): ILiquorTreeNodeState {
const checked = getNewCheckedState(node, selectedNodeIds);
const indeterminate = !checked && getNewIndeterminateState(node, selectedNodeIds);
return {
checked, indeterminate,
};
}
function getNewIndeterminateState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): boolean {
switch (node.data.type) {
case NodeType.Script:
return false;
case NodeType.Category:
return parseAllSubScriptIds(node).some((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>,
): boolean {
switch (node.data.type) {
case NodeType.Script:
return false;
case NodeType.Category:
return parseAllSubScriptIds(node).some((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
}
function getNewCheckedState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): boolean {
switch (node.data.type) {
case NodeType.Script:
return selectedNodeIds.some((id) => id === node.id);
case NodeType.Category:
return parseAllSubScriptIds(node).every((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>,
): boolean {
switch (node.data.type) {
case NodeType.Script:
return selectedNodeIds.some((id) => id === node.id);
case NodeType.Category:
return parseAllSubScriptIds(node).every((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
}
function parseAllSubScriptIds(categoryNode: ILiquorTreeNode): ReadonlyArray<string> {
if (categoryNode.data.type !== NodeType.Category) {
throw new Error('Not a category node');
}
if (!categoryNode.children) {
return [];
}
return categoryNode
.children
.flatMap((child) => getNodeIds(child));
if (categoryNode.data.type !== NodeType.Category) {
throw new Error('Not a category node');
}
if (!categoryNode.children) {
return [];
}
return categoryNode
.children
.flatMap((child) => getNodeIds(child));
}
function getNodeIds(node: ILiquorTreeNode): ReadonlyArray<string> {
switch (node.data.type) {
case NodeType.Script:
return [ node.id ];
case NodeType.Category:
return parseAllSubScriptIds(node);
default:
throw new Error('Unknown node type');
}
switch (node.data.type) {
case NodeType.Script:
return [node.id];
case NodeType.Category:
return parseAllSubScriptIds(node);
default:
throw new Error('Unknown node type');
}
}

View File

@@ -4,41 +4,42 @@ import { INode } from '../../Node/INode';
// Functions to translate INode to LiqourTree models and vice versa for anti-corruption
export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INode {
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return {
id: liquorTreeNode.id,
type: liquorTreeNode.data.type,
text: liquorTreeNode.data.text,
// selected: liquorTreeNode.states && liquorTreeNode.states.checked,
children: convertChildren(liquorTreeNode.children, convertExistingToNode),
documentationUrls: liquorTreeNode.data.documentationUrls,
isReversible : liquorTreeNode.data.isReversible,
};
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return {
id: liquorTreeNode.id,
type: liquorTreeNode.data.type,
text: liquorTreeNode.data.text,
// selected: liquorTreeNode.states && liquorTreeNode.states.checked,
children: convertChildren(liquorTreeNode.children, convertExistingToNode),
documentationUrls: liquorTreeNode.data.documentationUrls,
isReversible: liquorTreeNode.data.isReversible,
};
}
export function toNewLiquorTreeNode(node: INode): ILiquorTreeNewNode {
if (!node) { throw new Error('node is undefined'); }
return {
id: node.id,
text: node.text,
state: {
checked: false,
indeterminate: false,
},
children: convertChildren(node.children, toNewLiquorTreeNode),
data: {
documentationUrls: node.documentationUrls,
isReversible: node.isReversible,
type: node.type,
},
};
if (!node) { throw new Error('node is undefined'); }
return {
id: node.id,
text: node.text,
state: {
checked: false,
indeterminate: false,
},
children: convertChildren(node.children, toNewLiquorTreeNode),
data: {
documentationUrls: node.documentationUrls,
isReversible: node.isReversible,
type: node.type,
},
};
}
function convertChildren<TOldNode, TNewNode>(
oldChildren: readonly TOldNode[],
callback: (value: TOldNode) => TNewNode): TNewNode[] {
if (!oldChildren || oldChildren.length === 0) {
return [];
}
return oldChildren.map((childNode) => callback(childNode));
oldChildren: readonly TOldNode[],
callback: (value: TOldNode) => TNewNode,
): TNewNode[] {
if (!oldChildren || oldChildren.length === 0) {
return [];
}
return oldChildren.map((childNode) => callback(childNode));
}

View File

@@ -1,47 +1,45 @@
<template>
<div class="documentationUrls">
<a v-for="url of this.documentationUrls"
v-bind:key="url"
:href="url"
:alt="url"
target="_blank" class="documentationUrl"
v-tooltip.top-center="url"
v-on:click.stop>
<font-awesome-icon :icon="['fas', 'info-circle']" />
</a>
</div>
<div class="documentationUrls">
<a v-for="url of this.documentationUrls"
v-bind:key="url"
:href="url"
:alt="url"
target="_blank" class="documentationUrl"
v-tooltip.top-center="url"
v-on:click.stop>
<font-awesome-icon :icon="['fas', 'info-circle']" />
</a>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component
export default class DocumentationUrls extends Vue {
@Prop() public documentationUrls: string[];
}
import { Component, Prop, Vue } from 'vue-property-decorator';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component
export default class DocumentationUrls extends Vue {
@Prop() public documentationUrls: string[];
}
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.documentationUrls {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.documentationUrl {
display: flex;
color: $color-primary;
cursor: pointer;
vertical-align: middle;
&:hover {
color: $color-primary-darker;
}
&:not(:first-child) {
margin-left: 0.1em;
}
display: flex;
color: $color-primary;
cursor: pointer;
vertical-align: middle;
&:hover {
color: $color-primary-darker;
}
&:not(:first-child) {
margin-left: 0.1em;
}
}
</style>
</style>

View File

@@ -1,13 +1,13 @@
export enum NodeType {
Script,
Category,
Script,
Category,
}
export interface INode {
readonly id: string;
readonly text: string;
readonly isReversible: boolean;
readonly documentationUrls: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INode>;
readonly type: NodeType;
readonly id: string;
readonly text: string;
readonly isReversible: boolean;
readonly documentationUrls: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INode>;
readonly type: NodeType;
}

View File

@@ -1,36 +1,34 @@
<template>
<div id="node">
<div class="item text">{{ this.data.text }}</div>
<RevertToggle
class="item"
v-if="data.isReversible"
:node="data" />
<DocumentationUrls
class="item"
v-if="data.documentationUrls && data.documentationUrls.length > 0"
:documentationUrls="this.data.documentationUrls" />
</div>
<div id="node">
<div class="item text">{{ this.data.text }}</div>
<RevertToggle
class="item"
v-if="data.isReversible"
:node="data" />
<DocumentationUrls
class="item"
v-if="data.documentationUrls && data.documentationUrls.length > 0"
:documentationUrls="this.data.documentationUrls" />
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { INode } from './INode';
import RevertToggle from './RevertToggle.vue';
import DocumentationUrls from './DocumentationUrls.vue';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({
components: {
RevertToggle,
DocumentationUrls,
},
})
export default class Node extends Vue {
@Prop() public data: INode;
}
import { Component, Prop, Vue } from 'vue-property-decorator';
import { INode } from './INode';
import RevertToggle from './RevertToggle.vue';
import DocumentationUrls from './DocumentationUrls.vue';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({
components: {
RevertToggle,
DocumentationUrls,
},
})
export default class Node extends Vue {
@Prop() public data: INode;
}
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
@@ -46,4 +44,4 @@
margin-left: 5px;
}
}
</style>
</style>

View File

@@ -1,55 +1,55 @@
<template>
<div class="checkbox-switch" >
<input type="checkbox" class="input-checkbox"
v-model="isReverted"
@change="onRevertToggled()"
v-on:click.stop>
<div class="checkbox-animate">
<span class="checkbox-off">revert</span>
<span class="checkbox-on">revert</span>
</div>
</div>
<div class="checkbox-switch" >
<input type="checkbox" class="input-checkbox"
v-model="isReverted"
@change="onRevertToggled()"
v-on:click.stop>
<div class="checkbox-animate">
<span class="checkbox-off">revert</span>
<span class="checkbox-on">revert</span>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Watch } from 'vue-property-decorator';
import { IReverter } from './Reverter/IReverter';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { INode } from './INode';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { getReverter } from './Reverter/ReverterFactory';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IReverter } from './Reverter/IReverter';
import { INode } from './INode';
import { getReverter } from './Reverter/ReverterFactory';
@Component
export default class RevertToggle extends StatefulVue {
@Prop() public node: INode;
public isReverted = false;
@Prop() public node: INode;
private handler: IReverter;
public isReverted = false;
@Watch('node', {immediate: true}) public async onNodeChanged(node: INode) {
const context = await this.getCurrentContext();
this.handler = getReverter(node, context.state.collection);
}
public async onRevertToggled() {
const context = await this.getCurrentContext();
this.handler.selectWithRevertState(this.isReverted, context.state.selection);
}
private handler: IReverter;
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.updateStatus(newState.selection.selectedScripts);
this.events.unsubscribeAll();
this.events.register(newState.selection.changed.on((scripts) => this.updateStatus(scripts)));
}
@Watch('node', { immediate: true }) public async onNodeChanged(node: INode) {
const context = await this.getCurrentContext();
this.handler = getReverter(node, context.state.collection);
}
private updateStatus(scripts: ReadonlyArray<SelectedScript>) {
this.isReverted = this.handler.getState(scripts);
}
public async onRevertToggled() {
const context = await this.getCurrentContext();
this.handler.selectWithRevertState(this.isReverted, context.state.selection);
}
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.updateStatus(newState.selection.selectedScripts);
this.events.unsubscribeAll();
this.events.register(newState.selection.changed.on((scripts) => this.updateStatus(scripts)));
}
private updateStatus(scripts: ReadonlyArray<SelectedScript>) {
this.isReverted = this.handler.getState(scripts);
}
}
</script>
<style scoped lang="scss">
@use 'sass:math';
@use "@/presentation/assets/styles/main" as *;
@@ -65,96 +65,96 @@ $size-height : 30px;
// https://www.designlabthemes.com/css-toggle-switch/
.checkbox-switch {
cursor: pointer;
display: inline-block;
overflow: hidden;
position: relative;
width: $size-width;
height: $size-height;
-webkit-border-radius: $size-height;
border-radius: $size-height;
line-height: $size-height;
font-size: math.div($size-height, 2);
display: inline-block;
input.input-checkbox {
position: absolute;
left: 0;
top: 0;
width: $size-width;
height: $size-height;
padding: 0;
margin: 0;
opacity: 0;
z-index: 2;
cursor: pointer;
display: inline-block;
overflow: hidden;
}
.checkbox-animate {
position: relative;
width: $size-width;
height: $size-height;
-webkit-border-radius: $size-height;
border-radius: $size-height;
line-height: $size-height;
font-size: math.div($size-height, 2);
display: inline-block;
background-color: $color-bg-unchecked;
-webkit-transition: background-color 0.25s ease-out 0s;
transition: background-color 0.25s ease-out 0s;
input.input-checkbox {
position: absolute;
left: 0;
top: 0;
width: $size-width;
height: $size-height;
padding: 0;
margin: 0;
opacity: 0;
z-index: 2;
cursor: pointer;
// Circle
&:before {
$circle-size: $size-height * 0.66;
content: "";
display: block;
position: absolute;
width: $circle-size;
height: $circle-size;
border-radius: $circle-size * 2;
-webkit-border-radius: $circle-size * 2;
background-color: $color-bullet-unchecked;
top: $size-height * 0.16;
left: $size-width * 0.05;
-webkit-transition: left 0.3s ease-out 0s;
transition: left 0.3s ease-out 0s;
z-index: 10;
}
}
.checkbox-animate {
position: relative;
width: $size-width;
height: $size-height;
background-color: $color-bg-unchecked;
-webkit-transition: background-color 0.25s ease-out 0s;
transition: background-color 0.25s ease-out 0s;
// Circle
&:before {
$circle-size: $size-height * 0.66;
content: "";
display: block;
position: absolute;
width: $circle-size;
height: $circle-size;
border-radius: $circle-size * 2;
-webkit-border-radius: $circle-size * 2;
background-color: $color-bullet-unchecked;
top: $size-height * 0.16;
left: $size-width * 0.05;
-webkit-transition: left 0.3s ease-out 0s;
transition: left 0.3s ease-out 0s;
z-index: 10;
}
input.input-checkbox:checked {
+ .checkbox-animate {
background-color: $color-bg-checked;
}
input.input-checkbox:checked {
+ .checkbox-animate {
background-color: $color-bg-checked;
}
+ .checkbox-animate:before {
left: ($size-width - math.div($size-width, 3.5));
background-color: $color-bullet-checked;
}
+ .checkbox-animate .checkbox-off {
display: none;
opacity: 0;
}
+ .checkbox-animate .checkbox-on {
display: block;
opacity: 1;
}
+ .checkbox-animate:before {
left: ($size-width - math.div($size-width, 3.5));
background-color: $color-bullet-checked;
}
.checkbox-off, .checkbox-on {
float: left;
font-weight: 700;
-webkit-transition: all 0.3s ease-out 0s;
transition: all 0.3s ease-out 0s;
}
.checkbox-off {
margin-left: math.div($size-width, 3);
opacity: 1;
color: $color-text-unchecked;
}
.checkbox-on {
+ .checkbox-animate .checkbox-off {
display: none;
float: right;
margin-right: math.div($size-width, 3);
opacity: 0;
color: $color-text-checked;
}
+ .checkbox-animate .checkbox-on {
display: block;
opacity: 1;
}
}
.checkbox-off, .checkbox-on {
float: left;
font-weight: 700;
-webkit-transition: all 0.3s ease-out 0s;
transition: all 0.3s ease-out 0s;
}
.checkbox-off {
margin-left: math.div($size-width, 3);
opacity: 1;
color: $color-text-unchecked;
}
.checkbox-on {
display: none;
float: right;
margin-right: math.div($size-width, 3);
opacity: 0;
color: $color-text-checked;
}
}
</style>
</style>

View File

@@ -1,30 +1,34 @@
import { IReverter } from './IReverter';
import { getCategoryId } from '../../../ScriptNodeParser';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ScriptReverter } from './ScriptReverter';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { getCategoryId } from '../../../ScriptNodeParser';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';
export class CategoryReverter implements IReverter {
private readonly categoryId: number;
private readonly scriptReverters: ReadonlyArray<ScriptReverter>;
constructor(nodeId: string, collection: ICategoryCollection) {
this.categoryId = getCategoryId(nodeId);
this.scriptReverters = getAllSubScriptReverters(this.categoryId, collection);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
return this.scriptReverters.every((script) => script.getState(selectedScripts));
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateAllInCategory(this.categoryId, newState);
}
private readonly categoryId: number;
private readonly scriptReverters: ReadonlyArray<ScriptReverter>;
constructor(nodeId: string, collection: ICategoryCollection) {
this.categoryId = getCategoryId(nodeId);
this.scriptReverters = getAllSubScriptReverters(this.categoryId, collection);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
return this.scriptReverters.every((script) => script.getState(selectedScripts));
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateAllInCategory(this.categoryId, newState);
}
}
function getAllSubScriptReverters(categoryId: number, collection: ICategoryCollection) {
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id "${categoryId}" does not exist`);
}
const scripts = category.getAllScriptsRecursively();
return scripts.map((script) => new ScriptReverter(script.id));
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id "${categoryId}" does not exist`);
}
const scripts = category.getAllScriptsRecursively();
return scripts.map((script) => new ScriptReverter(script.id));
}

View File

@@ -2,6 +2,6 @@ import { IUserSelection } from '@/application/Context/State/Selection/IUserSelec
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
export interface IReverter {
getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean;
selectWithRevertState(newState: boolean, selection: IUserSelection): void;
getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean;
selectWithRevertState(newState: boolean, selection: IUserSelection): void;
}

View File

@@ -1,16 +1,16 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { INode, NodeType } from '../INode';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';
import { CategoryReverter } from './CategoryReverter';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
export function getReverter(node: INode, collection: ICategoryCollection): IReverter {
switch (node.type) {
case NodeType.Category:
return new CategoryReverter(node.id, collection);
case NodeType.Script:
return new ScriptReverter(node.id);
default:
throw new Error('Unknown script type');
}
switch (node.type) {
case NodeType.Category:
return new CategoryReverter(node.id, collection);
case NodeType.Script:
return new ScriptReverter(node.id);
default:
throw new Error('Unknown script type');
}
}

View File

@@ -1,21 +1,24 @@
import { IReverter } from './IReverter';
import { getScriptId } from '../../../ScriptNodeParser';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { getScriptId } from '../../../ScriptNodeParser';
import { IReverter } from './IReverter';
export class ScriptReverter implements IReverter {
private readonly scriptId: string;
constructor(nodeId: string) {
this.scriptId = getScriptId(nodeId);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
const selectedScript = selectedScripts.find((selected) => selected.id === this.scriptId);
if (!selectedScript) {
return false;
}
return selectedScript.revert;
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateSelectedScript(this.scriptId, newState);
private readonly scriptId: string;
constructor(nodeId: string) {
this.scriptId = getScriptId(nodeId);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
const selectedScript = selectedScripts.find((selected) => selected.id === this.scriptId);
if (!selectedScript) {
return false;
}
return selectedScript.revert;
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateSelectedScript(this.scriptId, newState);
}
}

View File

@@ -1,137 +1,163 @@
<template>
<span>
<span v-if="initialLiquourTreeNodes != null && initialLiquourTreeNodes.length > 0">
<tree :options="liquorTreeOptions"
:data="initialLiquourTreeNodes"
v-on:node:checked="nodeSelected($event)"
v-on:node:unchecked="nodeSelected($event)"
ref="treeElement"
>
<span class="tree-text" slot-scope="{ node }" >
<Node :data="convertExistingToNode(node)" />
</span>
</tree>
<span>
<span v-if="initialLiquorTreeNodes != null && initialLiquorTreeNodes.length > 0">
<tree :options="liquorTreeOptions"
:data="initialLiquorTreeNodes"
v-on:node:checked="nodeSelected($event)"
v-on:node:unchecked="nodeSelected($event)"
ref="treeElement"
>
<span class="tree-text" slot-scope="{ node }" >
<Node :data="convertExistingToNode(node)" />
</span>
<span v-else>Nooo 😢</span>
</tree>
</span>
<span v-else>Nooo 😢</span>
</span>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import LiquorTree from 'liquor-tree';
import {
Component, Prop, Vue, Watch,
} from 'vue-property-decorator';
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 { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
import { INodeSelectedEvent } from './INodeSelectedEvent';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import { getNewState } from './LiquorTree/NodeWrapper/NodeStateUpdater';
import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions';
import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodeWrapper/NodePredicateFilter';
import { ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({
components: {
LiquorTree,
Node,
},
components: {
LiquorTree,
Node,
},
})
export default class SelectableTree extends Vue { // Keep it stateless to make it easier to switch out
@Prop() public filterPredicate?: FilterPredicate;
@Prop() public filterText?: string;
@Prop() public selectedNodeIds?: ReadonlyArray<string>;
@Prop() public initialNodes?: ReadonlyArray<INode>;
export default class SelectableTree extends Vue { // Stateless to make it easier to switch out
@Prop() public filterPredicate?: FilterPredicate;
public initialLiquourTreeNodes?: ILiquorTreeNewNode[] = null;
public liquorTreeOptions = new LiquorTreeOptions(new NodePredicateFilter((node) => this.filterPredicate(node)));
public convertExistingToNode = convertExistingToNode;
@Prop() public filterText?: string;
public nodeSelected(node: ILiquorTreeExistingNode) {
const event: INodeSelectedEvent = {
node: convertExistingToNode(node),
isSelected: node.states.checked,
};
this.$emit('nodeSelected', event);
return;
}
@Prop() public selectedNodeIds?: ReadonlyArray<string>;
@Watch('initialNodes', { immediate: true })
public async updateNodes(nodes: readonly INode[]) {
if (!nodes) {
throw new Error('undefined initial nodes');
}
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
if (this.selectedNodeIds) {
recurseDown(initialNodes,
(node) => node.state = updateState(node.state, node, this.selectedNodeIds));
}
this.initialLiquourTreeNodes = initialNodes;
const api = await this.getLiquorTreeApi();
api.setModel(this.initialLiquourTreeNodes); // as liquor tree is not reactive to data after initialization
}
@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
}
}
@Prop() public initialNodes?: ReadonlyArray<INode>;
@Watch('selectedNodeIds')
public async setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) {
throw new Error('SelectedrecurseDown nodes are undefined');
}
const tree = await this.getLiquorTreeApi();
tree.recurseDown(
(node) => node.states = updateState(node.states, node, selectedNodeIds),
);
}
public initialLiquorTreeNodes?: ILiquorTreeNewNode[] = null;
private async getLiquorTreeApi(): Promise<ILiquorTree> {
const accessor = (): ILiquorTree => {
const uiElement = this.$refs.treeElement;
return uiElement ? (uiElement as any).tree : undefined;
};
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;
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('undefined initial nodes');
}
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
if (this.selectedNodeIds) {
recurseDown(
initialNodes,
(node) => {
node.state = updateState(node.state, node, this.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
}
}
@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;
};
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,
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): ILiquorTreeNodeState {
return {...old, ...getNewState(node, selectedNodeIds)};
old: ILiquorTreeNodeState,
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>,
): ILiquorTreeNodeState {
return { ...old, ...getNewState(node, selectedNodeIds) };
}
function recurseDown(
nodes: ReadonlyArray<ILiquorTreeNewNode>,
handler: (node: ILiquorTreeNewNode) => void) {
for (const node of nodes) {
handler(node);
if (node.children) {
recurseDown(node.children, handler);
}
nodes: ReadonlyArray<ILiquorTreeNewNode>,
handler: (node: ILiquorTreeNewNode) => void,
) {
for (const node of nodes) {
handler(node);
if (node.children) {
recurseDown(node.children, handler);
}
}
}
async function tryUntilDefined<T>(
accessor: () => T | undefined,
delayInMs: number, maxTries: number): Promise<T | undefined> {
let triesLeft = maxTries;
let value: T;
while (triesLeft !== 0) {
value = accessor();
if (value) {
return value;
}
triesLeft--;
await sleep(delayInMs);
accessor: () => T | undefined,
delayInMs: number,
maxTries: number,
): Promise<T | undefined> {
let triesLeft = maxTries;
let value: T;
while (triesLeft !== 0) {
value = accessor();
if (value) {
return value;
}
return value;
triesLeft--;
// eslint-disable-next-line no-await-in-loop
await sleep(delayInMs);
}
return value;
}
</script>

View File

@@ -1,38 +1,41 @@
<template>
<div class="scripts">
<div v-if="!isSearching">
<CardList v-if="currentView === ViewType.Cards"/>
<div class="tree" v-else-if="currentView === ViewType.Tree">
<ScriptsTree />
</div>
</div>
<div v-else> <!-- Searching -->
<div class="search">
<div class="search__query">
<div>Searching for "{{this.searchQuery | threeDotsTrim}}"</div>
<div class="search__query__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
v-on:click="clearSearchQuery()"/>
</div>
</div>
<div v-if="!searchHasMatches" class="search-no-matches">
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim}}" 😞</div>
<div>Feel free to extend the scripts <a :href="repositoryUrl" target="_blank" class="child github" >here</a> </div>
</div>
</div>
<div v-if="searchHasMatches" class="tree tree--searching">
<ScriptsTree />
</div>
</div>
<div class="scripts">
<div v-if="!isSearching">
<CardList v-if="currentView === ViewType.Cards"/>
<div class="tree" v-else-if="currentView === ViewType.Tree">
<ScriptsTree />
</div>
</div>
<div v-else> <!-- Searching -->
<div class="search">
<div class="search__query">
<div>Searching for "{{this.searchQuery | threeDotsTrim}}"</div>
<div class="search__query__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
v-on:click="clearSearchQuery()"/>
</div>
</div>
<div v-if="!searchHasMatches" class="search-no-matches">
<div>Sorry, no matches for "{{this.searchQuery | threeDotsTrim}}" 😞</div>
<div>
Feel free to extend the scripts
<a :href="repositoryUrl" target="_blank" class="child github" >here</a>
</div>
</div>
</div>
<div v-if="searchHasMatches" class="tree tree--searching">
<ScriptsTree />
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
import TheGrouper from '@/presentation/components/Scripts/Menu/View/TheViewChanger.vue';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
@@ -41,56 +44,62 @@ import { ApplicationFactory } from '@/application/ApplicationFactory';
/** Shows content of single category or many categories */
@Component({
components: {
components: {
TheGrouper,
ScriptsTree,
CardList,
},
filters: {
},
filters: {
threeDotsTrim(query: string) {
const threshold = 30;
if (query.length <= threshold - 3) {
return query;
}
return `${query.substr(0, threshold)}...`;
const threshold = 30;
if (query.length <= threshold - 3) {
return query;
}
return `${query.substr(0, threshold)}...`;
},
},
},
})
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 repositoryUrl = '';
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;
filter.removeFilter();
}
public searchQuery = '';
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.events.unsubscribeAll();
this.subscribeState(newState);
}
public isSearching = false;
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();
}),
);
}
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>
@@ -100,57 +109,58 @@ export default class TheScriptsView extends StatefulVue {
$margin-inner: 4px;
.scripts {
margin-top: $margin-inner;
@media screen and (min-width: $media-vertical-view-breakpoint) { // so the current code is always visible
overflow: auto;
max-height: 70vh;
}
.tree {
padding-left: 3%;
padding-top: 15px;
padding-bottom: 15px;
&--searching {
padding-top: 0px;
}
margin-top: $margin-inner;
@media screen and (min-width: $media-vertical-view-breakpoint) {
// so the current code is always visible
overflow: auto;
max-height: 70vh;
}
.tree {
padding-left: 3%;
padding-top: 15px;
padding-bottom: 15px;
&--searching {
padding-top: 0px;
}
}
}
.search {
display: flex;
flex-direction: column;
background-color: $color-primary-darker;
&__query {
display: flex;
justify-content: center;
flex-direction: row;
align-items: center;
margin-top: 1em;
color: $color-primary;
&__close-button {
cursor: pointer;
font-size: 1.25em;
margin-left: 0.25rem;
&:hover {
color: $color-primary-dark;
}
}
}
&-no-matches {
display:flex;
flex-direction: column;
background-color: $color-primary-darker;
&__query {
display: flex;
justify-content: center;
flex-direction: row;
align-items: center;
margin-top: 1em;
color: $color-primary;
&__close-button {
cursor: pointer;
font-size: 1.25em;
margin-left: 0.25rem;
&:hover {
color: $color-primary-dark;
}
}
word-break:break-word;
text-transform: uppercase;
color: $color-on-primary;
font-size: 1.5em;
padding:10px;
text-align:center;
> div {
padding-bottom:13px;
}
&-no-matches {
display:flex;
flex-direction: column;
word-break:break-word;
text-transform: uppercase;
color: $color-on-primary;
font-size: 1.5em;
padding:10px;
text-align:center;
> div {
padding-bottom:13px;
}
a {
color: $color-primary;
}
a {
color: $color-primary;
}
}
}
</style>
</style>