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:
@@ -8,12 +8,16 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
@Component
|
||||
export default class MenuOptionList extends Vue {
|
||||
@Prop() public label: string;
|
||||
}
|
||||
export default defineComponent({
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -6,26 +6,42 @@
|
||||
enabled: enabled,
|
||||
}"
|
||||
v-non-collapsing
|
||||
@click="enabled && onClicked()">{{label}}</span>
|
||||
@click="onClicked()">{{label}}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Component, Prop, Emit, Vue,
|
||||
} from 'vue-property-decorator';
|
||||
import { defineComponent } from 'vue';
|
||||
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
directives: { NonCollapsing },
|
||||
})
|
||||
export default class MenuOptionListItem extends Vue {
|
||||
@Prop() public enabled: boolean;
|
||||
props: {
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'click',
|
||||
],
|
||||
setup(props, { emit }) {
|
||||
const onClicked = () => {
|
||||
if (!props.enabled) {
|
||||
return;
|
||||
}
|
||||
emit('click');
|
||||
};
|
||||
|
||||
@Prop() public label: string;
|
||||
|
||||
@Emit('click') public onClicked() { /* do nothing except firing event */ }
|
||||
}
|
||||
return {
|
||||
onClicked,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<MenuOptionList label="Select">
|
||||
<MenuOptionListItem
|
||||
label="None"
|
||||
:enabled="this.currentSelection !== SelectionType.None"
|
||||
:enabled="currentSelection !== SelectionType.None"
|
||||
@click="selectType(SelectionType.None)"
|
||||
v-tooltip="
|
||||
'Deselect all selected scripts.<br/>'
|
||||
@@ -11,7 +11,7 @@
|
||||
/>
|
||||
<MenuOptionListItem
|
||||
label="Standard"
|
||||
:enabled="this.currentSelection !== SelectionType.Standard"
|
||||
:enabled="currentSelection !== SelectionType.Standard"
|
||||
@click="selectType(SelectionType.Standard)"
|
||||
v-tooltip="
|
||||
'🛡️ Balanced for privacy and functionality.<br/>'
|
||||
@@ -20,7 +20,7 @@
|
||||
/>
|
||||
<MenuOptionListItem
|
||||
label="Strict"
|
||||
:enabled="this.currentSelection !== SelectionType.Strict"
|
||||
:enabled="currentSelection !== SelectionType.Strict"
|
||||
@click="selectType(SelectionType.Strict)"
|
||||
v-tooltip="
|
||||
'🚫 Stronger privacy, disables risky functions that may leak your data.<br/>'
|
||||
@@ -30,7 +30,7 @@
|
||||
/>
|
||||
<MenuOptionListItem
|
||||
label="All"
|
||||
:enabled="this.currentSelection !== SelectionType.All"
|
||||
:enabled="currentSelection !== SelectionType.All"
|
||||
@click="selectType(SelectionType.All)"
|
||||
v-tooltip="
|
||||
'🔒 Strongest privacy, disabling any functionality that may leak your data.<br/>'
|
||||
@@ -42,47 +42,59 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import MenuOptionList from '../MenuOptionList.vue';
|
||||
import MenuOptionListItem from '../MenuOptionListItem.vue';
|
||||
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MenuOptionList,
|
||||
MenuOptionListItem,
|
||||
},
|
||||
})
|
||||
export default class TheSelector extends StatefulVue {
|
||||
public SelectionType = SelectionType;
|
||||
setup() {
|
||||
const { modifyCurrentState, onStateChange, events } = useCollectionState();
|
||||
|
||||
public currentSelection = SelectionType.None;
|
||||
const currentSelection = ref(SelectionType.None);
|
||||
|
||||
private selectionTypeHandler: SelectionTypeHandler;
|
||||
let selectionTypeHandler: SelectionTypeHandler;
|
||||
|
||||
public async selectType(type: SelectionType) {
|
||||
if (this.currentSelection === type) {
|
||||
return;
|
||||
onStateChange(() => {
|
||||
unregisterMutators();
|
||||
|
||||
modifyCurrentState((state) => {
|
||||
registerStateMutator(state);
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
function unregisterMutators() {
|
||||
events.unsubscribeAll();
|
||||
}
|
||||
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()));
|
||||
}
|
||||
function registerStateMutator(state: ICategoryCollectionState) {
|
||||
selectionTypeHandler = new SelectionTypeHandler(state);
|
||||
updateSelections();
|
||||
events.register(state.selection.changed.on(() => updateSelections()));
|
||||
}
|
||||
|
||||
private updateSelections() {
|
||||
this.currentSelection = this.selectionTypeHandler.getCurrentSelectionType();
|
||||
}
|
||||
}
|
||||
function selectType(type: SelectionType) {
|
||||
if (currentSelection.value === type) {
|
||||
return;
|
||||
}
|
||||
selectionTypeHandler.selectType(type);
|
||||
}
|
||||
|
||||
function updateSelections() {
|
||||
currentSelection.value = selectionTypeHandler.getCurrentSelectionType();
|
||||
}
|
||||
|
||||
return {
|
||||
SelectionType,
|
||||
currentSelection,
|
||||
selectType,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<MenuOptionList>
|
||||
<MenuOptionListItem
|
||||
v-for="os in this.allOses"
|
||||
v-for="os in allOses"
|
||||
:key="os.name"
|
||||
:enabled="currentOs !== os.os"
|
||||
@click="changeOs(os.os)"
|
||||
@@ -11,41 +11,55 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import {
|
||||
defineComponent, computed,
|
||||
} from 'vue';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
|
||||
import MenuOptionList from './MenuOptionList.vue';
|
||||
import MenuOptionListItem from './MenuOptionListItem.vue';
|
||||
|
||||
@Component({
|
||||
interface IOsViewModel {
|
||||
readonly name: string;
|
||||
readonly os: OperatingSystem;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MenuOptionList,
|
||||
MenuOptionListItem,
|
||||
},
|
||||
})
|
||||
export default class TheOsChanger extends StatefulVue {
|
||||
public allOses: Array<{ name: string, os: OperatingSystem }> = [];
|
||||
setup() {
|
||||
const { modifyCurrentContext, currentState } = useCollectionState();
|
||||
const { application } = useApplication();
|
||||
|
||||
public currentOs?: OperatingSystem = null;
|
||||
const allOses = computed<ReadonlyArray<IOsViewModel>>(() => (
|
||||
application.getSupportedOsList() ?? [])
|
||||
.map((os) : IOsViewModel => (
|
||||
{
|
||||
os,
|
||||
name: renderOsName(os),
|
||||
}
|
||||
)));
|
||||
|
||||
public async created() {
|
||||
const app = await ApplicationFactory.Current.getApp();
|
||||
this.allOses = app.getSupportedOsList()
|
||||
.map((os) => ({ os, name: renderOsName(os) }));
|
||||
}
|
||||
const currentOs = computed<OperatingSystem>(() => {
|
||||
return currentState.value.os;
|
||||
});
|
||||
|
||||
public async changeOs(newOs: OperatingSystem) {
|
||||
const context = await this.getCurrentContext();
|
||||
context.changeContext(newOs);
|
||||
}
|
||||
function changeOs(newOs: OperatingSystem) {
|
||||
modifyCurrentContext((context) => {
|
||||
context.changeContext(newOs);
|
||||
});
|
||||
}
|
||||
|
||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
||||
this.currentOs = newState.os;
|
||||
this.$forceUpdate(); // v-bind:class is not updated otherwise
|
||||
}
|
||||
}
|
||||
return {
|
||||
allOses,
|
||||
currentOs,
|
||||
changeOs,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function renderOsName(os: OperatingSystem): string {
|
||||
switch (os) {
|
||||
@@ -56,7 +70,3 @@ function renderOsName(os: OperatingSystem): string {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -5,53 +5,55 @@
|
||||
<TheViewChanger
|
||||
class="item"
|
||||
v-on:viewChanged="$emit('viewChanged', $event)"
|
||||
v-if="!this.isSearching" />
|
||||
v-if="!isSearching" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component } from 'vue-property-decorator';
|
||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
||||
import { defineComponent, ref, onUnmounted } from 'vue';
|
||||
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
|
||||
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({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
TheSelector,
|
||||
TheOsChanger,
|
||||
TheViewChanger,
|
||||
},
|
||||
})
|
||||
export default class TheScriptsMenu extends StatefulVue {
|
||||
public isSearching = false;
|
||||
setup() {
|
||||
const { onStateChange, events } = useCollectionState();
|
||||
|
||||
private listeners = new Array<IEventSubscription>();
|
||||
const isSearching = ref(false);
|
||||
|
||||
public destroyed() {
|
||||
this.unsubscribeAll();
|
||||
}
|
||||
onStateChange((state) => {
|
||||
subscribe(state);
|
||||
}, { immediate: true });
|
||||
|
||||
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;
|
||||
onUnmounted(() => {
|
||||
unsubscribeAll();
|
||||
});
|
||||
}
|
||||
|
||||
private unsubscribeAll() {
|
||||
this.listeners.forEach((listener) => listener.unsubscribe());
|
||||
this.listeners.splice(0, this.listeners.length);
|
||||
}
|
||||
}
|
||||
function subscribe(state: IReadOnlyCategoryCollectionState) {
|
||||
events.register(state.filter.filterRemoved.on(() => {
|
||||
isSearching.value = false;
|
||||
}));
|
||||
events.register(state.filter.filtered.on(() => {
|
||||
isSearching.value = true;
|
||||
}));
|
||||
}
|
||||
|
||||
function unsubscribeAll() {
|
||||
events.unsubscribeAll();
|
||||
}
|
||||
|
||||
return {
|
||||
isSearching,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
label="View"
|
||||
class="part">
|
||||
<MenuOptionListItem
|
||||
v-for="view in this.viewOptions"
|
||||
v-for="view in viewOptions"
|
||||
:key="view.type"
|
||||
:label="view.displayName"
|
||||
:enabled="currentView !== view.type"
|
||||
@@ -13,53 +13,54 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import MenuOptionList from '../MenuOptionList.vue';
|
||||
import MenuOptionListItem from '../MenuOptionListItem.vue';
|
||||
import { ViewType } from './ViewType';
|
||||
|
||||
const DefaultView = ViewType.Cards;
|
||||
interface IViewOption {
|
||||
readonly type: ViewType;
|
||||
readonly displayName: string;
|
||||
}
|
||||
const viewOptions: readonly IViewOption[] = [
|
||||
{ type: ViewType.Cards, displayName: 'Cards' },
|
||||
{ type: ViewType.Tree, displayName: 'Tree' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
MenuOptionList,
|
||||
MenuOptionListItem,
|
||||
},
|
||||
})
|
||||
export default class TheViewChanger extends Vue {
|
||||
public readonly viewOptions: IViewOption[] = [
|
||||
{ type: ViewType.Cards, displayName: 'Cards' },
|
||||
{ type: ViewType.Tree, displayName: 'Tree' },
|
||||
];
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
viewChanged: (viewType: ViewType) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const currentView = ref<ViewType>();
|
||||
|
||||
public ViewType = ViewType;
|
||||
setView(DefaultView);
|
||||
|
||||
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]}"`);
|
||||
function setView(view: ViewType) {
|
||||
if (currentView.value === view) {
|
||||
throw new Error(`View is already "${ViewType[view]}"`);
|
||||
}
|
||||
currentView.value = view;
|
||||
emit('viewChanged', currentView.value);
|
||||
}
|
||||
this.currentView = view;
|
||||
this.$emit('viewChanged', this.currentView);
|
||||
}
|
||||
}
|
||||
return {
|
||||
ViewType,
|
||||
viewOptions,
|
||||
currentView,
|
||||
setView,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
interface IViewOption {
|
||||
readonly type: ViewType;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
|
||||
@Component
|
||||
export default class Handle extends Vue {
|
||||
public readonly cursorCssValue = 'ew-resize';
|
||||
|
||||
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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$color : $color-primary-dark;
|
||||
$color-hover : $color-primary;
|
||||
|
||||
.handle {
|
||||
@include clickable($cursor: 'ew-resize');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@include hover-or-touch {
|
||||
.line {
|
||||
background: $color-hover;
|
||||
}
|
||||
.image {
|
||||
color: $color-hover;
|
||||
}
|
||||
}
|
||||
.line {
|
||||
flex: 1;
|
||||
background: $color;
|
||||
width: 3px;
|
||||
}
|
||||
.icon {
|
||||
color: $color;
|
||||
}
|
||||
margin-right: 5px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,16 +2,16 @@
|
||||
<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,
|
||||
'--vertical-margin': verticalMargin,
|
||||
'--first-min-width': firstMinWidth,
|
||||
'--first-initial-width': firstInitialWidth,
|
||||
'--second-min-width': secondMinWidth,
|
||||
}"
|
||||
>
|
||||
<div class="first" ref="firstElement">
|
||||
<slot name="first" />
|
||||
</div>
|
||||
<Handle class="handle" @resized="onResize($event)" />
|
||||
<SliderHandle class="handle" @resized="onResize($event)" />
|
||||
<div class="second">
|
||||
<slot name="second" />
|
||||
</div>
|
||||
@@ -19,30 +19,45 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from 'vue-property-decorator';
|
||||
import Handle from './Handle.vue';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import SliderHandle from './SliderHandle.vue';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
Handle,
|
||||
SliderHandle,
|
||||
},
|
||||
})
|
||||
export default class HorizontalResizeSlider extends Vue {
|
||||
@Prop() public verticalMargin: string;
|
||||
props: {
|
||||
verticalMargin: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
firstMinWidth: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
firstInitialWidth: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
secondMinWidth: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const firstElement = ref<HTMLElement>();
|
||||
|
||||
@Prop() public firstMinWidth: string;
|
||||
function onResize(displacementX: number): void {
|
||||
const leftWidth = firstElement.value.offsetWidth + displacementX;
|
||||
firstElement.value.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`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
firstElement,
|
||||
onResize,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
87
src/presentation/components/Scripts/Slider/SliderHandle.vue
Normal file
87
src/presentation/components/Scripts/Slider/SliderHandle.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
emits: {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
resized: (displacementX: number) => true,
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
const cursorCssValue = 'ew-resize';
|
||||
let initialX: number | undefined;
|
||||
|
||||
const resize = (event) => {
|
||||
const displacementX = event.clientX - initialX;
|
||||
emit('resized', displacementX);
|
||||
initialX = event.clientX;
|
||||
};
|
||||
|
||||
const stopResize = () => {
|
||||
document.body.style.removeProperty('cursor');
|
||||
document.removeEventListener('mousemove', resize);
|
||||
window.removeEventListener('mouseup', stopResize);
|
||||
};
|
||||
|
||||
function startResize(event: MouseEvent): void {
|
||||
initialX = event.clientX;
|
||||
document.body.style.setProperty('cursor', cursorCssValue);
|
||||
document.addEventListener('mousemove', resize);
|
||||
window.addEventListener('mouseup', stopResize);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
return {
|
||||
cursorCssValue,
|
||||
startResize,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/presentation/assets/styles/main" as *;
|
||||
|
||||
$color : $color-primary-dark;
|
||||
$color-hover : $color-primary;
|
||||
|
||||
.handle {
|
||||
@include clickable($cursor: 'ew-resize');
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@include hover-or-touch {
|
||||
.line {
|
||||
background: $color-hover;
|
||||
}
|
||||
.image {
|
||||
color: $color-hover;
|
||||
}
|
||||
}
|
||||
.line {
|
||||
flex: 1;
|
||||
background: $color;
|
||||
width: 3px;
|
||||
}
|
||||
.icon {
|
||||
color: $color;
|
||||
}
|
||||
margin-right: 5px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -19,24 +19,26 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import TheCodeArea from '@/presentation/components/Code/TheCodeArea.vue';
|
||||
import TheScriptsView from '@/presentation/components/Scripts/View/TheScriptsView.vue';
|
||||
import TheScriptsMenu from '@/presentation/components/Scripts/Menu/TheScriptsMenu.vue';
|
||||
import HorizontalResizeSlider from '@/presentation/components/Scripts/Slider/HorizontalResizeSlider.vue';
|
||||
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
|
||||
|
||||
@Component({
|
||||
export default defineComponent({
|
||||
components: {
|
||||
TheCodeArea,
|
||||
TheScriptsView,
|
||||
TheScriptsMenu,
|
||||
HorizontalResizeSlider,
|
||||
},
|
||||
})
|
||||
export default class TheScriptArea extends Vue {
|
||||
public currentView = ViewType.Cards;
|
||||
}
|
||||
setup() {
|
||||
const currentView = ref(ViewType.Cards);
|
||||
|
||||
return { currentView };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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, '');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { INode } from './Node/INode';
|
||||
import { INodeContent } from './Node/INodeContent';
|
||||
|
||||
export interface INodeSelectedEvent {
|
||||
isSelected: boolean;
|
||||
node: INode;
|
||||
node: INodeContent;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user