diff --git a/docs/tests.md b/docs/tests.md index d6736080..2ab67659 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -81,8 +81,10 @@ These checks validate various qualities like runtime execution, building process - [`Stubs/`](./../tests/unit/shared/Stubs): Maintains stubs for component isolation, equipped with basic functionalities and, when necessary, spying or mocking capabilities. - [`./tests/integration/`](./../tests/integration/): Contains integration test files. - [`cypress.config.ts`](./../cypress.config.ts): Cypress (E2E tests) configuration file. +- [`cypress-dirs.json`](./../cypress-dirs.json): A central definition of directories used by Cypress, designed for reuse across different configurations. - [`./tests/e2e/`](./../tests/e2e/): Base Cypress folder, includes tests with `.cy.ts` extension. - - [`/support/e2e.ts`](./../tests/e2e/support/e2e.ts): Support file, runs before every single spec file. - [`/tsconfig.json`]: TypeScript configuration for file Cypress code, improves IDE support, recommended to have by official documentation. - *(git ignored)* `/videos`: Asset folder for videos taken during tests. - *(git ignored)* `/screenshots`: Asset folder for Screenshots taken during tests. + - [`/support/e2e.ts`](./../tests/e2e/support/e2e.ts): Support file, runs before every single test file. + - [`/support/interactions/`](./../tests/e2e/support/interactions/): Contains reusable functions for simulating user interactions, enhancing test readability and maintainability. diff --git a/src/application/Common/Timing/BatchedDebounce.ts b/src/application/Common/Timing/BatchedDebounce.ts new file mode 100644 index 00000000..6eb69cd1 --- /dev/null +++ b/src/application/Common/Timing/BatchedDebounce.ts @@ -0,0 +1,27 @@ +import { PlatformTimer } from './PlatformTimer'; +import { TimeoutType, Timer } from './Timer'; + +export function batchedDebounce( + callback: (batches: readonly T[]) => void, + waitInMs: number, + timer: Timer = PlatformTimer, +): (arg: T) => void { + let lastTimeoutId: TimeoutType | undefined; + let batches: Array = []; + + return (arg: T) => { + batches.push(arg); + + const later = () => { + callback(batches); + batches = []; + lastTimeoutId = undefined; + }; + + if (lastTimeoutId !== undefined) { + timer.clearTimeout(lastTimeoutId); + } + + lastTimeoutId = timer.setTimeout(later, waitInMs); + }; +} diff --git a/src/application/Common/Timing/PlatformTimer.ts b/src/application/Common/Timing/PlatformTimer.ts new file mode 100644 index 00000000..101d5203 --- /dev/null +++ b/src/application/Common/Timing/PlatformTimer.ts @@ -0,0 +1,7 @@ +import { Timer } from './Timer'; + +export const PlatformTimer: Timer = { + setTimeout: (callback, ms) => setTimeout(callback, ms), + clearTimeout: (timeoutId) => clearTimeout(timeoutId), + dateNow: () => Date.now(), +}; diff --git a/src/presentation/components/Shared/Throttle.ts b/src/application/Common/Timing/Throttle.ts similarity index 63% rename from src/presentation/components/Shared/Throttle.ts rename to src/application/Common/Timing/Throttle.ts index 87f8d948..74102fb4 100644 --- a/src/presentation/components/Shared/Throttle.ts +++ b/src/application/Common/Timing/Throttle.ts @@ -1,40 +1,24 @@ +import { Timer, TimeoutType } from './Timer'; +import { PlatformTimer } from './PlatformTimer'; + export type CallbackType = (..._: unknown[]) => void; export function throttle( callback: CallbackType, waitInMs: number, - timer: ITimer = NodeTimer, + timer: Timer = PlatformTimer, ): CallbackType { const throttler = new Throttler(timer, waitInMs, callback); return (...args: unknown[]) => throttler.invoke(...args); } -// Allows aligning with both NodeJs (NodeJs.Timeout) and Window type (number) -export type Timeout = ReturnType; - -export interface ITimer { - setTimeout: (callback: () => void, ms: number) => Timeout; - clearTimeout: (timeoutId: Timeout) => void; - dateNow(): number; -} - -const NodeTimer: ITimer = { - setTimeout: (callback, ms) => setTimeout(callback, ms), - clearTimeout: (timeoutId) => clearTimeout(timeoutId), - dateNow: () => Date.now(), -}; - -interface IThrottler { - invoke: CallbackType; -} - -class Throttler implements IThrottler { - private queuedExecutionId: Timeout | undefined; +class Throttler { + private queuedExecutionId: TimeoutType | undefined; private previouslyRun: number; constructor( - private readonly timer: ITimer, + private readonly timer: Timer, private readonly waitInMs: number, private readonly callback: CallbackType, ) { diff --git a/src/application/Common/Timing/Timer.ts b/src/application/Common/Timing/Timer.ts new file mode 100644 index 00000000..fe23b612 --- /dev/null +++ b/src/application/Common/Timing/Timer.ts @@ -0,0 +1,8 @@ +// Allows aligning with both NodeJs (NodeJs.Timeout) and Window type (number) +export type TimeoutType = ReturnType; + +export interface Timer { + setTimeout: (callback: () => void, ms: number) => TimeoutType; + clearTimeout: (timeoutId: TimeoutType) => void; + dateNow(): number; +} diff --git a/src/application/Context/State/CategoryCollectionState.ts b/src/application/Context/State/CategoryCollectionState.ts index da692d11..09efaf3a 100644 --- a/src/application/Context/State/CategoryCollectionState.ts +++ b/src/application/Context/State/CategoryCollectionState.ts @@ -4,23 +4,48 @@ import { UserFilter } from './Filter/UserFilter'; import { IUserFilter } from './Filter/IUserFilter'; import { ApplicationCode } from './Code/ApplicationCode'; import { UserSelection } from './Selection/UserSelection'; -import { IUserSelection } from './Selection/IUserSelection'; import { ICategoryCollectionState } from './ICategoryCollectionState'; import { IApplicationCode } from './Code/IApplicationCode'; +import { UserSelectionFacade } from './Selection/UserSelectionFacade'; export class CategoryCollectionState implements ICategoryCollectionState { public readonly os: OperatingSystem; public readonly code: IApplicationCode; - public readonly selection: IUserSelection; + public readonly selection: UserSelection; public readonly filter: IUserFilter; - public constructor(readonly collection: ICategoryCollection) { - this.selection = new UserSelection(collection, []); - this.code = new ApplicationCode(this.selection, collection.scripting); - this.filter = new UserFilter(collection); + public constructor( + public readonly collection: ICategoryCollection, + selectionFactory = DefaultSelectionFactory, + codeFactory = DefaultCodeFactory, + filterFactory = DefaultFilterFactory, + ) { + this.selection = selectionFactory(collection, []); + this.code = codeFactory(this.selection.scripts, collection.scripting); + this.filter = filterFactory(collection); this.os = collection.os; } } + +export type CodeFactory = ( + ...params: ConstructorParameters +) => IApplicationCode; + +const DefaultCodeFactory: CodeFactory = (...params) => new ApplicationCode(...params); + +export type SelectionFactory = ( + ...params: ConstructorParameters +) => UserSelection; + +const DefaultSelectionFactory: SelectionFactory = ( + ...params +) => new UserSelectionFacade(...params); + +export type FilterFactory = ( + ...params: ConstructorParameters +) => IUserFilter; + +const DefaultFilterFactory: FilterFactory = (...params) => new UserFilter(...params); diff --git a/src/application/Context/State/Code/ApplicationCode.ts b/src/application/Context/State/Code/ApplicationCode.ts index 91cf795f..a1c5d3f7 100644 --- a/src/application/Context/State/Code/ApplicationCode.ts +++ b/src/application/Context/State/Code/ApplicationCode.ts @@ -1,7 +1,7 @@ -import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; -import { IReadOnlyUserSelection } from '@/application/Context/State/Selection/IUserSelection'; import { EventSource } from '@/infrastructure/Events/EventSource'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; +import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; +import { ReadonlyScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection'; import { CodeChangedEvent } from './Event/CodeChangedEvent'; import { CodePosition } from './Position/CodePosition'; import { ICodeChangedEvent } from './Event/ICodeChangedEvent'; @@ -17,12 +17,12 @@ export class ApplicationCode implements IApplicationCode { private scriptPositions = new Map(); constructor( - userSelection: IReadOnlyUserSelection, + selection: ReadonlyScriptSelection, private readonly scriptingDefinition: IScriptingDefinition, private readonly generator: IUserScriptGenerator = new UserScriptGenerator(), ) { - this.setCode(userSelection.selectedScripts); - userSelection.changed.on((scripts) => { + this.setCode(selection.selectedScripts); + selection.changed.on((scripts) => { this.setCode(scripts); }); } diff --git a/src/application/Context/State/Code/Event/CodeChangedEvent.ts b/src/application/Context/State/Code/Event/CodeChangedEvent.ts index 681df926..0dbd6ca0 100644 --- a/src/application/Context/State/Code/Event/CodeChangedEvent.ts +++ b/src/application/Context/State/Code/Event/CodeChangedEvent.ts @@ -1,6 +1,6 @@ import { IScript } from '@/domain/IScript'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; -import { SelectedScript } from '../../Selection/SelectedScript'; +import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import { ICodeChangedEvent } from './ICodeChangedEvent'; export class CodeChangedEvent implements ICodeChangedEvent { @@ -36,7 +36,14 @@ export class CodeChangedEvent implements ICodeChangedEvent { } public getScriptPositionInCode(script: IScript): ICodePosition { - const position = this.scripts.get(script); + return this.getPositionById(script.id); + } + + private getPositionById(scriptId: string): ICodePosition { + const position = [...this.scripts.entries()] + .filter(([s]) => s.id === scriptId) + .map(([, pos]) => pos) + .at(0); if (!position) { throw new Error('Unknown script: Position could not be found for the script'); } diff --git a/src/application/Context/State/Code/Event/ICodeChangedEvent.ts b/src/application/Context/State/Code/Event/ICodeChangedEvent.ts index 570ebad8..ea05f0fb 100644 --- a/src/application/Context/State/Code/Event/ICodeChangedEvent.ts +++ b/src/application/Context/State/Code/Event/ICodeChangedEvent.ts @@ -3,9 +3,9 @@ import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePo export interface ICodeChangedEvent { readonly code: string; - addedScripts: ReadonlyArray; - removedScripts: ReadonlyArray; - changedScripts: ReadonlyArray; + readonly addedScripts: ReadonlyArray; + readonly removedScripts: ReadonlyArray; + readonly changedScripts: ReadonlyArray; isEmpty(): boolean; getScriptPositionInCode(script: IScript): ICodePosition; } diff --git a/src/application/Context/State/Code/Generation/IUserScript.ts b/src/application/Context/State/Code/Generation/IUserScript.ts index 596819fd..0c1ef825 100644 --- a/src/application/Context/State/Code/Generation/IUserScript.ts +++ b/src/application/Context/State/Code/Generation/IUserScript.ts @@ -1,7 +1,7 @@ -import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; +import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; export interface IUserScript { - code: string; - scriptPositions: Map; + readonly code: string; + readonly scriptPositions: Map; } diff --git a/src/application/Context/State/Code/Generation/IUserScriptGenerator.ts b/src/application/Context/State/Code/Generation/IUserScriptGenerator.ts index 23e47c90..808672f9 100644 --- a/src/application/Context/State/Code/Generation/IUserScriptGenerator.ts +++ b/src/application/Context/State/Code/Generation/IUserScriptGenerator.ts @@ -1,9 +1,10 @@ -import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; +import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import { IUserScript } from './IUserScript'; export interface IUserScriptGenerator { buildCode( selectedScripts: ReadonlyArray, - scriptingDefinition: IScriptingDefinition): IUserScript; + scriptingDefinition: IScriptingDefinition, + ): IUserScript; } diff --git a/src/application/Context/State/Code/Generation/UserScriptGenerator.ts b/src/application/Context/State/Code/Generation/UserScriptGenerator.ts index e50b4a28..1cab63c3 100644 --- a/src/application/Context/State/Code/Generation/UserScriptGenerator.ts +++ b/src/application/Context/State/Code/Generation/UserScriptGenerator.ts @@ -1,6 +1,6 @@ -import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; +import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import { CodePosition } from '../Position/CodePosition'; import { IUserScriptGenerator } from './IUserScriptGenerator'; import { IUserScript } from './IUserScript'; diff --git a/src/application/Context/State/ICategoryCollectionState.ts b/src/application/Context/State/ICategoryCollectionState.ts index f07497e4..815aa413 100644 --- a/src/application/Context/State/ICategoryCollectionState.ts +++ b/src/application/Context/State/ICategoryCollectionState.ts @@ -1,18 +1,18 @@ import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { IReadOnlyUserFilter, IUserFilter } from './Filter/IUserFilter'; -import { IReadOnlyUserSelection, IUserSelection } from './Selection/IUserSelection'; +import { ReadonlyUserSelection, UserSelection } from './Selection/UserSelection'; import { IApplicationCode } from './Code/IApplicationCode'; export interface IReadOnlyCategoryCollectionState { readonly code: IApplicationCode; readonly os: OperatingSystem; readonly filter: IReadOnlyUserFilter; - readonly selection: IReadOnlyUserSelection; + readonly selection: ReadonlyUserSelection; readonly collection: ICategoryCollection; } export interface ICategoryCollectionState extends IReadOnlyCategoryCollectionState { readonly filter: IUserFilter; - readonly selection: IUserSelection; + readonly selection: UserSelection; } diff --git a/src/application/Context/State/Selection/Category/CategorySelection.ts b/src/application/Context/State/Selection/Category/CategorySelection.ts new file mode 100644 index 00000000..12fadc5f --- /dev/null +++ b/src/application/Context/State/Selection/Category/CategorySelection.ts @@ -0,0 +1,11 @@ +import { ICategory } from '@/domain/ICategory'; +import { CategorySelectionChangeCommand } from './CategorySelectionChange'; + +export interface ReadonlyCategorySelection { + areAllScriptsSelected(category: ICategory): boolean; + isAnyScriptSelected(category: ICategory): boolean; +} + +export interface CategorySelection extends ReadonlyCategorySelection { + processChanges(action: CategorySelectionChangeCommand): void; +} diff --git a/src/application/Context/State/Selection/Category/CategorySelectionChange.ts b/src/application/Context/State/Selection/Category/CategorySelectionChange.ts new file mode 100644 index 00000000..a944a3f6 --- /dev/null +++ b/src/application/Context/State/Selection/Category/CategorySelectionChange.ts @@ -0,0 +1,15 @@ +type CategorySelectionStatus = { + readonly isSelected: true; + readonly isReverted: boolean; +} | { + readonly isSelected: false; +}; + +export interface CategorySelectionChange { + readonly categoryId: number; + readonly newStatus: CategorySelectionStatus; +} + +export interface CategorySelectionChangeCommand { + readonly changes: readonly CategorySelectionChange[]; +} diff --git a/src/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper.ts b/src/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper.ts new file mode 100644 index 00000000..33667251 --- /dev/null +++ b/src/application/Context/State/Selection/Category/ScriptToCategorySelectionMapper.ts @@ -0,0 +1,60 @@ +import { ICategory } from '@/domain/ICategory'; +import { ICategoryCollection } from '@/domain/ICategoryCollection'; +import { ScriptSelection } from '../Script/ScriptSelection'; +import { ScriptSelectionChange } from '../Script/ScriptSelectionChange'; +import { CategorySelection } from './CategorySelection'; +import { CategorySelectionChange, CategorySelectionChangeCommand } from './CategorySelectionChange'; + +export class ScriptToCategorySelectionMapper implements CategorySelection { + constructor( + private readonly scriptSelection: ScriptSelection, + private readonly collection: ICategoryCollection, + ) { + + } + + public areAllScriptsSelected(category: ICategory): boolean { + const { selectedScripts } = this.scriptSelection; + if (selectedScripts.length === 0) { + return false; + } + const scripts = category.getAllScriptsRecursively(); + if (selectedScripts.length < scripts.length) { + return false; + } + return scripts.every( + (script) => selectedScripts.some((selected) => selected.id === script.id), + ); + } + + public isAnyScriptSelected(category: ICategory): boolean { + const { selectedScripts } = this.scriptSelection; + if (selectedScripts.length === 0) { + return false; + } + return selectedScripts.some((s) => category.includes(s.script)); + } + + public processChanges(action: CategorySelectionChangeCommand): void { + const scriptChanges = action.changes.reduce((changes, change) => { + changes.push(...this.collectScriptChanges(change)); + return changes; + }, new Array()); + this.scriptSelection.processChanges({ + changes: scriptChanges, + }); + } + + private collectScriptChanges(change: CategorySelectionChange): ScriptSelectionChange[] { + const category = this.collection.getCategory(change.categoryId); + const scripts = category.getAllScriptsRecursively(); + const scriptsChangesInCategory = scripts + .map((script): ScriptSelectionChange => ({ + scriptId: script.id, + newStatus: { + ...change.newStatus, + }, + })); + return scriptsChangesInCategory; + } +} diff --git a/src/application/Context/State/Selection/IUserSelection.ts b/src/application/Context/State/Selection/IUserSelection.ts deleted file mode 100644 index b696bada..00000000 --- a/src/application/Context/State/Selection/IUserSelection.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IScript } from '@/domain/IScript'; -import { ICategory } from '@/domain/ICategory'; -import { IEventSource } from '@/infrastructure/Events/IEventSource'; -import { SelectedScript } from './SelectedScript'; - -export interface IReadOnlyUserSelection { - readonly changed: IEventSource>; - readonly selectedScripts: ReadonlyArray; - isSelected(scriptId: string): boolean; - areAllSelected(category: ICategory): boolean; - isAnySelected(category: ICategory): boolean; -} - -export interface IUserSelection extends IReadOnlyUserSelection { - removeAllInCategory(categoryId: number): void; - addOrUpdateAllInCategory(categoryId: number, revert: boolean): void; - addSelectedScript(scriptId: string, revert: boolean): void; - addOrUpdateSelectedScript(scriptId: string, revert: boolean): void; - removeSelectedScript(scriptId: string): void; - selectOnly(scripts: ReadonlyArray): void; - selectAll(): void; - deselectAll(): void; -} diff --git a/src/application/Context/State/Selection/Script/DebouncedScriptSelection.ts b/src/application/Context/State/Selection/Script/DebouncedScriptSelection.ts new file mode 100644 index 00000000..ffe73762 --- /dev/null +++ b/src/application/Context/State/Selection/Script/DebouncedScriptSelection.ts @@ -0,0 +1,171 @@ +import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository'; +import { IScript } from '@/domain/IScript'; +import { EventSource } from '@/infrastructure/Events/EventSource'; +import { ReadonlyRepository, Repository } from '@/application/Repository/Repository'; +import { ICategoryCollection } from '@/domain/ICategoryCollection'; +import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce'; +import { ScriptSelection } from './ScriptSelection'; +import { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange'; +import { SelectedScript } from './SelectedScript'; +import { UserSelectedScript } from './UserSelectedScript'; + +const DEBOUNCE_DELAY_IN_MS = 100; + +export type DebounceFunction = typeof batchedDebounce; + +export class DebouncedScriptSelection implements ScriptSelection { + public readonly changed = new EventSource>(); + + private readonly scripts: Repository; + + public readonly processChanges: ScriptSelection['processChanges']; + + constructor( + private readonly collection: ICategoryCollection, + selectedScripts: ReadonlyArray, + debounce: DebounceFunction = batchedDebounce, + ) { + this.scripts = new InMemoryRepository(); + for (const script of selectedScripts) { + this.scripts.addItem(script); + } + this.processChanges = debounce( + (batchedRequests: readonly ScriptSelectionChangeCommand[]) => { + const consolidatedChanges = batchedRequests.flatMap((request) => request.changes); + this.processScriptChanges(consolidatedChanges); + }, + DEBOUNCE_DELAY_IN_MS, + ); + } + + public isSelected(scriptId: string): boolean { + return this.scripts.exists(scriptId); + } + + public get selectedScripts(): readonly SelectedScript[] { + return this.scripts.getItems(); + } + + public selectAll(): void { + const scriptsToSelect = this.collection + .getAllScripts() + .filter((script) => !this.scripts.exists(script.id)) + .map((script) => new UserSelectedScript(script, false)); + if (scriptsToSelect.length === 0) { + return; + } + this.processChanges({ + changes: scriptsToSelect.map((script): ScriptSelectionChange => ({ + scriptId: script.id, + newStatus: { + isSelected: true, + isReverted: false, + }, + })), + }); + } + + public deselectAll(): void { + if (this.scripts.length === 0) { + return; + } + const selectedScriptIds = this.scripts.getItems().map((script) => script.id); + this.processChanges({ + changes: selectedScriptIds.map((scriptId): ScriptSelectionChange => ({ + scriptId, + newStatus: { + isSelected: false, + }, + })), + }); + } + + public selectOnly(scripts: readonly IScript[]): void { + if (scripts.length === 0) { + throw new Error('Provided script array is empty. To deselect all scripts, please use the deselectAll() method instead.'); + } + this.processChanges({ + changes: [ + ...getScriptIdsToBeDeselected(this.scripts, scripts) + .map((scriptId): ScriptSelectionChange => ({ + scriptId, + newStatus: { + isSelected: false, + }, + })), + ...getScriptIdsToBeSelected(this.scripts, scripts) + .map((scriptId): ScriptSelectionChange => ({ + scriptId, + newStatus: { + isSelected: true, + isReverted: false, + }, + })), + ], + }); + } + + private processScriptChanges(changes: readonly ScriptSelectionChange[]): void { + let totalChanged = 0; + for (const change of changes) { + totalChanged += this.applyChange(change); + } + if (totalChanged > 0) { + this.changed.notify(this.scripts.getItems()); + } + } + + private applyChange(change: ScriptSelectionChange): number { + const script = this.collection.getScript(change.scriptId); + if (change.newStatus.isSelected) { + return this.addOrUpdateScript(script.id, change.newStatus.isReverted); + } + return this.removeScript(script.id); + } + + private addOrUpdateScript(scriptId: string, revert: boolean): number { + const script = this.collection.getScript(scriptId); + const selectedScript = new UserSelectedScript(script, revert); + if (!this.scripts.exists(selectedScript.id)) { + this.scripts.addItem(selectedScript); + return 1; + } + const existingSelectedScript = this.scripts.getById(selectedScript.id); + if (equals(selectedScript, existingSelectedScript)) { + return 0; + } + this.scripts.addOrUpdateItem(selectedScript); + return 1; + } + + private removeScript(scriptId: string): number { + if (!this.scripts.exists(scriptId)) { + return 0; + } + this.scripts.removeItem(scriptId); + return 1; + } +} + +function getScriptIdsToBeSelected( + existingItems: ReadonlyRepository, + desiredScripts: readonly IScript[], +): string[] { + return desiredScripts + .filter((script) => !existingItems.exists(script.id)) + .map((script) => script.id); +} + +function getScriptIdsToBeDeselected( + existingItems: ReadonlyRepository, + desiredScripts: readonly IScript[], +): string[] { + return existingItems + .getItems() + .filter((existing) => !desiredScripts.some((script) => existing.id === script.id)) + .map((script) => script.id); +} + +function equals(a: SelectedScript, b: SelectedScript): boolean { + return a.script.equals(b.script.id) && a.revert === b.revert; +} diff --git a/src/application/Context/State/Selection/Script/ScriptSelection.ts b/src/application/Context/State/Selection/Script/ScriptSelection.ts new file mode 100644 index 00000000..f3d202b4 --- /dev/null +++ b/src/application/Context/State/Selection/Script/ScriptSelection.ts @@ -0,0 +1,17 @@ +import { IEventSource } from '@/infrastructure/Events/IEventSource'; +import { IScript } from '@/domain/IScript'; +import { SelectedScript } from './SelectedScript'; +import { ScriptSelectionChangeCommand } from './ScriptSelectionChange'; + +export interface ReadonlyScriptSelection { + readonly changed: IEventSource; + readonly selectedScripts: readonly SelectedScript[]; + isSelected(scriptId: string): boolean; +} + +export interface ScriptSelection extends ReadonlyScriptSelection { + selectOnly(scripts: readonly IScript[]): void; + selectAll(): void; + deselectAll(): void; + processChanges(action: ScriptSelectionChangeCommand): void; +} diff --git a/src/application/Context/State/Selection/Script/ScriptSelectionChange.ts b/src/application/Context/State/Selection/Script/ScriptSelectionChange.ts new file mode 100644 index 00000000..bc596d02 --- /dev/null +++ b/src/application/Context/State/Selection/Script/ScriptSelectionChange.ts @@ -0,0 +1,15 @@ +export type ScriptSelectionStatus = { + readonly isSelected: true; + readonly isReverted: boolean; +} | { + readonly isSelected: false; +}; + +export interface ScriptSelectionChange { + readonly scriptId: string; + readonly newStatus: ScriptSelectionStatus; +} + +export interface ScriptSelectionChangeCommand { + readonly changes: ReadonlyArray; +} diff --git a/src/application/Context/State/Selection/Script/SelectedScript.ts b/src/application/Context/State/Selection/Script/SelectedScript.ts new file mode 100644 index 00000000..ef1dd7f8 --- /dev/null +++ b/src/application/Context/State/Selection/Script/SelectedScript.ts @@ -0,0 +1,9 @@ +import { IEntity } from '@/infrastructure/Entity/IEntity'; +import { IScript } from '@/domain/IScript'; + +type ScriptId = IScript['id']; + +export interface SelectedScript extends IEntity { + readonly script: IScript; + readonly revert: boolean; +} diff --git a/src/application/Context/State/Selection/SelectedScript.ts b/src/application/Context/State/Selection/Script/UserSelectedScript.ts similarity index 50% rename from src/application/Context/State/Selection/SelectedScript.ts rename to src/application/Context/State/Selection/Script/UserSelectedScript.ts index e3966a56..b0d70d4b 100644 --- a/src/application/Context/State/Selection/SelectedScript.ts +++ b/src/application/Context/State/Selection/Script/UserSelectedScript.ts @@ -1,14 +1,17 @@ import { BaseEntity } from '@/infrastructure/Entity/BaseEntity'; import { IScript } from '@/domain/IScript'; +import { SelectedScript } from './SelectedScript'; -export class SelectedScript extends BaseEntity { +type SelectedScriptId = SelectedScript['id']; + +export class UserSelectedScript extends BaseEntity { constructor( public readonly script: IScript, public readonly revert: boolean, ) { super(script.id); if (revert && !script.canRevert()) { - throw new Error('cannot revert an irreversible script'); + throw new Error(`The script with ID '${script.id}' is not reversible and cannot be reverted.`); } } } diff --git a/src/application/Context/State/Selection/UserSelection.ts b/src/application/Context/State/Selection/UserSelection.ts index bba4d93d..c7b01964 100644 --- a/src/application/Context/State/Selection/UserSelection.ts +++ b/src/application/Context/State/Selection/UserSelection.ts @@ -1,164 +1,12 @@ -import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository'; -import { IScript } from '@/domain/IScript'; -import { EventSource } from '@/infrastructure/Events/EventSource'; -import { IRepository } from '@/infrastructure/Repository/IRepository'; -import { ICategory } from '@/domain/ICategory'; -import { ICategoryCollection } from '@/domain/ICategoryCollection'; -import { IUserSelection } from './IUserSelection'; -import { SelectedScript } from './SelectedScript'; +import { CategorySelection, ReadonlyCategorySelection } from './Category/CategorySelection'; +import { ReadonlyScriptSelection, ScriptSelection } from './Script/ScriptSelection'; -export class UserSelection implements IUserSelection { - public readonly changed = new EventSource>(); - - private readonly scripts: IRepository; - - constructor( - private readonly collection: ICategoryCollection, - selectedScripts: ReadonlyArray, - ) { - this.scripts = new InMemoryRepository(); - for (const script of selectedScripts) { - this.scripts.addItem(script); - } - } - - public areAllSelected(category: ICategory): boolean { - if (this.selectedScripts.length === 0) { - return false; - } - const scripts = category.getAllScriptsRecursively(); - if (this.selectedScripts.length < scripts.length) { - return false; - } - return scripts.every( - (script) => this.selectedScripts.some((selected) => selected.id === script.id), - ); - } - - public isAnySelected(category: ICategory): boolean { - if (this.selectedScripts.length === 0) { - return false; - } - return this.selectedScripts.some((s) => category.includes(s.script)); - } - - public removeAllInCategory(categoryId: number): void { - const category = this.collection.getCategory(categoryId); - const scriptsToRemove = category.getAllScriptsRecursively() - .filter((script) => this.scripts.exists(script.id)); - if (!scriptsToRemove.length) { - return; - } - for (const script of scriptsToRemove) { - this.scripts.removeItem(script.id); - } - this.changed.notify(this.scripts.getItems()); - } - - public addOrUpdateAllInCategory(categoryId: number, revert = false): void { - const scriptsToAddOrUpdate = this.collection - .getCategory(categoryId) - .getAllScriptsRecursively() - .filter( - (script) => !this.scripts.exists(script.id) - || this.scripts.getById(script.id).revert !== revert, - ) - .map((script) => new SelectedScript(script, revert)); - if (!scriptsToAddOrUpdate.length) { - return; - } - for (const script of scriptsToAddOrUpdate) { - this.scripts.addOrUpdateItem(script); - } - this.changed.notify(this.scripts.getItems()); - } - - public addSelectedScript(scriptId: string, revert: boolean): void { - const script = this.collection.getScript(scriptId); - const selectedScript = new SelectedScript(script, revert); - this.scripts.addItem(selectedScript); - this.changed.notify(this.scripts.getItems()); - } - - public addOrUpdateSelectedScript(scriptId: string, revert: boolean): void { - const script = this.collection.getScript(scriptId); - const selectedScript = new SelectedScript(script, revert); - this.scripts.addOrUpdateItem(selectedScript); - this.changed.notify(this.scripts.getItems()); - } - - public removeSelectedScript(scriptId: string): void { - this.scripts.removeItem(scriptId); - this.changed.notify(this.scripts.getItems()); - } - - public isSelected(scriptId: string): boolean { - return this.scripts.exists(scriptId); - } - - /** Get users scripts based on his/her selections */ - public get selectedScripts(): ReadonlyArray { - return this.scripts.getItems(); - } - - public selectAll(): void { - const scriptsToSelect = this.collection - .getAllScripts() - .filter((script) => !this.scripts.exists(script.id)) - .map((script) => new SelectedScript(script, false)); - if (scriptsToSelect.length === 0) { - return; - } - for (const script of scriptsToSelect) { - this.scripts.addItem(script); - } - this.changed.notify(this.scripts.getItems()); - } - - public deselectAll(): void { - if (this.scripts.length === 0) { - return; - } - const selectedScriptIds = this.scripts.getItems().map((script) => script.id); - for (const scriptId of selectedScriptIds) { - this.scripts.removeItem(scriptId); - } - this.changed.notify([]); - } - - public selectOnly(scripts: readonly IScript[]): void { - if (!scripts.length) { - throw new Error('Scripts are empty. Use deselectAll() if you want to deselect everything'); - } - let totalChanged = 0; - totalChanged += this.unselectMissingWithoutNotifying(scripts); - totalChanged += this.selectNewWithoutNotifying(scripts); - if (totalChanged > 0) { - this.changed.notify(this.scripts.getItems()); - } - } - - private unselectMissingWithoutNotifying(scripts: readonly IScript[]): number { - if (this.scripts.length === 0 || scripts.length === 0) { - return 0; - } - const existingItems = this.scripts.getItems(); - const missingIds = existingItems - .filter((existing) => !scripts.some((script) => existing.id === script.id)) - .map((script) => script.id); - for (const id of missingIds) { - this.scripts.removeItem(id); - } - return missingIds.length; - } - - private selectNewWithoutNotifying(scripts: readonly IScript[]): number { - const unselectedScripts = scripts - .filter((script) => !this.scripts.exists(script.id)) - .map((script) => new SelectedScript(script, false)); - for (const newScript of unselectedScripts) { - this.scripts.addItem(newScript); - } - return unselectedScripts.length; - } +export interface ReadonlyUserSelection { + readonly categories: ReadonlyCategorySelection; + readonly scripts: ReadonlyScriptSelection; +} + +export interface UserSelection extends ReadonlyUserSelection { + readonly categories: CategorySelection; + readonly scripts: ScriptSelection; } diff --git a/src/application/Context/State/Selection/UserSelectionFacade.ts b/src/application/Context/State/Selection/UserSelectionFacade.ts new file mode 100644 index 00000000..ef6cbfdf --- /dev/null +++ b/src/application/Context/State/Selection/UserSelectionFacade.ts @@ -0,0 +1,39 @@ +import { ICategoryCollection } from '@/domain/ICategoryCollection'; +import { CategorySelection } from './Category/CategorySelection'; +import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper'; +import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection'; +import { ScriptSelection } from './Script/ScriptSelection'; +import { UserSelection } from './UserSelection'; +import { SelectedScript } from './Script/SelectedScript'; + +export class UserSelectionFacade implements UserSelection { + public readonly categories: CategorySelection; + + public readonly scripts: ScriptSelection; + + constructor( + collection: ICategoryCollection, + selectedScripts: readonly SelectedScript[], + scriptsFactory = DefaultScriptsFactory, + categoriesFactory = DefaultCategoriesFactory, + ) { + this.scripts = scriptsFactory(collection, selectedScripts); + this.categories = categoriesFactory(this.scripts, collection); + } +} + +export type ScriptsFactory = ( + ...params: ConstructorParameters +) => ScriptSelection; + +const DefaultScriptsFactory: ScriptsFactory = ( + ...params +) => new DebouncedScriptSelection(...params); + +export type CategoriesFactory = ( + ...params: ConstructorParameters +) => CategorySelection; + +const DefaultCategoriesFactory: CategoriesFactory = ( + ...params +) => new ScriptToCategorySelectionMapper(...params); diff --git a/src/application/Repository/Repository.ts b/src/application/Repository/Repository.ts new file mode 100644 index 00000000..dd364978 --- /dev/null +++ b/src/application/Repository/Repository.ts @@ -0,0 +1,17 @@ +import { IEntity } from '@/infrastructure/Entity/IEntity'; + +export interface ReadonlyRepository> { + readonly length: number; + getItems(predicate?: (entity: TEntity) => boolean): readonly TEntity[]; + getById(id: TKey): TEntity; + exists(id: TKey): boolean; +} + +export interface MutableRepository> { + addItem(item: TEntity): void; + addOrUpdateItem(item: TEntity): void; + removeItem(id: TKey): void; +} + +export interface Repository> + extends ReadonlyRepository, MutableRepository { } diff --git a/src/infrastructure/Repository/IRepository.ts b/src/infrastructure/Repository/IRepository.ts deleted file mode 100644 index e7c14e0b..00000000 --- a/src/infrastructure/Repository/IRepository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IEntity } from '../Entity/IEntity'; - -export interface IRepository> { - readonly length: number; - getItems(predicate?: (entity: TEntity) => boolean): TEntity[]; - getById(id: TKey): TEntity; - addItem(item: TEntity): void; - addOrUpdateItem(item: TEntity): void; - removeItem(id: TKey): void; - exists(id: TKey): boolean; -} diff --git a/src/infrastructure/Repository/InMemoryRepository.ts b/src/infrastructure/Repository/InMemoryRepository.ts index af0da495..597fadb8 100644 --- a/src/infrastructure/Repository/InMemoryRepository.ts +++ b/src/infrastructure/Repository/InMemoryRepository.ts @@ -1,8 +1,8 @@ import { IEntity } from '../Entity/IEntity'; -import { IRepository } from './IRepository'; +import { Repository } from '../../application/Repository/Repository'; export class InMemoryRepository> -implements IRepository { +implements Repository { private readonly items: TEntity[]; constructor(items?: TEntity[]) { diff --git a/src/presentation/components/Code/TheCodeArea.vue b/src/presentation/components/Code/TheCodeArea.vue index 638b6390..16cc682f 100644 --- a/src/presentation/components/Code/TheCodeArea.vue +++ b/src/presentation/components/Code/TheCodeArea.vue @@ -3,8 +3,10 @@ v-non-collapsing @size-changed="sizeChanged()" > +
@@ -12,7 +14,7 @@