Refactor Vue components using Composition API #230

- Migrate `StatefulVue`:
  - Introduce `UseCollectionState` that replaces its behavior and acts
    as a shared state store.
  - Add more encapsulated, granular functions based on read or write
    access to state in CollectionState.
- Some linting rules get activates due to new code-base compability to
  modern parses, fix linting errors.
  - Rename Dialog to ModalDialog as after refactoring,
    eslintvue/no-reserved-component-names does not allow name Dialog.
  - To comply with `vue/multi-word-component-names`, rename:
    - `Code`          -> `CodeInstruction`
    - `Handle`        -> `SliderHandle`
    - `Documentable`  -> `DocumentableNode`
    - `Node`          -> `NodeContent`
    - `INode`         -> `INodeContent`
    - `Responsive`    -> `SizeObserver`
- Remove `vue-property-decorator` and `vue-class-component`
  dependencies.
- Refactor `watch` with computed properties when possible for cleaner
  code.
  - Introduce `UseApplication` to reduce repeated code in new components
    that use `computed` more heavily than before.
- Change TypeScript target to `es2017` to allow top level async calls
  for getting application context/state/instance to simplify the code by
  removing async calls. However, mocha (unit and integration) tests do
  not run with top level awaits, so a workaround is used.
This commit is contained in:
undergroundwires
2023-08-07 13:16:39 +02:00
parent 3a594ac7fd
commit 1b9be8fe2d
67 changed files with 2135 additions and 1267 deletions

View File

@@ -11,14 +11,14 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent } from 'vue';
import TheHeader from '@/presentation/components/TheHeader.vue';
import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue';
import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
@Component({
export default defineComponent({
components: {
TheHeader,
TheCodeButtons,
@@ -26,10 +26,8 @@ import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
TheSearchBar,
TheFooter,
},
})
export default class App extends Vue {
});
}
</script>
<style lang="scss">

View File

@@ -14,20 +14,36 @@
</template>
<script lang="ts">
import {
Component, Prop, Emit, Vue,
} from 'vue-property-decorator';
import { defineComponent } from 'vue';
@Component
export default class IconButton extends Vue {
@Prop() public text!: number;
export default defineComponent({
props: {
text: {
type: String,
required: true,
},
iconPrefix: {
type: String,
required: true,
},
iconName: {
type: String,
required: true,
},
},
emits: [
'click',
],
setup(_, { emit }) {
function onClicked() {
emit('click');
}
@Prop() public iconPrefix!: string;
@Prop() public iconName!: string;
@Emit('click') public onClicked() { /* do nothing except firing event */ }
}
return {
onClicked,
};
},
});
</script>
<style scoped lang="scss">

View File

@@ -12,16 +12,23 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent, useSlots } from 'vue';
import { Clipboard } from '@/infrastructure/Clipboard';
@Component
export default class Code extends Vue {
public copyCode(): void {
const code = this.$slots.default[0].text;
Clipboard.copyText(code);
}
}
export default defineComponent({
setup() {
const slots = useSlots();
function copyCode() {
const code = slots.default()[0].text;
Clipboard.copyText(code);
}
return {
copyCode,
};
},
});
</script>
<style scoped lang="scss">

View File

@@ -6,8 +6,8 @@
<hr />
<p>
<strong>1. The easy alternative</strong>. Run your script without any manual steps by
<a :href="this.macOsDownloadUrl">downloading desktop version</a> of {{ this.appName }} on the
{{ this.osName }} system you wish to configure, and then click on the Run button. This is
<a :href="macOsDownloadUrl">downloading desktop version</a> of {{ appName }} on the
{{ osName }} system you wish to configure, and then click on the Run button. This is
recommended for most users.
</p>
<hr />
@@ -20,7 +20,7 @@
<p>
<ol>
<li
v-for='(step, index) in this.data.steps'
v-for='(step, index) in data.steps'
v-bind:key="index"
class="step"
>
@@ -34,7 +34,7 @@
/>
</div>
<div v-if="step.code" class="step__code">
<Code>{{ step.code.instruction }}</Code>
<CodeInstruction>{{ step.code.instruction }}</CodeInstruction>
<font-awesome-icon
v-if="step.code.details"
class="explanation"
@@ -49,36 +49,47 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import {
defineComponent, PropType, computed,
} from 'vue';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import Code from './Code.vue';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import CodeInstruction from './CodeInstruction.vue';
import { IInstructionListData } from './InstructionListData';
@Component({
export default defineComponent({
components: {
Code,
CodeInstruction,
},
})
export default class InstructionList extends Vue {
public appName = '';
props: {
data: {
type: Object as PropType<IInstructionListData>,
required: true,
},
},
setup(props) {
const { info } = useApplication();
public macOsDownloadUrl = '';
const appName = computed<string>(() => info.name);
public osName = '';
const macOsDownloadUrl = computed<string>(
() => info.getDownloadUrl(OperatingSystem.macOS),
);
@Prop() public data: IInstructionListData;
const osName = computed<string>(() => {
if (!props.data) {
throw new Error('missing data');
}
return renderOsName(props.data.operatingSystem);
});
public async created() {
if (!this.data) {
throw new Error('missing data');
}
const app = await ApplicationFactory.Current.getApp();
this.appName = app.info.name;
this.macOsDownloadUrl = app.info.getDownloadUrl(OperatingSystem.macOS);
this.osName = renderOsName(this.data.operatingSystem);
}
}
return {
appName,
macOsDownloadUrl,
osName,
};
},
});
function renderOsName(os: OperatingSystem): string {
switch (os) {

View File

@@ -1,17 +1,17 @@
<template>
<div class="container" v-if="hasCode">
<IconButton
v-if="this.canRun"
v-if="canRun"
text="Run"
v-on:click="executeCode"
icon-prefix="fas"
icon-name="play"
/>
<IconButton
:text="this.isDesktopVersion ? 'Save' : 'Download'"
:text="isDesktopVersion ? 'Save' : 'Download'"
v-on:click="saveCode"
icon-prefix="fas"
:icon-name="this.isDesktopVersion ? 'save' : 'file-download'"
:icon-name="isDesktopVersion ? 'save' : 'file-download'"
/>
<IconButton
text="Copy"
@@ -19,25 +19,24 @@
icon-prefix="fas"
icon-name="copy"
/>
<Dialog v-if="this.hasInstructions" ref="instructionsDialog">
<InstructionList :data="this.instructions" />
</Dialog>
<ModalDialog v-if="instructions" ref="instructionsDialog">
<InstructionList :data="instructions" />
</ModalDialog>
</div>
</template>
<script lang="ts">
import { Component } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { defineComponent, ref, computed } from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import Dialog from '@/presentation/components/Shared/Dialog.vue';
import ModalDialog from '@/presentation/components/Shared/ModalDialog.vue';
import { Environment } from '@/application/Environment/Environment';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CodeRunner } from '@/infrastructure/CodeRunner';
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import InstructionList from './Instructions/InstructionList.vue';
@@ -45,79 +44,89 @@ import IconButton from './IconButton.vue';
import { IInstructionListData } from './Instructions/InstructionListData';
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
@Component({
const isDesktopVersion = Environment.CurrentEnvironment.isDesktop;
export default defineComponent({
components: {
IconButton,
InstructionList,
Dialog,
ModalDialog,
},
})
export default class TheCodeButtons extends StatefulVue {
public readonly isDesktopVersion = Environment.CurrentEnvironment.isDesktop;
setup() {
const {
currentState, currentContext, onStateChange, events,
} = useCollectionState();
public canRun = false;
const instructionsDialog = ref<typeof ModalDialog>();
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os));
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
const hasCode = ref(false);
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
currentState.value.collection.os,
fileName.value,
));
public hasCode = false;
public instructions: IInstructionListData | undefined;
public hasInstructions = false;
public fileName = '';
public async copyCode() {
const code = await this.getCurrentCode();
Clipboard.copyText(code.current);
}
public async saveCode() {
const context = await this.getCurrentContext();
saveCode(this.fileName, context.state);
if (this.hasInstructions) {
(this.$refs.instructionsDialog as Dialog).show();
async function copyCode() {
const code = await getCurrentCode();
Clipboard.copyText(code.current);
}
}
public async executeCode() {
const context = await this.getCurrentContext();
await executeCode(context);
}
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.updateRunState(newState.os);
this.updateDownloadState(newState.collection);
this.updateCodeState(newState.code);
}
private async getCurrentCode(): Promise<IApplicationCode> {
const context = await this.getCurrentContext();
const { code } = context.state;
return code;
}
private updateRunState(selectedOs: OperatingSystem) {
const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os;
this.canRun = this.isDesktopVersion && isRunningOnSelectedOs;
}
private updateDownloadState(collection: ICategoryCollection) {
this.fileName = buildFileName(collection.scripting);
this.hasInstructions = hasInstructions(collection.os);
if (this.hasInstructions) {
this.instructions = getInstructions(collection.os, this.fileName);
function saveCode() {
saveCodeToDisk(fileName.value, currentState.value);
instructionsDialog.value?.show();
}
}
private updateCodeState(code: IApplicationCode) {
this.hasCode = code.current && code.current.length > 0;
this.events.unsubscribeAll();
this.events.register(code.changed.on((newCode) => {
this.hasCode = newCode && newCode.code.length > 0;
}));
async function executeCode() {
await runCode(currentContext);
}
onStateChange((newState) => {
subscribeToCodeChanges(newState.code);
}, { immediate: true });
function subscribeToCodeChanges(code: IApplicationCode) {
hasCode.value = code.current && code.current.length > 0;
events.unsubscribeAll();
events.register(code.changed.on((newCode) => {
hasCode.value = newCode && newCode.code.length > 0;
}));
}
async function getCurrentCode(): Promise<IApplicationCode> {
const { code } = currentContext.state;
return code;
}
return {
isDesktopVersion,
canRun,
hasCode,
instructions,
fileName,
instructionsDialog,
copyCode,
saveCode,
executeCode,
};
},
});
function getDownloadInstructions(
os: OperatingSystem,
fileName: string,
): IInstructionListData | undefined {
if (!hasInstructions(os)) {
return undefined;
}
return getInstructions(os, fileName);
}
function saveCode(fileName: string, state: IReadOnlyCategoryCollectionState) {
function getCanRunState(selectedOs: OperatingSystem): boolean {
const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os;
return isDesktopVersion && isRunningOnSelectedOs;
}
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
const content = state.code.current;
const type = getType(state.collection.scripting.language);
SaveFileDialog.saveFile(content, fileName, type);
@@ -141,7 +150,7 @@ function buildFileName(scripting: IScriptingDefinition) {
return fileName;
}
async function executeCode(context: IReadOnlyApplicationContext) {
async function runCode(context: IReadOnlyApplicationContext) {
const runner = new CodeRunner();
await runner.runCode(
/* code: */ context.state.code.current,

View File

@@ -1,126 +1,138 @@
<template>
<Responsive
<SizeObserver
v-on:sizeChanged="sizeChanged()"
v-non-collapsing>
<div
:id="editorId"
class="code-area"
/>
</Responsive>
</SizeObserver>
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import { defineComponent, onUnmounted, onMounted } from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { IScript } from '@/domain/IScript';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory';
import Responsive from '@/presentation/components/Shared/Responsive.vue';
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import ace from './ace-importer';
@Component({
components: {
Responsive,
export default defineComponent({
props: {
theme: {
type: String,
default: undefined,
},
},
directives: { NonCollapsing },
})
export default class TheCodeArea extends StatefulVue {
public readonly editorId = 'codeEditor';
components: {
SizeObserver,
},
directives: {
NonCollapsing,
},
setup(props) {
const { onStateChange, currentState, events } = useCollectionState();
private editor!: ace.Ace.Editor;
const editorId = 'codeEditor';
let editor: ace.Ace.Editor | undefined;
let currentMarkerId: number | undefined;
private currentMarkerId?: number;
onUnmounted(() => {
destroyEditor();
});
@Prop() private theme!: string;
onMounted(() => { // allow editor HTML to render
onStateChange((newState) => {
handleNewState(newState);
}, { immediate: true });
});
public destroyed() {
this.destroyEditor();
}
public sizeChanged() {
if (this.editor) {
this.editor.resize();
function handleNewState(newState: IReadOnlyCategoryCollectionState) {
destroyEditor();
editor = initializeEditor(
props.theme,
editorId,
newState.collection.scripting.language,
);
const appCode = newState.code;
const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language);
editor.setValue(innerCode, 1);
events.unsubscribeAll();
events.register(appCode.changed.on((code) => updateCode(code)));
}
}
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
this.destroyEditor();
this.editor = initializeEditor(
this.theme,
this.editorId,
newState.collection.scripting.language,
);
const appCode = newState.code;
const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language);
this.editor.setValue(innerCode, 1);
this.events.unsubscribeAll();
this.events.register(appCode.changed.on((code) => this.updateCode(code)));
}
private async updateCode(event: ICodeChangedEvent) {
this.removeCurrentHighlighting();
if (event.isEmpty()) {
const context = await this.getCurrentContext();
const defaultCode = getDefaultCode(context.state.collection.scripting.language);
this.editor.setValue(defaultCode, 1);
return;
function updateCode(event: ICodeChangedEvent) {
removeCurrentHighlighting();
if (event.isEmpty()) {
const defaultCode = getDefaultCode(currentState.value.collection.scripting.language);
editor.setValue(defaultCode, 1);
return;
}
editor.setValue(event.code, 1);
if (event.addedScripts?.length > 0) {
reactToChanges(event, event.addedScripts);
} else if (event.changedScripts?.length > 0) {
reactToChanges(event, event.changedScripts);
}
}
this.editor.setValue(event.code, 1);
if (event.addedScripts && event.addedScripts.length) {
this.reactToChanges(event, event.addedScripts);
} else if (event.changedScripts && event.changedScripts.length) {
this.reactToChanges(event, event.changedScripts);
function sizeChanged() {
editor?.resize();
}
}
private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
const positions = scripts
.map((script) => event.getScriptPositionInCode(script));
const start = Math.min(
...positions.map((position) => position.startLine),
);
const end = Math.max(
...positions.map((position) => position.endLine),
);
this.scrollToLine(end + 2);
this.highlight(start, end);
}
private highlight(startRow: number, endRow: number) {
const AceRange = ace.require('ace/range').Range;
this.currentMarkerId = this.editor.session.addMarker(
new AceRange(startRow, 0, endRow, 0),
'code-area__highlight',
'fullLine',
);
}
private scrollToLine(row: number) {
const column = this.editor.session.getLine(row).length;
this.editor.gotoLine(row, column, true);
}
private removeCurrentHighlighting() {
if (!this.currentMarkerId) {
return;
function destroyEditor() {
editor?.destroy();
editor = undefined;
}
this.editor.session.removeMarker(this.currentMarkerId);
this.currentMarkerId = undefined;
}
private destroyEditor() {
if (this.editor) {
this.editor.destroy();
this.editor = undefined;
function removeCurrentHighlighting() {
if (!currentMarkerId) {
return;
}
editor.session.removeMarker(currentMarkerId);
currentMarkerId = undefined;
}
}
}
function reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
const positions = scripts
.map((script) => event.getScriptPositionInCode(script));
const start = Math.min(
...positions.map((position) => position.startLine),
);
const end = Math.max(
...positions.map((position) => position.endLine),
);
scrollToLine(end + 2);
highlight(start, end);
}
function highlight(startRow: number, endRow: number) {
const AceRange = ace.require('ace/range').Range;
currentMarkerId = editor.session.addMarker(
new AceRange(startRow, 0, endRow, 0),
'code-area__highlight',
'fullLine',
);
}
function scrollToLine(row: number) {
const column = editor.session.getLine(row).length;
editor.gotoLine(row, column, true);
}
return {
editorId,
sizeChanged,
};
},
});
function initializeEditor(
theme: string,
theme: string | undefined,
editorId: string,
language: ScriptingLanguage,
): ace.Ace.Editor {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { IApplication } from '@/domain/IApplication';
/* Application is always static */
let cachedApplication: IApplication;
// Running tests through Vue CLI throws 'Top-level-await is only supported in EcmaScript Modules'
// This is a temporary workaround until migrating to Vite
ApplicationFactory.Current.getApp().then((app) => {
cachedApplication = app;
});
export function useApplication(application: IApplication = cachedApplication) {
return {
application,
info: application.info,
};
}

View File

@@ -0,0 +1,85 @@
import { ref, computed, readonly } from 'vue';
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
let singletonContext: IApplicationContext;
// Running tests through Vue CLI throws 'Top-level-await is only supported in EcmaScript Modules'
// This is a temporary workaround until migrating to Vite
buildContext().then((context) => {
singletonContext = context;
});
export function useCollectionState(context: IApplicationContext = singletonContext) {
const events = new EventSubscriptionCollection();
const ownEvents = new EventSubscriptionCollection();
const currentState = ref<ICategoryCollectionState>(context.state);
ownEvents.register(
context.contextChanged.on((event) => {
currentState.value = event.newState;
}),
);
type NewStateEventHandler = (
newState: IReadOnlyCategoryCollectionState,
oldState: IReadOnlyCategoryCollectionState | undefined,
) => void;
interface IStateCallbackSettings {
readonly immediate: boolean;
}
const defaultSettings: IStateCallbackSettings = {
immediate: false,
};
function onStateChange(
handler: NewStateEventHandler,
settings: Partial<IStateCallbackSettings> = defaultSettings,
) {
if (!handler) {
throw new Error('missing state handler');
}
ownEvents.register(
context.contextChanged.on((event) => {
handler(event.newState, event.oldState);
}),
);
const defaultedSettings: IStateCallbackSettings = {
...defaultSettings,
...settings,
};
if (defaultedSettings.immediate) {
handler(context.state, undefined);
}
}
type StateModifier = (
state: ICategoryCollectionState,
) => void;
function modifyCurrentState(mutator: StateModifier) {
if (!mutator) {
throw new Error('missing state mutator');
}
mutator(context.state);
}
type ContextModifier = (
state: IApplicationContext,
) => void;
function modifyCurrentContext(mutator: ContextModifier) {
if (!mutator) {
throw new Error('missing context mutator');
}
mutator(context);
}
return {
modifyCurrentState,
modifyCurrentContext,
onStateChange,
currentContext: context as IReadOnlyApplicationContext,
currentState: readonly(computed<IReadOnlyCategoryCollectionState>(() => currentState.value)),
events,
};
}

View File

@@ -10,7 +10,7 @@
<div class="dialog__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
@click="$modal.hide(name)"
@click="hide"
/>
</div>
</div>
@@ -18,18 +18,41 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent, onMounted } from 'vue';
@Component
export default class Dialog extends Vue {
private static idCounter = 0;
let idCounter = 0;
public name = (++Dialog.idCounter).toString();
export default defineComponent({
setup() {
const name = (++idCounter).toString();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let modal: any;
onMounted(async () => {
// Hack until Vue 3, so we can use vue-js-modal
const main = await import('@/presentation/main');
const { getVue } = main;
modal = getVue().$modal;
});
function show(): void {
modal.show(name);
}
function hide(): void {
modal.hide();
}
return {
name,
modal,
hide,
show,
};
},
});
public show(): void {
this.$modal.show(this.name);
}
}
</script>
<style scoped lang="scss">

View File

@@ -1,88 +0,0 @@
<template>
<div ref="containerElement" class="container">
<slot ref="containerElement" />
</div>
</template>
<script lang="ts">
import { Component, Vue, Emit } from 'vue-property-decorator';
import { throttle } from './Throttle';
@Component
export default class Responsive extends Vue {
private width: number;
private height: number;
private observer: ResizeObserver;
private get container(): HTMLElement { return this.$refs.containerElement as HTMLElement; }
public async mounted() {
this.width = this.container.offsetWidth;
this.height = this.container.offsetHeight;
const resizeCallback = throttle(() => this.updateSize(), 200);
if ('ResizeObserver' in window === false) {
const module = await import('@juggle/resize-observer');
window.ResizeObserver = module.ResizeObserver;
}
this.observer = new window.ResizeObserver(resizeCallback);
this.observer.observe(this.container);
this.fireChangeEvents();
}
public updateSize() {
let sizeChanged = false;
if (this.isWidthChanged()) {
this.updateWidth(this.container.offsetWidth);
sizeChanged = true;
}
if (this.isHeightChanged()) {
this.updateHeight(this.container.offsetHeight);
sizeChanged = true;
}
if (sizeChanged) {
this.$emit('sizeChanged');
}
}
@Emit('widthChanged') public updateWidth(width: number) {
this.width = width;
}
@Emit('heightChanged') public updateHeight(height: number) {
this.height = height;
}
public destroyed() {
if (this.observer) {
this.observer.disconnect();
}
}
private fireChangeEvents() {
this.updateWidth(this.container.offsetWidth);
this.updateHeight(this.container.offsetHeight);
this.$emit('sizeChanged');
}
private isWidthChanged(): boolean {
return this.width !== this.container.offsetWidth;
}
private isHeightChanged(): boolean {
return this.height !== this.container.offsetHeight;
}
}
</script>
<style scoped lang="scss">
.container {
width: 100%;
height: 100%;
display: inline-block; // if inline then it has no height or weight
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div ref="containerElement" class="container">
<slot ref="containerElement" />
</div>
</template>
<script lang="ts">
import {
defineComponent, ref, onMounted, onBeforeUnmount,
} from 'vue';
export default defineComponent({
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
sizeChanged: () => true,
widthChanged: (width: number) => true,
heightChanged: (height: number) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(_, { emit }) {
const containerElement = ref<HTMLElement>();
let width = 0;
let height = 0;
let observer: ResizeObserver;
onMounted(async () => {
width = containerElement.value.offsetWidth;
height = containerElement.value.offsetHeight;
observer = await initializeResizeObserver(updateSize);
observer.observe(containerElement.value);
fireChangeEvents();
});
onBeforeUnmount(() => {
observer?.disconnect();
});
async function initializeResizeObserver(
callback: ResizeObserverCallback,
): Promise<ResizeObserver> {
if ('ResizeObserver' in window) {
return new window.ResizeObserver(callback);
}
const module = await import('@juggle/resize-observer');
return new module.ResizeObserver(callback);
}
function updateSize() {
let sizeChanged = false;
if (isWidthChanged()) {
updateWidth(containerElement.value.offsetWidth);
sizeChanged = true;
}
if (isHeightChanged()) {
updateHeight(containerElement.value.offsetHeight);
sizeChanged = true;
}
if (sizeChanged) {
emit('sizeChanged');
}
}
function updateWidth(newWidth: number) {
width = newWidth;
emit('widthChanged', newWidth);
}
function updateHeight(newHeight: number) {
height = newHeight;
emit('heightChanged', newHeight);
}
function fireChangeEvents() {
updateWidth(containerElement.value.offsetWidth);
updateHeight(containerElement.value.offsetHeight);
emit('sizeChanged');
}
function isWidthChanged(): boolean {
return width !== containerElement.value.offsetWidth;
}
function isHeightChanged(): boolean {
return height !== containerElement.value.offsetHeight;
}
return {
containerElement,
};
},
});
</script>
<style scoped lang="scss">
.container {
width: 100%;
height: 100%;
display: inline-block; // if inline then it has no height or weight
}
</style>

View File

@@ -1,37 +0,0 @@
import { Component, Vue } from 'vue-property-decorator';
import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
import { IApplicationContext, IApplicationContextChangedEvent } from '@/application/Context/IApplicationContext';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore because https://github.com/vuejs/vue-class-component/issues/91
@Component
export abstract class StatefulVue extends Vue {
private static readonly instance = new AsyncLazy<IApplicationContext>(() => buildContext());
protected readonly events = new EventSubscriptionCollection();
private readonly ownEvents = new EventSubscriptionCollection();
public async mounted() {
const context = await this.getCurrentContext();
this.ownEvents.register(
context.contextChanged.on((event) => this.handleStateChangedEvent(event)),
);
this.handleCollectionState(context.state, undefined);
}
protected abstract handleCollectionState(
newState: IReadOnlyCategoryCollectionState,
oldState: IReadOnlyCategoryCollectionState | undefined): void;
protected getCurrentContext(): Promise<IApplicationContext> {
return StatefulVue.instance.getValue();
}
private handleStateChangedEvent(event: IApplicationContextChangedEvent) {
this.handleCollectionState(event.newState, event.oldState);
}
}

View File

@@ -18,28 +18,35 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent } from 'vue';
import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import DownloadUrlListItem from './DownloadUrlListItem.vue';
@Component({
components: { DownloadUrlListItem },
})
export default class DownloadUrlList extends Vue {
public readonly supportedDesktops: ReadonlyArray<OperatingSystem>;
const supportedOperativeSystems: readonly OperatingSystem[] = [
OperatingSystem.Windows,
OperatingSystem.Linux,
OperatingSystem.macOS,
];
public readonly hasCurrentOsDesktopVersion: boolean = false;
constructor() {
super();
const supportedOperativeSystems = [
OperatingSystem.Windows, OperatingSystem.Linux, OperatingSystem.macOS];
export default defineComponent({
components: {
DownloadUrlListItem,
},
setup() {
const currentOs = Environment.CurrentEnvironment.os;
this.supportedDesktops = supportedOperativeSystems.sort((os) => (os === currentOs ? 0 : 1));
this.hasCurrentOsDesktopVersion = supportedOperativeSystems.includes(currentOs);
}
}
const supportedDesktops = [
...supportedOperativeSystems,
].sort((os) => (os === currentOs ? 0 : 1));
const hasCurrentOsDesktopVersion = supportedOperativeSystems.includes(currentOs);
return {
supportedDesktops,
hasCurrentOsDesktopVersion,
};
},
});
</script>
<style scoped lang="scss">

View File

@@ -11,42 +11,48 @@
<script lang="ts">
import {
Component, Prop, Watch, Vue,
} from 'vue-property-decorator';
defineComponent, PropType, computed,
} from 'vue';
import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
@Component
export default class DownloadUrlListItem extends Vue {
@Prop() public operatingSystem!: OperatingSystem;
const currentOs = Environment.CurrentEnvironment.os;
public downloadUrl = '';
export default defineComponent({
props: {
operatingSystem: {
type: Number as PropType<OperatingSystem>,
required: true,
},
},
setup(props) {
const { info } = useApplication();
public operatingSystemName = '';
const isCurrentOs = computed<boolean>(() => {
return currentOs === props.operatingSystem;
});
public isCurrentOs = false;
const operatingSystemName = computed<string>(() => {
return getOperatingSystemName(props.operatingSystem);
});
public hasCurrentOsDesktopVersion = false;
const hasCurrentOsDesktopVersion = computed<boolean>(() => {
return hasDesktopVersion(props.operatingSystem);
});
public async mounted() {
await this.onOperatingSystemChanged(this.operatingSystem);
}
const downloadUrl = computed<string | undefined>(() => {
return info.getDownloadUrl(props.operatingSystem);
});
@Watch('operatingSystem')
public async onOperatingSystemChanged(os: OperatingSystem) {
const currentOs = Environment.CurrentEnvironment.os;
this.isCurrentOs = os === currentOs;
this.downloadUrl = await getDownloadUrl(os);
this.operatingSystemName = getOperatingSystemName(os);
this.hasCurrentOsDesktopVersion = hasDesktopVersion(currentOs);
}
}
async function getDownloadUrl(os: OperatingSystem): Promise<string> {
const context = await ApplicationFactory.Current.getApp();
return context.info.getDownloadUrl(os);
}
return {
downloadUrl,
operatingSystemName,
isCurrentOs,
hasCurrentOsDesktopVersion,
};
},
});
function hasDesktopVersion(os: OperatingSystem): boolean {
return os === OperatingSystem.Windows

View File

@@ -41,30 +41,26 @@
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent, computed } from 'vue';
import { Environment } from '@/application/Environment/Environment';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { IApplication } from '@/domain/IApplication';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
@Component
export default class PrivacyPolicy extends Vue {
public repositoryUrl = '';
const { isDesktop } = Environment.CurrentEnvironment;
public feedbackUrl = '';
export default defineComponent({
setup() {
const { info } = useApplication();
public isDesktop = Environment.CurrentEnvironment.isDesktop;
const repositoryUrl = computed<string>(() => info.repositoryUrl);
const feedbackUrl = computed<string>(() => info.feedbackUrl);
public async created() {
const app = await ApplicationFactory.Current.getApp();
this.initialize(app);
}
private initialize(app: IApplication) {
const { info } = app;
this.repositoryUrl = info.repositoryWebUrl;
this.feedbackUrl = info.feedbackUrl;
}
}
return {
repositoryUrl,
feedbackUrl,
isDesktop,
};
},
});
</script>
<style scoped lang="scss">

View File

@@ -33,57 +33,58 @@
</div>
<div class="footer__section__item">
<font-awesome-icon class="icon" :icon="['fas', 'user-secret']" />
<a @click="$refs.privacyDialog.show()">Privacy</a>
<a @click="privacyDialog.show()">Privacy</a>
</div>
</div>
</div>
<Dialog ref="privacyDialog">
<ModalDialog ref="privacyDialog">
<PrivacyPolicy />
</Dialog>
</ModalDialog>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { defineComponent, ref, computed } from 'vue';
import { Environment } from '@/application/Environment/Environment';
import Dialog from '@/presentation/components/Shared/Dialog.vue';
import { IApplication } from '@/domain/IApplication';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import ModalDialog from '@/presentation/components/Shared/ModalDialog.vue';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import DownloadUrlList from './DownloadUrlList.vue';
import PrivacyPolicy from './PrivacyPolicy.vue';
@Component({
const { isDesktop } = Environment.CurrentEnvironment;
export default defineComponent({
components: {
Dialog, PrivacyPolicy, DownloadUrlList,
ModalDialog,
PrivacyPolicy,
DownloadUrlList,
},
})
export default class TheFooter extends Vue {
public readonly isDesktop = Environment.CurrentEnvironment.isDesktop;
setup() {
const { info } = useApplication();
public version = '';
const privacyDialog = ref<typeof ModalDialog>();
public repositoryUrl = '';
const version = computed<string>(() => info.version.toString());
public releaseUrl = '';
const homepageUrl = computed<string>(() => info.homepage);
public feedbackUrl = '';
const repositoryUrl = computed<string>(() => info.repositoryWebUrl);
public homepageUrl = '';
const releaseUrl = computed<string>(() => info.releaseUrl);
public async created() {
const app = await ApplicationFactory.Current.getApp();
this.initialize(app);
}
const feedbackUrl = computed<string>(() => info.feedbackUrl);
private initialize(app: IApplication) {
const { info } = app;
this.version = info.version.toString();
this.homepageUrl = info.homepage;
this.repositoryUrl = info.repositoryWebUrl;
this.releaseUrl = info.releaseUrl;
this.feedbackUrl = info.feedbackUrl;
}
}
return {
isDesktop,
privacyDialog,
version,
homepageUrl,
repositoryUrl,
releaseUrl,
feedbackUrl,
};
},
});
</script>
<style scoped lang="scss">

View File

@@ -1,26 +1,27 @@
<template>
<div id="container">
<h1 class="child title">{{ title }}</h1>
<h2 class="child subtitle">Now you have the choice</h2>
<h2 class="child subtitle">{{ subtitle }}</h2>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { defineComponent, computed } from 'vue';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
@Component
export default class TheHeader extends Vue {
public title = '';
export default defineComponent({
setup() {
const { info } = useApplication();
public subtitle = '';
const title = computed(() => info.name);
const subtitle = computed(() => info.slogan);
public async created() {
const app = await ApplicationFactory.Current.getApp();
this.title = app.info.name;
this.subtitle = app.info.slogan;
}
}
return {
title,
subtitle,
};
},
});
</script>
<style scoped lang="scss">

View File

@@ -3,7 +3,7 @@
<input
type="search"
class="search-term"
:placeholder="searchPlaceHolder"
:placeholder="searchPlaceholder"
v-model="searchQuery"
>
<div class="icon-wrapper">
@@ -13,53 +13,75 @@
</template>
<script lang="ts">
import { Component, Watch } from 'vue-property-decorator';
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
import {
defineComponent, ref, watch, computed,
} from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
@Component({
directives: { NonCollapsing },
})
export default class TheSearchBar extends StatefulVue {
public searchPlaceHolder = 'Search';
export default defineComponent({
directives: {
NonCollapsing,
},
setup() {
const {
modifyCurrentState, onStateChange, events, currentState,
} = useCollectionState();
public searchQuery = '';
const searchPlaceholder = computed<string>(() => {
const { totalScripts } = currentState.value.collection;
return `Search in ${totalScripts} scripts`;
});
const searchQuery = ref<string>();
@Watch('searchQuery')
public async updateFilter(newFilter?: string) {
const context = await this.getCurrentContext();
const { filter } = context.state;
if (!newFilter) {
filter.removeFilter();
} else {
filter.setFilter(newFilter);
watch(searchQuery, (newFilter) => updateFilter(newFilter));
function updateFilter(newFilter: string) {
modifyCurrentState((state) => {
const { filter } = state;
if (!newFilter) {
filter.removeFilter();
} else {
filter.setFilter(newFilter);
}
});
}
}
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState) {
const { totalScripts } = newState.collection;
this.searchPlaceHolder = `Search in ${totalScripts} scripts`;
this.searchQuery = newState.filter.currentFilter ? newState.filter.currentFilter.query : '';
this.events.unsubscribeAll();
this.subscribeFilter(newState.filter);
}
onStateChange((newState) => {
events.unsubscribeAll();
subscribeSearchQuery(newState);
}, { immediate: true });
private subscribeFilter(filter: IReadOnlyUserFilter) {
this.events.register(filter.filtered.on((result) => this.handleFiltered(result)));
this.events.register(filter.filterRemoved.on(() => this.handleFilterRemoved()));
}
function subscribeSearchQuery(newState: IReadOnlyCategoryCollectionState) {
searchQuery.value = newState.filter.currentFilter ? newState.filter.currentFilter.query : '';
subscribeFilter(newState.filter);
}
private handleFiltered(result: IFilterResult) {
this.searchQuery = result.query;
}
function subscribeFilter(filter: IReadOnlyUserFilter) {
events.register(
filter.filtered.on((result) => handleFiltered(result)),
filter.filterRemoved.on(() => handleFilterRemoved()),
);
}
function handleFilterRemoved() {
searchQuery.value = '';
}
function handleFiltered(result: IFilterResult) {
searchQuery.value = result.query;
}
return {
searchPlaceholder,
searchQuery,
};
},
});
private handleFilterRemoved() {
this.searchQuery = '';
}
}
</script>
<style scoped lang="scss">

View File

@@ -1,10 +1,20 @@
import Vue from 'vue';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import App from './components/App.vue';
import { ApplicationBootstrapper } from './bootstrapping/ApplicationBootstrapper';
new ApplicationBootstrapper()
.bootstrap(Vue);
let vue: Vue;
new Vue({
render: (h) => h(App),
}).$mount('#app');
buildContext().then(() => {
// hack workaround to solve running tests through
// Vue CLI throws 'Top-level-await is only supported in EcmaScript Modules'
// once migrated to vite, remove buildContext() call from here and use top-level-await
new ApplicationBootstrapper()
.bootstrap(Vue);
vue = new Vue({
render: (h) => h(App),
}).$mount('#app');
});
export const getVue = () => vue; // exporting is hack until Vue 3 so vue-js-modal can be used