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:
@@ -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) {
|
||||
if (type === SelectionType.Custom) {
|
||||
throw new Error('cannot select custom type');
|
||||
}
|
||||
const selector = selectors.get(type);
|
||||
selector.select(this.state);
|
||||
}
|
||||
|
||||
public getCurrentSelectionType(): SelectionType {
|
||||
for (const [type, selector] of selectors.entries()) {
|
||||
if (selector.isSelected(this.state)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return SelectionType.Custom;
|
||||
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(context);
|
||||
}
|
||||
|
||||
interface ISingleTypeHandler {
|
||||
isSelected: (state: IReadOnlyCategoryCollectionState) => boolean;
|
||||
select: (state: ICategoryCollectionState) => void;
|
||||
export function getCurrentSelectionType(context: SelectionCheckContext): SelectionType {
|
||||
for (const [type, selector] of selectors.entries()) {
|
||||
if (selector.isSelected(context)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return SelectionType.Custom;
|
||||
}
|
||||
|
||||
const selectors = new Map<SelectionType, ISingleTypeHandler>([
|
||||
export interface SelectionCheckContext {
|
||||
readonly selection: IReadOnlyUserSelection;
|
||||
readonly collection: ICategoryCollection;
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
});
|
||||
}, { immediate: true });
|
||||
|
||||
function subscribeAndUpdateSelections(
|
||||
state: ICategoryCollectionState,
|
||||
): IEventSubscription {
|
||||
return state.selection.changed.on(() => updateSelections());
|
||||
}
|
||||
const currentSelectionType = computed<SelectionType>({
|
||||
get: () => getCurrentSelectionType({
|
||||
selection: currentSelection.value,
|
||||
collection: currentCollection.value,
|
||||
}),
|
||||
set: (type: SelectionType) => {
|
||||
selectType(type);
|
||||
},
|
||||
});
|
||||
|
||||
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"
|
||||
/>
|
||||
<AppIcon
|
||||
icon="battery-full"
|
||||
v-if="areAllChildrenSelected"
|
||||
/>
|
||||
</div>
|
||||
<CardSelectionIndicator
|
||||
class="card__inner__selection_indicator"
|
||||
:categoryId="categoryId"
|
||||
/>
|
||||
</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> {
|
||||
|
||||
Reference in New Issue
Block a user