Refactor user selection state handling using hook
This commit introduces `useUserSelectionState` compositional hook. it centralizes and allows reusing the logic for tracking and mutation user selection state across the application. The change aims to increase code reusability, simplify the code, improve testability, and adhere to the single responsibility principle. It makes the code more reliable against race conditions and removes the need for tracking deep changes. Other supporting changes: - Introduce `CardStateIndicator` component for displaying the card state indicator icon, improving the testability and separation of concerns. - Refactor `SelectionTypeHandler` to use functional code with more clear interfaces to simplify the code. It reduces complexity, increases maintainability and increase readability by explicitly separating mutating functions. - Add new unit tests and extend improving ones to cover the new logic introduced in this commit. Remove the need to mount a wrapper component to simplify and optimize some tests, using parameter injection to inject dependencies intead.
This commit is contained in:
@@ -5,5 +5,5 @@ export interface IEventSubscriptionCollection {
|
||||
|
||||
register(subscriptions: IEventSubscription[]): void;
|
||||
unsubscribeAll(): void;
|
||||
unsubscribeAllAndRegister(subscriptions: IEventSubscription[]);
|
||||
unsubscribeAllAndRegister(subscriptions: IEventSubscription[]): void;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TransientKey, injectKey,
|
||||
} from '@/presentation/injectionSymbols';
|
||||
import { PropertyKeys } from '@/TypeHelpers';
|
||||
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
|
||||
export function provideDependencies(
|
||||
context: IApplicationContext,
|
||||
@@ -48,6 +49,14 @@ export function provideDependencies(
|
||||
return useCurrentCode(state, events);
|
||||
},
|
||||
),
|
||||
useUserSelectionState: (di) => di.provide(
|
||||
InjectionKeys.useUserSelectionState,
|
||||
() => {
|
||||
const events = di.injectKey((keys) => keys.useAutoUnsubscribedEvents);
|
||||
const state = di.injectKey((keys) => keys.useCollectionState);
|
||||
return useUserSelectionState(state, events);
|
||||
},
|
||||
),
|
||||
};
|
||||
registerAll(Object.values(resolvers), api);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { IScript } from '@/domain/IScript';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { RecommendationLevel } from '@/domain/RecommendationLevel';
|
||||
import { scrambledEqual } from '@/application/Common/Array';
|
||||
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { IReadOnlyUserSelection, IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
|
||||
export enum SelectionType {
|
||||
Standard,
|
||||
@@ -12,66 +13,79 @@ export enum SelectionType {
|
||||
Custom,
|
||||
}
|
||||
|
||||
export class SelectionTypeHandler {
|
||||
constructor(private readonly state: ICategoryCollectionState) {
|
||||
if (!state) { throw new Error('missing state'); }
|
||||
}
|
||||
|
||||
public selectType(type: SelectionType) {
|
||||
export function setCurrentSelectionType(type: SelectionType, context: SelectionMutationContext) {
|
||||
if (type === SelectionType.Custom) {
|
||||
throw new Error('cannot select custom type');
|
||||
}
|
||||
const selector = selectors.get(type);
|
||||
selector.select(this.state);
|
||||
}
|
||||
selector.select(context);
|
||||
}
|
||||
|
||||
public getCurrentSelectionType(): SelectionType {
|
||||
export function getCurrentSelectionType(context: SelectionCheckContext): SelectionType {
|
||||
for (const [type, selector] of selectors.entries()) {
|
||||
if (selector.isSelected(this.state)) {
|
||||
if (selector.isSelected(context)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return SelectionType.Custom;
|
||||
}
|
||||
}
|
||||
|
||||
interface ISingleTypeHandler {
|
||||
isSelected: (state: IReadOnlyCategoryCollectionState) => boolean;
|
||||
select: (state: ICategoryCollectionState) => void;
|
||||
export interface SelectionCheckContext {
|
||||
readonly selection: IReadOnlyUserSelection;
|
||||
readonly collection: ICategoryCollection;
|
||||
}
|
||||
|
||||
const selectors = new Map<SelectionType, ISingleTypeHandler>([
|
||||
export interface SelectionMutationContext {
|
||||
readonly selection: IUserSelection,
|
||||
readonly collection: ICategoryCollection,
|
||||
}
|
||||
|
||||
interface SelectionTypeHandler {
|
||||
isSelected: (context: SelectionCheckContext) => boolean;
|
||||
select: (context: SelectionMutationContext) => void;
|
||||
}
|
||||
|
||||
const selectors = new Map<SelectionType, SelectionTypeHandler>([
|
||||
[SelectionType.None, {
|
||||
select: (state) => state.selection.deselectAll(),
|
||||
isSelected: (state) => state.selection.selectedScripts.length === 0,
|
||||
select: ({ selection }) => selection.deselectAll(),
|
||||
isSelected: ({ selection }) => selection.selectedScripts.length === 0,
|
||||
}],
|
||||
[SelectionType.Standard, getRecommendationLevelSelector(RecommendationLevel.Standard)],
|
||||
[SelectionType.Strict, getRecommendationLevelSelector(RecommendationLevel.Strict)],
|
||||
[SelectionType.All, {
|
||||
select: (state) => state.selection.selectAll(),
|
||||
isSelected: (state) => state.selection.selectedScripts.length === state.collection.totalScripts,
|
||||
select: ({ selection }) => selection.selectAll(),
|
||||
isSelected: (
|
||||
{ selection, collection },
|
||||
) => selection.selectedScripts.length === collection.totalScripts,
|
||||
}],
|
||||
]);
|
||||
|
||||
function getRecommendationLevelSelector(level: RecommendationLevel): ISingleTypeHandler {
|
||||
function getRecommendationLevelSelector(
|
||||
level: RecommendationLevel,
|
||||
): SelectionTypeHandler {
|
||||
return {
|
||||
select: (state) => selectOnly(level, state),
|
||||
isSelected: (state) => hasAllSelectedLevelOf(level, state),
|
||||
select: (context) => selectOnly(level, context),
|
||||
isSelected: (context) => hasAllSelectedLevelOf(level, context),
|
||||
};
|
||||
}
|
||||
|
||||
function hasAllSelectedLevelOf(
|
||||
level: RecommendationLevel,
|
||||
state: IReadOnlyCategoryCollectionState,
|
||||
) {
|
||||
const scripts = state.collection.getScriptsByLevel(level);
|
||||
const { selectedScripts } = state.selection;
|
||||
context: SelectionCheckContext,
|
||||
): boolean {
|
||||
const { collection, selection } = context;
|
||||
const scripts = collection.getScriptsByLevel(level);
|
||||
const { selectedScripts } = selection;
|
||||
return areAllSelected(scripts, selectedScripts);
|
||||
}
|
||||
|
||||
function selectOnly(level: RecommendationLevel, state: ICategoryCollectionState) {
|
||||
const scripts = state.collection.getScriptsByLevel(level);
|
||||
state.selection.selectOnly(scripts);
|
||||
function selectOnly(
|
||||
level: RecommendationLevel,
|
||||
context: SelectionMutationContext,
|
||||
): void {
|
||||
const { collection, selection } = context;
|
||||
const scripts = collection.getScriptsByLevel(level);
|
||||
selection.selectOnly(scripts);
|
||||
}
|
||||
|
||||
function areAllSelected(
|
||||
|
||||
@@ -65,14 +65,15 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import {
|
||||
defineComponent, computed,
|
||||
} from 'vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import MenuOptionList from '../MenuOptionList.vue';
|
||||
import MenuOptionListItem from '../MenuOptionListItem.vue';
|
||||
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
|
||||
import { SelectionType, setCurrentSelectionType, getCurrentSelectionType } from './SelectionTypeHandler';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -81,43 +82,38 @@ export default defineComponent({
|
||||
TooltipWrapper,
|
||||
},
|
||||
setup() {
|
||||
const { modifyCurrentState, onStateChange } = injectKey((keys) => keys.useCollectionState);
|
||||
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
|
||||
const {
|
||||
currentSelection, modifyCurrentSelection,
|
||||
} = injectKey((keys) => keys.useUserSelectionState);
|
||||
const { currentState } = injectKey((keys) => keys.useCollectionState);
|
||||
|
||||
const currentSelection = ref(SelectionType.None);
|
||||
const currentCollection = computed<ICategoryCollection>(() => currentState.value.collection);
|
||||
|
||||
let selectionTypeHandler: SelectionTypeHandler;
|
||||
|
||||
onStateChange(() => {
|
||||
modifyCurrentState((state) => {
|
||||
selectionTypeHandler = new SelectionTypeHandler(state);
|
||||
updateSelections();
|
||||
events.unsubscribeAllAndRegister([
|
||||
subscribeAndUpdateSelections(state),
|
||||
]);
|
||||
const currentSelectionType = computed<SelectionType>({
|
||||
get: () => getCurrentSelectionType({
|
||||
selection: currentSelection.value,
|
||||
collection: currentCollection.value,
|
||||
}),
|
||||
set: (type: SelectionType) => {
|
||||
selectType(type);
|
||||
},
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
function subscribeAndUpdateSelections(
|
||||
state: ICategoryCollectionState,
|
||||
): IEventSubscription {
|
||||
return state.selection.changed.on(() => updateSelections());
|
||||
}
|
||||
|
||||
function selectType(type: SelectionType) {
|
||||
if (currentSelection.value === type) {
|
||||
if (currentSelectionType.value === type) {
|
||||
return;
|
||||
}
|
||||
selectionTypeHandler.selectType(type);
|
||||
}
|
||||
|
||||
function updateSelections() {
|
||||
currentSelection.value = selectionTypeHandler.getCurrentSelectionType();
|
||||
modifyCurrentSelection((mutableSelection) => {
|
||||
setCurrentSelectionType(type, {
|
||||
selection: mutableSelection,
|
||||
collection: currentCollection.value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
SelectionType,
|
||||
currentSelection,
|
||||
currentSelection: currentSelectionType,
|
||||
selectType,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -22,16 +22,10 @@
|
||||
:icon="isExpanded ? 'folder-open' : 'folder'"
|
||||
/>
|
||||
<!-- Indeterminate and full states -->
|
||||
<div class="card__inner__state-icons">
|
||||
<AppIcon
|
||||
icon="battery-half"
|
||||
v-if="isAnyChildSelected && !areAllChildrenSelected"
|
||||
<CardSelectionIndicator
|
||||
class="card__inner__selection_indicator"
|
||||
:categoryId="categoryId"
|
||||
/>
|
||||
<AppIcon
|
||||
icon="battery-full"
|
||||
v-if="areAllChildrenSelected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__expander" v-on:click.stop>
|
||||
<div class="card__expander__content">
|
||||
@@ -49,17 +43,19 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent, ref, watch, computed, shallowRef,
|
||||
defineComponent, computed, shallowRef,
|
||||
} from 'vue';
|
||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||
import CardSelectionIndicator from './CardSelectionIndicator.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
ScriptsTree,
|
||||
AppIcon,
|
||||
CardSelectionIndicator,
|
||||
},
|
||||
props: {
|
||||
categoryId: {
|
||||
@@ -77,8 +73,7 @@ export default defineComponent({
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const { onStateChange, currentState } = injectKey((keys) => keys.useCollectionState);
|
||||
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
|
||||
const { currentState } = injectKey((keys) => keys.useCollectionState);
|
||||
|
||||
const isExpanded = computed({
|
||||
get: () => {
|
||||
@@ -92,8 +87,6 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const isAnyChildSelected = ref(false);
|
||||
const areAllChildrenSelected = ref(false);
|
||||
const cardElement = shallowRef<HTMLElement>();
|
||||
|
||||
const cardTitle = computed<string | undefined>(() => {
|
||||
@@ -108,37 +101,14 @@ export default defineComponent({
|
||||
isExpanded.value = false;
|
||||
}
|
||||
|
||||
onStateChange((state) => {
|
||||
events.unsubscribeAllAndRegister([
|
||||
state.selection.changed.on(
|
||||
() => updateSelectionIndicators(props.categoryId),
|
||||
),
|
||||
]);
|
||||
updateSelectionIndicators(props.categoryId);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(
|
||||
() => props.categoryId,
|
||||
(categoryId) => updateSelectionIndicators(categoryId),
|
||||
);
|
||||
|
||||
async function scrollToCard() {
|
||||
await sleep(400); // wait a bit to allow GUI to render the expanded card
|
||||
cardElement.value.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -192,7 +162,7 @@ $card-horizontal-gap : $card-gap;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
&__state-icons {
|
||||
&__selection_indicator {
|
||||
height: $card-inner-padding;
|
||||
margin-right: -$card-inner-padding;
|
||||
padding-right: 10px;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppIcon
|
||||
icon="battery-half"
|
||||
v-if="isAnyChildSelected && !areAllChildrenSelected"
|
||||
/>
|
||||
<AppIcon
|
||||
icon="battery-full"
|
||||
v-if="areAllChildrenSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import { ICategory } from '@/domain/ICategory';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
AppIcon,
|
||||
},
|
||||
props: {
|
||||
categoryId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { currentState } = injectKey((keys) => keys.useCollectionState);
|
||||
const { currentSelection } = injectKey((keys) => keys.useUserSelectionState);
|
||||
const currentCollection = computed<ICategoryCollection>(() => currentState.value.collection);
|
||||
|
||||
const currentCategory = computed<ICategory>(
|
||||
() => currentCollection.value.findCategory(props.categoryId),
|
||||
);
|
||||
|
||||
const isAnyChildSelected = computed<boolean>(
|
||||
() => currentSelection.value.isAnySelected(currentCategory.value),
|
||||
);
|
||||
|
||||
const areAllChildrenSelected = computed<boolean>(
|
||||
() => currentSelection.value.areAllSelected(currentCategory.value),
|
||||
);
|
||||
|
||||
return {
|
||||
isAnyChildSelected,
|
||||
areAllChildrenSelected,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ToggleSwitch
|
||||
v-model="isChecked"
|
||||
v-model="isReverted"
|
||||
:stopClickPropagation="true"
|
||||
:label="'revert'"
|
||||
/>
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
PropType, defineComponent, ref, watch, computed,
|
||||
PropType, defineComponent, computed,
|
||||
} from 'vue';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
|
||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
||||
import { IReverter } from './Reverter/IReverter';
|
||||
import { getReverter } from './Reverter/ReverterFactory';
|
||||
import ToggleSwitch from './ToggleSwitch.vue';
|
||||
@@ -29,56 +29,37 @@ export default defineComponent({
|
||||
},
|
||||
setup(props) {
|
||||
const {
|
||||
currentState, modifyCurrentState, onStateChange,
|
||||
} = injectKey((keys) => keys.useCollectionState);
|
||||
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
|
||||
currentSelection, modifyCurrentSelection,
|
||||
} = injectKey((keys) => keys.useUserSelectionState);
|
||||
const { currentState } = injectKey((keys) => keys.useCollectionState);
|
||||
|
||||
const isReverted = ref(false);
|
||||
const currentCollection = computed<ICategoryCollection>(() => currentState.value.collection);
|
||||
|
||||
let handler: IReverter | undefined;
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
(node) => onNodeChanged(node),
|
||||
{ immediate: true },
|
||||
const revertHandler = computed<IReverter>(
|
||||
() => getReverter(props.node, currentCollection.value),
|
||||
);
|
||||
|
||||
onStateChange((newState) => {
|
||||
updateRevertStatusFromState(newState.selection.selectedScripts);
|
||||
events.unsubscribeAllAndRegister([
|
||||
newState.selection.changed.on((scripts) => updateRevertStatusFromState(scripts)),
|
||||
]);
|
||||
}, { immediate: true });
|
||||
|
||||
function onNodeChanged(node: NodeMetadata) {
|
||||
handler = getReverter(node, currentState.value.collection);
|
||||
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
|
||||
}
|
||||
|
||||
function updateRevertStatusFromState(scripts: ReadonlyArray<SelectedScript>) {
|
||||
isReverted.value = handler?.getState(scripts) ?? false;
|
||||
}
|
||||
|
||||
function syncReversionStatusWithState(value: boolean) {
|
||||
if (value === isReverted.value) {
|
||||
return;
|
||||
}
|
||||
modifyCurrentState((state) => {
|
||||
handler.selectWithRevertState(value, state.selection);
|
||||
});
|
||||
}
|
||||
|
||||
const isChecked = computed({
|
||||
const isReverted = computed<boolean>({
|
||||
get() {
|
||||
return isReverted.value;
|
||||
const { selectedScripts } = currentSelection.value;
|
||||
return revertHandler.value.getState(selectedScripts);
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
syncReversionStatusWithState(value);
|
||||
},
|
||||
});
|
||||
|
||||
function syncReversionStatusWithState(value: boolean) {
|
||||
if (value === isReverted.value) {
|
||||
return;
|
||||
}
|
||||
modifyCurrentSelection((mutableSelection) => {
|
||||
revertHandler.value.selectWithRevertState(value, mutableSelection);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isChecked,
|
||||
isReverted,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import TreeView from './TreeView/TreeView.vue';
|
||||
import NodeContent from './NodeContent/NodeContent.vue';
|
||||
import { useTreeViewFilterEvent } from './TreeViewAdapter/UseTreeViewFilterEvent';
|
||||
@@ -38,10 +39,11 @@ export default defineComponent({
|
||||
NodeContent,
|
||||
},
|
||||
setup(props) {
|
||||
const { selectedScriptNodeIds } = useSelectedScriptNodeIds();
|
||||
const useUserCollectionStateHook = injectKey((keys) => keys.useUserSelectionState);
|
||||
const { selectedScriptNodeIds } = useSelectedScriptNodeIds(useUserCollectionStateHook);
|
||||
const { latestFilterEvent } = useTreeViewFilterEvent();
|
||||
const { treeViewInputNodes } = useTreeViewNodeInput(() => props.categoryId);
|
||||
const { updateNodeSelection } = useCollectionSelectionStateUpdater();
|
||||
const { updateNodeSelection } = useCollectionSelectionStateUpdater(useUserCollectionStateHook);
|
||||
|
||||
function handleNodeChangedEvent(event: TreeNodeStateChangedEmittedEvent) {
|
||||
updateNodeSelection(event);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
import { TreeNodeCheckState } from '../TreeView/Node/State/CheckState';
|
||||
import { TreeNodeStateChangedEmittedEvent } from '../TreeView/Bindings/TreeNodeStateChangedEmittedEvent';
|
||||
|
||||
export function useCollectionSelectionStateUpdater() {
|
||||
const { modifyCurrentState, currentState } = injectKey((keys) => keys.useCollectionState);
|
||||
export function useCollectionSelectionStateUpdater(
|
||||
useSelectionStateHook: ReturnType<typeof useUserSelectionState>,
|
||||
) {
|
||||
const { modifyCurrentSelection, currentSelection } = useSelectionStateHook;
|
||||
|
||||
function updateNodeSelection(change: TreeNodeStateChangedEmittedEvent) {
|
||||
const { node } = change;
|
||||
@@ -14,19 +16,19 @@ export function useCollectionSelectionStateUpdater() {
|
||||
return;
|
||||
}
|
||||
if (node.state.current.checkState === TreeNodeCheckState.Checked) {
|
||||
if (currentState.value.selection.isSelected(node.id)) {
|
||||
if (currentSelection.value.isSelected(node.id)) {
|
||||
return;
|
||||
}
|
||||
modifyCurrentState((state) => {
|
||||
state.selection.addSelectedScript(node.id, false);
|
||||
modifyCurrentSelection((selection) => {
|
||||
selection.addSelectedScript(node.id, false);
|
||||
});
|
||||
}
|
||||
if (node.state.current.checkState === TreeNodeCheckState.Unchecked) {
|
||||
if (!currentState.value.selection.isSelected(node.id)) {
|
||||
if (!currentSelection.value.isSelected(node.id)) {
|
||||
return;
|
||||
}
|
||||
modifyCurrentState((state) => {
|
||||
state.selection.removeSelectedScript(node.id);
|
||||
modifyCurrentSelection((selection) => {
|
||||
selection.removeSelectedScript(node.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import {
|
||||
computed, shallowReadonly, shallowRef, triggerRef,
|
||||
computed, shallowReadonly,
|
||||
} from 'vue';
|
||||
import { injectKey } from '@/presentation/injectionSymbols';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
import { getScriptNodeId } from './CategoryNodeMetadataConverter';
|
||||
|
||||
export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) {
|
||||
const { selectedScripts } = useSelectedScripts();
|
||||
export function useSelectedScriptNodeIds(
|
||||
useSelectionStateHook: ReturnType<typeof useUserSelectionState>,
|
||||
scriptNodeIdParser = getScriptNodeId,
|
||||
) {
|
||||
const { currentSelection } = useSelectionStateHook;
|
||||
|
||||
const selectedNodeIds = computed<readonly string[]>(() => {
|
||||
return selectedScripts
|
||||
return currentSelection
|
||||
.value
|
||||
.selectedScripts
|
||||
.map((selected) => scriptNodeIdParser(selected.script));
|
||||
});
|
||||
|
||||
@@ -18,33 +21,3 @@ export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) {
|
||||
selectedScriptNodeIds: shallowReadonly(selectedNodeIds),
|
||||
};
|
||||
}
|
||||
|
||||
function useSelectedScripts() {
|
||||
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
|
||||
const { onStateChange } = injectKey((keys) => keys.useCollectionState);
|
||||
|
||||
const selectedScripts = shallowRef<readonly SelectedScript[]>([]);
|
||||
|
||||
function updateSelectedScripts(newReference: readonly SelectedScript[]) {
|
||||
if (selectedScripts.value === newReference) {
|
||||
// Manually trigger update if the array was mutated using the same reference.
|
||||
// Array might have been mutated without changing the reference
|
||||
triggerRef(selectedScripts);
|
||||
} else {
|
||||
selectedScripts.value = newReference;
|
||||
}
|
||||
}
|
||||
|
||||
onStateChange((state) => {
|
||||
updateSelectedScripts(state.selection.selectedScripts);
|
||||
events.unsubscribeAllAndRegister([
|
||||
state.selection.changed.on((scripts) => {
|
||||
updateSelectedScripts(scripts);
|
||||
}),
|
||||
]);
|
||||
}, { immediate: true });
|
||||
|
||||
return {
|
||||
selectedScripts: shallowReadonly(selectedScripts),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { shallowReadonly, shallowRef, triggerRef } from 'vue';
|
||||
import { IReadOnlyUserSelection, IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import type { useAutoUnsubscribedEvents } from './UseAutoUnsubscribedEvents';
|
||||
import type { useCollectionState } from './UseCollectionState';
|
||||
|
||||
export function useUserSelectionState(
|
||||
collectionState: ReturnType<typeof useCollectionState>,
|
||||
autoUnsubscribedEvents: ReturnType<typeof useAutoUnsubscribedEvents>,
|
||||
) {
|
||||
const { events } = autoUnsubscribedEvents;
|
||||
const { onStateChange, modifyCurrentState, currentState } = collectionState;
|
||||
|
||||
const currentSelection = shallowRef<IReadOnlyUserSelection>(currentState.value.selection);
|
||||
|
||||
onStateChange((state) => {
|
||||
updateSelection(state.selection);
|
||||
events.unsubscribeAllAndRegister([
|
||||
state.selection.changed.on(() => {
|
||||
updateSelection(state.selection);
|
||||
}),
|
||||
]);
|
||||
}, { immediate: true });
|
||||
|
||||
function modifyCurrentSelection(mutator: SelectionModifier) {
|
||||
modifyCurrentState((state) => {
|
||||
mutator(state.selection);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelection(newSelection: IReadOnlyUserSelection) {
|
||||
if (currentSelection.value === newSelection) {
|
||||
// Do not trust Vue tracking, the changed selection object
|
||||
// reference may stay same for same collection.
|
||||
triggerRef(currentSelection);
|
||||
} else {
|
||||
currentSelection.value = newSelection;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentSelection: shallowReadonly(currentSelection),
|
||||
modifyCurrentSelection,
|
||||
};
|
||||
}
|
||||
|
||||
export type SelectionModifier = (
|
||||
state: IUserSelection,
|
||||
) => void;
|
||||
@@ -5,6 +5,7 @@ import type { useRuntimeEnvironment } from '@/presentation/components/Shared/Hoo
|
||||
import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard';
|
||||
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
|
||||
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
|
||||
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
|
||||
export const InjectionKeys = {
|
||||
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
|
||||
@@ -13,6 +14,7 @@ export const InjectionKeys = {
|
||||
useAutoUnsubscribedEvents: defineTransientKey<ReturnType<typeof useAutoUnsubscribedEvents>>('useAutoUnsubscribedEvents'),
|
||||
useClipboard: defineTransientKey<ReturnType<typeof useClipboard>>('useClipboard'),
|
||||
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
|
||||
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
|
||||
};
|
||||
|
||||
export interface InjectionKeyWithLifetime<T> {
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('DependencyProvider', () => {
|
||||
useAutoUnsubscribedEvents: createTransientTests(),
|
||||
useClipboard: createTransientTests(),
|
||||
useCurrentCode: createTransientTests(),
|
||||
useUserSelectionState: createTransientTests(),
|
||||
};
|
||||
Object.entries(testCases).forEach(([key, runTests]) => {
|
||||
const registeredKey = InjectionKeys[key].key;
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SelectionType, SelectionTypeHandler } from '@/presentation/components/Scripts/Menu/Selector/SelectionTypeHandler';
|
||||
import {
|
||||
SelectionCheckContext, SelectionMutationContext, SelectionType,
|
||||
getCurrentSelectionType, setCurrentSelectionType,
|
||||
} from '@/presentation/components/Scripts/Menu/Selector/SelectionTypeHandler';
|
||||
import { scrambledEqual } from '@/application/Common/Array';
|
||||
import { RecommendationLevel } from '@/domain/RecommendationLevel';
|
||||
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
|
||||
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||
import { SelectionStateTestScenario } from './SelectionStateTestScenario';
|
||||
|
||||
describe('SelectionTypeHandler', () => {
|
||||
describe('ctor', () => {
|
||||
describe('throws when state is missing', () => {
|
||||
itEachAbsentObjectValue((absentValue) => {
|
||||
// arrange
|
||||
const expectedError = 'missing state';
|
||||
const state = absentValue;
|
||||
// act
|
||||
const sut = () => new SelectionTypeHandler(state);
|
||||
// assert
|
||||
expect(sut).to.throw(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('selectType', () => {
|
||||
describe('setCurrentSelectionType', () => {
|
||||
it('throws when type is custom', () => {
|
||||
// arrange
|
||||
const expectedError = 'cannot select custom type';
|
||||
const scenario = new SelectionStateTestScenario();
|
||||
const state = scenario.generateState([]);
|
||||
const sut = new SelectionTypeHandler(state);
|
||||
// act
|
||||
const act = () => sut.selectType(SelectionType.Custom);
|
||||
const act = () => setCurrentSelectionType(SelectionType.Custom, createMutationContext(state));
|
||||
// assert
|
||||
expect(act).to.throw(expectedError);
|
||||
});
|
||||
@@ -47,7 +36,6 @@ describe('SelectionTypeHandler', () => {
|
||||
for (const initialScriptsCase of initialScriptsCases) {
|
||||
describe(initialScriptsCase.name, () => {
|
||||
const state = scenario.generateState(initialScriptsCase.initialScripts);
|
||||
const sut = new SelectionTypeHandler(state);
|
||||
const typeExpectations = [{
|
||||
input: SelectionType.None,
|
||||
output: [],
|
||||
@@ -64,7 +52,7 @@ describe('SelectionTypeHandler', () => {
|
||||
for (const expectation of typeExpectations) {
|
||||
// act
|
||||
it(`${SelectionType[expectation.input]} returns as expected`, () => {
|
||||
sut.selectType(expectation.input);
|
||||
setCurrentSelectionType(expectation.input, createMutationContext(state));
|
||||
// assert
|
||||
const actual = state.selection.selectedScripts;
|
||||
const expected = expectation.output;
|
||||
@@ -114,9 +102,8 @@ describe('SelectionTypeHandler', () => {
|
||||
for (const testCase of testCases) {
|
||||
it(testCase.name, () => {
|
||||
const state = scenario.generateState(testCase.selection);
|
||||
const sut = new SelectionTypeHandler(state);
|
||||
// act
|
||||
const actual = sut.getCurrentSelectionType();
|
||||
const actual = getCurrentSelectionType(createCheckContext(state));
|
||||
// assert
|
||||
expect(actual).to.deep.equal(
|
||||
testCase.expected,
|
||||
@@ -135,3 +122,17 @@ describe('SelectionTypeHandler', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createMutationContext(state: ICategoryCollectionState): SelectionMutationContext {
|
||||
return {
|
||||
selection: state.selection,
|
||||
collection: state.collection,
|
||||
};
|
||||
}
|
||||
|
||||
function createCheckContext(state: ICategoryCollectionState): SelectionCheckContext {
|
||||
return {
|
||||
selection: state.selection,
|
||||
collection: state.collection,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { useCollectionSelectionStateUpdater } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseCollectionSelectionStateUpdater';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
|
||||
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
|
||||
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
|
||||
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
|
||||
import { TreeNodeCheckState } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/State/CheckState';
|
||||
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
import { TreeNodeStateChangedEmittedEventStub } from '@tests/unit/shared/Stubs/TreeNodeStateChangedEmittedEventStub';
|
||||
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
|
||||
|
||||
describe('useCollectionSelectionStateUpdater', () => {
|
||||
describe('updateNodeSelection', () => {
|
||||
describe('when node is a branch node', () => {
|
||||
it('does nothing', () => {
|
||||
// arrange
|
||||
const { returnObject, useStateStub } = mountWrapperComponent();
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(
|
||||
createTreeNodeStub({
|
||||
@@ -31,14 +28,13 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
// act
|
||||
returnObject.updateNodeSelection(mockEvent);
|
||||
// assert
|
||||
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
|
||||
expect(modifyCall).toBeUndefined();
|
||||
expect(useSelectionStateStub.isSelectionModified()).to.equal(false);
|
||||
});
|
||||
});
|
||||
describe('when old and new check states are the same', () => {
|
||||
it('does nothing', () => {
|
||||
// arrange
|
||||
const { returnObject, useStateStub } = mountWrapperComponent();
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(
|
||||
createTreeNodeStub({
|
||||
@@ -53,24 +49,22 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
// act
|
||||
returnObject.updateNodeSelection(mockEvent);
|
||||
// assert
|
||||
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
|
||||
expect(modifyCall).toBeUndefined();
|
||||
expect(useSelectionStateStub.isSelectionModified()).to.equal(false);
|
||||
});
|
||||
});
|
||||
describe('when checkState is checked', () => {
|
||||
it('adds to selection if not already selected', () => {
|
||||
// arrange
|
||||
const { returnObject, useStateStub } = mountWrapperComponent();
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const selectionStub = new UserSelectionStub([]);
|
||||
selectionStub.isSelected = () => false;
|
||||
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(
|
||||
createTreeNodeStub({
|
||||
useSelectionStateStub.withUserSelection(selectionStub);
|
||||
const node = createTreeNodeStub({
|
||||
isBranch: false,
|
||||
currentState: TreeNodeCheckState.Checked,
|
||||
}),
|
||||
)
|
||||
});
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(node)
|
||||
.withCheckStateChange({
|
||||
oldState: TreeNodeCheckState.Unchecked,
|
||||
newState: TreeNodeCheckState.Checked,
|
||||
@@ -78,17 +72,15 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
// act
|
||||
returnObject.updateNodeSelection(mockEvent);
|
||||
// assert
|
||||
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
|
||||
expect(modifyCall).toBeDefined();
|
||||
const addSelectedScriptCall = selectionStub.callHistory.find((call) => call.methodName === 'addSelectedScript');
|
||||
expect(addSelectedScriptCall).toBeDefined();
|
||||
expect(useSelectionStateStub.isSelectionModified()).to.equal(true);
|
||||
expect(selectionStub.isScriptAdded(node.id)).to.equal(true);
|
||||
});
|
||||
it('does nothing if already selected', () => {
|
||||
// arrange
|
||||
const { returnObject, useStateStub } = mountWrapperComponent();
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const selectionStub = new UserSelectionStub([]);
|
||||
selectionStub.isSelected = () => true;
|
||||
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
||||
useSelectionStateStub.withUserSelection(selectionStub);
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(
|
||||
createTreeNodeStub({
|
||||
@@ -103,24 +95,22 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
// act
|
||||
returnObject.updateNodeSelection(mockEvent);
|
||||
// assert
|
||||
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
|
||||
expect(modifyCall).toBeUndefined();
|
||||
expect(useSelectionStateStub.isSelectionModified()).to.equal(false);
|
||||
});
|
||||
});
|
||||
describe('when checkState is unchecked', () => {
|
||||
it('removes from selection if already selected', () => {
|
||||
// arrange
|
||||
const { returnObject, useStateStub } = mountWrapperComponent();
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const selectionStub = new UserSelectionStub([]);
|
||||
selectionStub.isSelected = () => true;
|
||||
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(
|
||||
createTreeNodeStub({
|
||||
useSelectionStateStub.withUserSelection(selectionStub);
|
||||
const node = createTreeNodeStub({
|
||||
isBranch: false,
|
||||
currentState: TreeNodeCheckState.Unchecked,
|
||||
}),
|
||||
)
|
||||
});
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(node)
|
||||
.withCheckStateChange({
|
||||
oldState: TreeNodeCheckState.Checked,
|
||||
newState: TreeNodeCheckState.Unchecked,
|
||||
@@ -128,17 +118,15 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
// act
|
||||
returnObject.updateNodeSelection(mockEvent);
|
||||
// assert
|
||||
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
|
||||
expect(modifyCall).toBeDefined();
|
||||
const removeSelectedScriptCall = selectionStub.callHistory.find((call) => call.methodName === 'removeSelectedScript');
|
||||
expect(removeSelectedScriptCall).toBeDefined();
|
||||
expect(useSelectionStateStub.isSelectionModified()).to.equal(true);
|
||||
expect(selectionStub.isScriptRemoved(node.id)).to.equal(true);
|
||||
});
|
||||
it('does nothing if not already selected', () => {
|
||||
// arrange
|
||||
const { returnObject, useStateStub } = mountWrapperComponent();
|
||||
const { returnObject, useSelectionStateStub } = runHook();
|
||||
const selectionStub = new UserSelectionStub([]);
|
||||
selectionStub.isSelected = () => false;
|
||||
useStateStub.withState(new CategoryCollectionStateStub().withSelection(selectionStub));
|
||||
useSelectionStateStub.withUserSelection(selectionStub);
|
||||
const mockEvent = new TreeNodeStateChangedEmittedEventStub()
|
||||
.withNode(
|
||||
createTreeNodeStub({
|
||||
@@ -153,33 +141,18 @@ describe('useCollectionSelectionStateUpdater', () => {
|
||||
// act
|
||||
returnObject.updateNodeSelection(mockEvent);
|
||||
// assert
|
||||
const modifyCall = useStateStub.callHistory.find((call) => call.methodName === 'modifyCurrentState');
|
||||
expect(modifyCall).toBeUndefined();
|
||||
expect(useSelectionStateStub.isSelectionModified()).to.equal(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mountWrapperComponent() {
|
||||
const useStateStub = new UseCollectionStateStub();
|
||||
let returnObject: ReturnType<typeof useCollectionSelectionStateUpdater>;
|
||||
|
||||
shallowMount({
|
||||
setup() {
|
||||
returnObject = useCollectionSelectionStateUpdater();
|
||||
},
|
||||
template: '<div></div>',
|
||||
}, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState.key]: () => useStateStub.get(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function runHook() {
|
||||
const useSelectionStateStub = new UseUserSelectionStateStub();
|
||||
const returnObject = useCollectionSelectionStateUpdater(useSelectionStateStub.get());
|
||||
return {
|
||||
returnObject,
|
||||
useStateStub,
|
||||
useSelectionStateStub,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { nextTick, watch } from 'vue';
|
||||
import { useSelectedScriptNodeIds } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/UseSelectedScriptNodeIds';
|
||||
import { InjectionKeys } from '@/presentation/injectionSymbols';
|
||||
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
|
||||
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
|
||||
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
|
||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||
import { getScriptNodeId } from '@/presentation/components/Scripts/View/Tree/TreeViewAdapter/CategoryNodeMetadataConverter';
|
||||
import { IScript } from '@/domain/IScript';
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
import { UseUserSelectionStateStub } from '@tests/unit/shared/Stubs/UseUserSelectionStateStub';
|
||||
|
||||
describe('useSelectedScriptNodeIds', () => {
|
||||
it('returns an empty array when no scripts are selected', () => {
|
||||
// arrange
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
useStateStub.withState(new CategoryCollectionStateStub().withSelectedScripts([]));
|
||||
const { useSelectionStateStub, returnObject } = runHook();
|
||||
useSelectionStateStub.withSelectedScripts([]);
|
||||
// act
|
||||
const actualIds = returnObject.selectedScriptNodeIds.value;
|
||||
// assert
|
||||
expect(actualIds).to.have.lengthOf(0);
|
||||
});
|
||||
it('initially registers the unsubscribe callback', () => {
|
||||
// arrange
|
||||
const eventsStub = new UseAutoUnsubscribedEventsStub();
|
||||
// act
|
||||
mountWrapperComponent({
|
||||
useAutoUnsubscribedEvents: eventsStub,
|
||||
});
|
||||
// assert
|
||||
const calls = eventsStub.events.callHistory;
|
||||
expect(eventsStub.events.callHistory).has.lengthOf(1);
|
||||
const call = calls.find((c) => c.methodName === 'unsubscribeAllAndRegister');
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
describe('returns correct node IDs for selected scripts', () => {
|
||||
it('immediately', () => {
|
||||
// arrange
|
||||
@@ -45,12 +26,11 @@ describe('useSelectedScriptNodeIds', () => {
|
||||
[selectedScripts[0].script, 'expected-id-1'],
|
||||
[selectedScripts[1].script, 'expected-id-2'],
|
||||
]);
|
||||
const { useStateStub, returnObject } = mountWrapperComponent({
|
||||
const useSelectionStateStub = new UseUserSelectionStateStub()
|
||||
.withSelectedScripts(selectedScripts);
|
||||
const { returnObject } = runHook({
|
||||
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
|
||||
});
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelectedScripts(selectedScripts),
|
||||
immediateOnly: true,
|
||||
useSelectionState: useSelectionStateStub,
|
||||
});
|
||||
// act
|
||||
const actualIds = returnObject.selectedScriptNodeIds.value;
|
||||
@@ -59,35 +39,6 @@ describe('useSelectedScriptNodeIds', () => {
|
||||
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
|
||||
expect(actualIds).to.include.members(expectedNodeIds);
|
||||
});
|
||||
it('when the collection state changes', () => {
|
||||
// arrange
|
||||
const initialScripts = [];
|
||||
const changedScripts = [
|
||||
new SelectedScriptStub('id-1'),
|
||||
new SelectedScriptStub('id-2'),
|
||||
];
|
||||
const parsedNodeIds = new Map<IScript, string>([
|
||||
[changedScripts[0].script, 'expected-id-1'],
|
||||
[changedScripts[1].script, 'expected-id-2'],
|
||||
]);
|
||||
const { useStateStub, returnObject } = mountWrapperComponent({
|
||||
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
|
||||
});
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelectedScripts(initialScripts),
|
||||
immediateOnly: true,
|
||||
});
|
||||
// act
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelectedScripts(changedScripts),
|
||||
immediateOnly: false,
|
||||
});
|
||||
const actualIds = returnObject.selectedScriptNodeIds.value;
|
||||
// assert
|
||||
const expectedNodeIds = [...parsedNodeIds.values()];
|
||||
expect(actualIds).to.have.lengthOf(expectedNodeIds.length);
|
||||
expect(actualIds).to.include.members(expectedNodeIds);
|
||||
});
|
||||
it('when the selection state changes', () => {
|
||||
// arrange
|
||||
const initialScripts = [];
|
||||
@@ -99,18 +50,14 @@ describe('useSelectedScriptNodeIds', () => {
|
||||
[changedScripts[0].script, 'expected-id-1'],
|
||||
[changedScripts[1].script, 'expected-id-2'],
|
||||
]);
|
||||
const { useStateStub, returnObject } = mountWrapperComponent({
|
||||
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
|
||||
});
|
||||
const userSelection = new UserSelectionStub([])
|
||||
const useSelectionStateStub = new UseUserSelectionStateStub()
|
||||
.withSelectedScripts(initialScripts);
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub()
|
||||
.withSelection(userSelection),
|
||||
immediateOnly: true,
|
||||
const { returnObject } = runHook({
|
||||
scriptNodeIdParser: createNodeIdParserFromMap(parsedNodeIds),
|
||||
useSelectionState: useSelectionStateStub,
|
||||
});
|
||||
// act
|
||||
userSelection.triggerSelectionChangedEvent(changedScripts);
|
||||
useSelectionStateStub.withSelectedScripts(changedScripts);
|
||||
const actualIds = returnObject.selectedScriptNodeIds.value;
|
||||
// assert
|
||||
const expectedNodeIds = [...parsedNodeIds.values()];
|
||||
@@ -118,96 +65,6 @@ describe('useSelectedScriptNodeIds', () => {
|
||||
expect(actualIds).to.include.members(expectedNodeIds);
|
||||
});
|
||||
});
|
||||
describe('reactivity to state changes', () => {
|
||||
describe('when the collection state changes', () => {
|
||||
it('with new array references', async () => {
|
||||
// arrange
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
let isChangeTriggered = false;
|
||||
watch(() => returnObject.selectedScriptNodeIds.value, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub(),
|
||||
immediateOnly: false,
|
||||
});
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
it('with the same array reference', async () => {
|
||||
// arrange
|
||||
const sharedSelectedScriptsReference = [];
|
||||
const initialCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelectedScripts(sharedSelectedScriptsReference);
|
||||
const changedCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelectedScripts(sharedSelectedScriptsReference);
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: initialCollectionState,
|
||||
immediateOnly: true,
|
||||
});
|
||||
let isChangeTriggered = false;
|
||||
watch(() => returnObject.selectedScriptNodeIds.value, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
sharedSelectedScriptsReference.push(new SelectedScriptStub('new')); // mutate array using same reference
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: changedCollectionState,
|
||||
immediateOnly: false,
|
||||
});
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
});
|
||||
describe('when the selection state changes', () => {
|
||||
it('with new array references', async () => {
|
||||
// arrange
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
const userSelection = new UserSelectionStub([])
|
||||
.withSelectedScripts([]);
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub()
|
||||
.withSelection(userSelection),
|
||||
immediateOnly: true,
|
||||
});
|
||||
let isChangeTriggered = false;
|
||||
watch(() => returnObject.selectedScriptNodeIds.value, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
userSelection.triggerSelectionChangedEvent([]);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
it('with the same array reference', async () => {
|
||||
// arrange
|
||||
const { useStateStub, returnObject } = mountWrapperComponent();
|
||||
const sharedSelectedScriptsReference = [];
|
||||
const userSelection = new UserSelectionStub([])
|
||||
.withSelectedScripts(sharedSelectedScriptsReference);
|
||||
useStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub()
|
||||
.withSelection(userSelection),
|
||||
immediateOnly: true,
|
||||
});
|
||||
let isChangeTriggered = false;
|
||||
watch(() => returnObject.selectedScriptNodeIds.value, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
sharedSelectedScriptsReference.push(new SelectedScriptStub('new')); // mutate array using same reference
|
||||
userSelection.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ScriptNodeIdParser = typeof getScriptNodeId;
|
||||
@@ -222,33 +79,16 @@ function createNodeIdParserFromMap(scriptToIdMap: Map<IScript, string>): ScriptN
|
||||
};
|
||||
}
|
||||
|
||||
function mountWrapperComponent(scenario?: {
|
||||
function runHook(scenario?: {
|
||||
readonly scriptNodeIdParser?: ScriptNodeIdParser,
|
||||
readonly useAutoUnsubscribedEvents?: UseAutoUnsubscribedEventsStub,
|
||||
readonly useSelectionState?: UseUserSelectionStateStub,
|
||||
}) {
|
||||
const useStateStub = new UseCollectionStateStub();
|
||||
const useSelectionStateStub = scenario?.useSelectionState ?? new UseUserSelectionStateStub();
|
||||
const nodeIdParser: ScriptNodeIdParser = scenario?.scriptNodeIdParser
|
||||
?? ((script) => script.id);
|
||||
let returnObject: ReturnType<typeof useSelectedScriptNodeIds>;
|
||||
|
||||
shallowMount({
|
||||
setup() {
|
||||
returnObject = useSelectedScriptNodeIds(nodeIdParser);
|
||||
},
|
||||
template: '<div></div>',
|
||||
}, {
|
||||
global: {
|
||||
provide: {
|
||||
[InjectionKeys.useCollectionState.key]:
|
||||
() => useStateStub.get(),
|
||||
[InjectionKeys.useAutoUnsubscribedEvents.key]:
|
||||
() => (scenario?.useAutoUnsubscribedEvents ?? new UseAutoUnsubscribedEventsStub()).get(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const returnObject = useSelectedScriptNodeIds(useSelectionStateStub.get(), nodeIdParser);
|
||||
return {
|
||||
returnObject,
|
||||
useStateStub,
|
||||
useSelectionStateStub,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('UseTreeViewFilterEvent', () => {
|
||||
.withFilter(filterStub);
|
||||
const { returnObject } = mountWrapperComponent({ useStateStub: stateStub });
|
||||
let totalFilterUpdates = 0;
|
||||
watch(() => returnObject.latestFilterEvent.value, () => {
|
||||
watch(returnObject.latestFilterEvent, () => {
|
||||
totalFilterUpdates++;
|
||||
});
|
||||
// act
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { nextTick, watch } from 'vue';
|
||||
import { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
import { UseCollectionStateStub } from '@tests/unit/shared/Stubs/UseCollectionStateStub';
|
||||
import { UseAutoUnsubscribedEventsStub } from '@tests/unit/shared/Stubs/UseAutoUnsubscribedEventsStub';
|
||||
import { UserSelectionStub } from '@tests/unit/shared/Stubs/UserSelectionStub';
|
||||
import { ScriptStub } from '@tests/unit/shared/Stubs/ScriptStub';
|
||||
import { CategoryCollectionStateStub } from '@tests/unit/shared/Stubs/CategoryCollectionStateStub';
|
||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { SelectedScriptStub } from '@tests/unit/shared/Stubs/SelectedScriptStub';
|
||||
|
||||
describe('useUserSelectionState', () => {
|
||||
describe('currentSelection', () => {
|
||||
it('initializes with correct selection', () => {
|
||||
// arrange
|
||||
const expectedSelection = new UserSelectionStub([new ScriptStub('initialSelectedScript')]);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(expectedSelection));
|
||||
// act
|
||||
const { returnObject } = runHook({
|
||||
useCollectionState: collectionStateStub,
|
||||
});
|
||||
// assert
|
||||
const actualSelection = returnObject.currentSelection.value;
|
||||
expect(actualSelection).to.equal(expectedSelection);
|
||||
});
|
||||
describe('once collection state is changed', () => {
|
||||
it('updated', () => {
|
||||
// arrange
|
||||
const initialSelection = new UserSelectionStub([new ScriptStub('initialSelectedScript')]);
|
||||
const changedSelection = new UserSelectionStub([new ScriptStub('changedSelectedScript')]);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(initialSelection));
|
||||
const { returnObject } = runHook({
|
||||
useCollectionState: collectionStateStub,
|
||||
});
|
||||
// act
|
||||
collectionStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelection(changedSelection),
|
||||
immediateOnly: false,
|
||||
});
|
||||
// assert
|
||||
const actualSelection = returnObject.currentSelection.value;
|
||||
expect(actualSelection).to.equal(changedSelection);
|
||||
});
|
||||
it('not updated when old state changes', async () => {
|
||||
// arrange
|
||||
const oldSelectionState = new UserSelectionStub([new ScriptStub('inOldState')]);
|
||||
const newSelectionState = new UserSelectionStub([new ScriptStub('inNewState')]);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(oldSelectionState));
|
||||
const { returnObject } = runHook({
|
||||
useCollectionState: collectionStateStub,
|
||||
});
|
||||
collectionStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelection(newSelectionState),
|
||||
immediateOnly: false,
|
||||
});
|
||||
let totalUpdates = 0;
|
||||
watch(returnObject.currentSelection, () => {
|
||||
totalUpdates++;
|
||||
});
|
||||
// act
|
||||
oldSelectionState.triggerSelectionChangedEvent([new SelectedScriptStub('newInOldState')]);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(totalUpdates).to.equal(0);
|
||||
});
|
||||
describe('triggers change', () => {
|
||||
it('with new selection reference', async () => {
|
||||
// arrange
|
||||
const oldSelection = new UserSelectionStub([]);
|
||||
const newSelection = new UserSelectionStub([]);
|
||||
const initialCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(oldSelection);
|
||||
const changedCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(newSelection);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(initialCollectionState);
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
let isChangeTriggered = false;
|
||||
watch(returnObject.currentSelection, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
collectionStateStub.triggerOnStateChange({
|
||||
newState: changedCollectionState,
|
||||
immediateOnly: false,
|
||||
});
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
it('with the same selection reference', async () => {
|
||||
// arrange
|
||||
const userSelection = new UserSelectionStub([new ScriptStub('sameScriptInSameReference')]);
|
||||
const initialCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(userSelection);
|
||||
const changedCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(userSelection);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(initialCollectionState);
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
let isChangeTriggered = false;
|
||||
watch(returnObject.currentSelection, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
collectionStateStub.triggerOnStateChange({
|
||||
newState: changedCollectionState,
|
||||
immediateOnly: false,
|
||||
});
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('once selection state is changed', () => {
|
||||
it('updated with same collection state', async () => {
|
||||
// arrange
|
||||
const initialScripts = [new ScriptStub('initialSelectedScript')];
|
||||
const changedScripts = [new SelectedScriptStub('changedSelectedScript')];
|
||||
const selectionState = new UserSelectionStub(initialScripts);
|
||||
const collectionState = new CategoryCollectionStateStub().withSelection(selectionState);
|
||||
const collectionStateStub = new UseCollectionStateStub().withState(collectionState);
|
||||
const { returnObject } = runHook({
|
||||
useCollectionState: collectionStateStub,
|
||||
});
|
||||
// act
|
||||
selectionState.triggerSelectionChangedEvent(changedScripts);
|
||||
await nextTick();
|
||||
// assert
|
||||
const actualSelection = returnObject.currentSelection.value;
|
||||
expect(actualSelection).to.equal(selectionState);
|
||||
});
|
||||
it('updated once collection state is changed', async () => {
|
||||
// arrange
|
||||
const changedScripts = [new SelectedScriptStub('changedSelectedScript')];
|
||||
const newSelectionState = new UserSelectionStub([new ScriptStub('initialSelectedScriptInNewCollection')]);
|
||||
const initialCollectionState = new CategoryCollectionStateStub().withSelectedScripts([new SelectedScriptStub('initialSelectedScriptInInitialCollection')]);
|
||||
const collectionStateStub = new UseCollectionStateStub().withState(initialCollectionState);
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
// act
|
||||
collectionStateStub.triggerOnStateChange({
|
||||
newState: new CategoryCollectionStateStub().withSelection(newSelectionState),
|
||||
immediateOnly: false,
|
||||
});
|
||||
newSelectionState.triggerSelectionChangedEvent(changedScripts);
|
||||
// assert
|
||||
const actualSelection = returnObject.currentSelection.value;
|
||||
expect(actualSelection).to.equal(newSelectionState);
|
||||
});
|
||||
describe('triggers change', () => {
|
||||
it('with new selected scripts array reference', async () => {
|
||||
// arrange
|
||||
const oldSelectedScriptsArrayReference = [];
|
||||
const newSelectedScriptsArrayReference = [];
|
||||
const userSelection = new UserSelectionStub(oldSelectedScriptsArrayReference)
|
||||
.withSelectedScripts(oldSelectedScriptsArrayReference);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(userSelection));
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
let isChangeTriggered = false;
|
||||
watch(returnObject.currentSelection, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
userSelection.triggerSelectionChangedEvent(newSelectedScriptsArrayReference);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
it('with same selected scripts array reference', async () => {
|
||||
// arrange
|
||||
const sharedSelectedScriptsReference = [];
|
||||
const userSelection = new UserSelectionStub([])
|
||||
.withSelectedScripts(sharedSelectedScriptsReference);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(new CategoryCollectionStateStub().withSelection(userSelection));
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
let isChangeTriggered = false;
|
||||
watch(returnObject.currentSelection, () => {
|
||||
isChangeTriggered = true;
|
||||
});
|
||||
// act
|
||||
userSelection.triggerSelectionChangedEvent(sharedSelectedScriptsReference);
|
||||
await nextTick();
|
||||
// assert
|
||||
expect(isChangeTriggered).to.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('modifyCurrentSelection', () => {
|
||||
it('should modify current state', () => {
|
||||
// arrange
|
||||
const { returnObject, collectionStateStub } = runHook();
|
||||
const expectedSelection = collectionStateStub.state.selection;
|
||||
let mutatedSelection: IUserSelection | undefined;
|
||||
const mutator: SelectionModifier = (selection) => {
|
||||
mutatedSelection = selection;
|
||||
};
|
||||
// act
|
||||
returnObject.modifyCurrentSelection(mutator);
|
||||
// assert
|
||||
expect(collectionStateStub.isStateModified()).to.equal(true);
|
||||
expect(mutatedSelection).to.equal(expectedSelection);
|
||||
});
|
||||
it('new state is modified once collection state is changed', async () => {
|
||||
// arrange
|
||||
const { returnObject, collectionStateStub } = runHook();
|
||||
const expectedSelection = new UserSelectionStub([]);
|
||||
const newCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(expectedSelection);
|
||||
let mutatedSelection: IUserSelection | undefined;
|
||||
const mutator: SelectionModifier = (selection) => {
|
||||
mutatedSelection = selection;
|
||||
};
|
||||
// act
|
||||
collectionStateStub.triggerOnStateChange({
|
||||
newState: newCollectionState,
|
||||
immediateOnly: false,
|
||||
});
|
||||
await nextTick();
|
||||
returnObject.modifyCurrentSelection(mutator);
|
||||
// assert
|
||||
expect(collectionStateStub.isStateModified()).to.equal(true);
|
||||
expect(mutatedSelection).to.equal(expectedSelection);
|
||||
});
|
||||
it('old state is not modified once collection state is changed', async () => {
|
||||
// arrange
|
||||
const oldState = new CategoryCollectionStateStub().withSelectedScripts([
|
||||
new SelectedScriptStub('scriptFromOldState'),
|
||||
]);
|
||||
const collectionStateStub = new UseCollectionStateStub()
|
||||
.withState(oldState);
|
||||
const { returnObject } = runHook({ useCollectionState: collectionStateStub });
|
||||
const expectedSelection = new UserSelectionStub([]);
|
||||
const newCollectionState = new CategoryCollectionStateStub()
|
||||
.withSelection(expectedSelection);
|
||||
let totalMutations = 0;
|
||||
const mutator: SelectionModifier = () => {
|
||||
totalMutations++;
|
||||
};
|
||||
// act
|
||||
collectionStateStub.triggerOnStateChange({
|
||||
newState: newCollectionState,
|
||||
immediateOnly: false,
|
||||
});
|
||||
await nextTick();
|
||||
returnObject.modifyCurrentSelection(mutator);
|
||||
// assert
|
||||
expect(totalMutations).to.equal(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function runHook(scenario?: {
|
||||
useCollectionState?: UseCollectionStateStub,
|
||||
}) {
|
||||
const collectionStateStub = scenario?.useCollectionState ?? new UseCollectionStateStub();
|
||||
const eventsStub = new UseAutoUnsubscribedEventsStub();
|
||||
const returnObject = useUserSelectionState(
|
||||
collectionStateStub.get(),
|
||||
eventsStub.get(),
|
||||
);
|
||||
return {
|
||||
returnObject,
|
||||
collectionStateStub,
|
||||
eventsStub,
|
||||
};
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export class EventSubscriptionCollectionStub
|
||||
args: [subscriptions],
|
||||
});
|
||||
// Not calling other methods to avoid registering method calls.
|
||||
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
|
||||
this.subscriptions.splice(0, this.subscriptions.length, ...subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,21 +50,46 @@ export class UseCollectionStateStub
|
||||
return this.currentState.value;
|
||||
}
|
||||
|
||||
public isStateModified(): boolean {
|
||||
const call = this.callHistory.find((c) => c.methodName === 'modifyCurrentState');
|
||||
return call !== undefined;
|
||||
}
|
||||
|
||||
public triggerImmediateStateChange(): void {
|
||||
this.triggerOnStateChange({
|
||||
newState: this.currentState.value,
|
||||
immediateOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
public triggerOnStateChange(scenario: {
|
||||
readonly newState: ICategoryCollectionState,
|
||||
readonly immediateOnly: boolean,
|
||||
}): void {
|
||||
this.currentState.value = scenario.newState;
|
||||
let calls = this.callHistory.filter((call) => call.methodName === 'onStateChange');
|
||||
let handlers = this.getRegisteredHandlers();
|
||||
if (scenario.immediateOnly) {
|
||||
calls = calls.filter((call) => call.args[1].immediate === true);
|
||||
handlers = handlers.filter((args) => args[1]?.immediate === true);
|
||||
}
|
||||
const handlers = calls.map((call) => call.args[0] as NewStateEventHandler);
|
||||
handlers.forEach(
|
||||
const callbacks = handlers.map((args) => args[0] as NewStateEventHandler);
|
||||
if (!callbacks.length) {
|
||||
throw new Error('No handler callbacks are registered to handle state change');
|
||||
}
|
||||
callbacks.forEach(
|
||||
(handler) => handler(scenario.newState, undefined),
|
||||
);
|
||||
}
|
||||
|
||||
public get(): ReturnType<typeof useCollectionState> {
|
||||
return {
|
||||
modifyCurrentState: this.modifyCurrentState.bind(this),
|
||||
modifyCurrentContext: this.modifyCurrentContext.bind(this),
|
||||
onStateChange: this.onStateChange.bind(this),
|
||||
currentContext: this.currentContext,
|
||||
currentState: this.currentState,
|
||||
};
|
||||
}
|
||||
|
||||
private onStateChange(
|
||||
handler: NewStateEventHandler,
|
||||
settings?: Partial<IStateCallbackSettings>,
|
||||
@@ -94,13 +119,14 @@ export class UseCollectionStateStub
|
||||
});
|
||||
}
|
||||
|
||||
public get(): ReturnType<typeof useCollectionState> {
|
||||
return {
|
||||
modifyCurrentState: this.modifyCurrentState.bind(this),
|
||||
modifyCurrentContext: this.modifyCurrentContext.bind(this),
|
||||
onStateChange: this.onStateChange.bind(this),
|
||||
currentContext: this.currentContext,
|
||||
currentState: this.currentState,
|
||||
};
|
||||
private getRegisteredHandlers(): readonly Parameters<ReturnType<typeof useCollectionState>['onStateChange']>[] {
|
||||
const calls = this.callHistory.filter((call) => call.methodName === 'onStateChange');
|
||||
return calls.map((handler) => {
|
||||
const [callback, settings] = handler.args;
|
||||
return [
|
||||
callback as NewStateEventHandler,
|
||||
settings as Partial<IStateCallbackSettings>,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
49
tests/unit/shared/Stubs/UseUserSelectionStateStub.ts
Normal file
49
tests/unit/shared/Stubs/UseUserSelectionStateStub.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { shallowRef } from 'vue';
|
||||
import type { SelectionModifier, useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
|
||||
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
|
||||
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
import { UserSelectionStub } from './UserSelectionStub';
|
||||
|
||||
export class UseUserSelectionStateStub
|
||||
extends StubWithObservableMethodCalls<ReturnType<typeof useUserSelectionState>> {
|
||||
private readonly currentSelection = shallowRef<IUserSelection>(
|
||||
new UserSelectionStub([]),
|
||||
);
|
||||
|
||||
private modifyCurrentSelection(mutator: SelectionModifier) {
|
||||
mutator(this.currentSelection.value);
|
||||
this.registerMethodCall({
|
||||
methodName: 'modifyCurrentSelection',
|
||||
args: [mutator],
|
||||
});
|
||||
}
|
||||
|
||||
public withUserSelection(userSelection: IUserSelection): this {
|
||||
this.currentSelection.value = userSelection;
|
||||
return this;
|
||||
}
|
||||
|
||||
public withSelectedScripts(selectedScripts: readonly SelectedScript[]): this {
|
||||
return this.withUserSelection(
|
||||
new UserSelectionStub(selectedScripts.map((s) => s.script))
|
||||
.withSelectedScripts(selectedScripts),
|
||||
);
|
||||
}
|
||||
|
||||
public get selection(): IUserSelection {
|
||||
return this.currentSelection.value;
|
||||
}
|
||||
|
||||
public isSelectionModified(): boolean {
|
||||
const modifyCall = this.callHistory.find((call) => call.methodName === 'modifyCurrentSelection');
|
||||
return modifyCall !== undefined;
|
||||
}
|
||||
|
||||
public get(): ReturnType<typeof useUserSelectionState> {
|
||||
return {
|
||||
currentSelection: this.currentSelection,
|
||||
modifyCurrentSelection: this.modifyCurrentSelection.bind(this),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,20 @@ export class UserSelectionStub
|
||||
return this;
|
||||
}
|
||||
|
||||
public isScriptAdded(scriptId: string): boolean {
|
||||
const call = this.callHistory.find(
|
||||
(c) => c.methodName === 'addSelectedScript' && c.args[0] === scriptId,
|
||||
);
|
||||
return call !== undefined;
|
||||
}
|
||||
|
||||
public isScriptRemoved(scriptId: string): boolean {
|
||||
const call = this.callHistory.find(
|
||||
(c) => c.methodName === 'removeSelectedScript' && c.args[0] === scriptId,
|
||||
);
|
||||
return call !== undefined;
|
||||
}
|
||||
|
||||
public areAllSelected(): boolean {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user