Fix code highlighting and optimize category select
This commit introduces a batched debounce mechanism for managing user
selection state changes. It effectively reduces unnecessary processing
during rapid script checking, preventing multiple triggers for code
compilation and UI rendering.
Key improvements include:
- Enhanced performance, especially noticeable when selecting large
categories. This update resolves minor UI freezes experienced when
selecting categories with numerous scripts.
- Correction of a bug where the code area only highlighted the last
selected script when multiple scripts were chosen.
Other changes include:
- Timing functions:
- Create a `Timing` folder for `throttle` and the new
`batchedDebounce` functions.
- Move these functions to the application layer from the presentation
layer, reflecting their application-wide use.
- Refactor existing code for improved clarity, naming consistency, and
adherence to new naming conventions.
- Add missing unit tests.
- `UserSelection`:
- State modifications in `UserSelection` now utilize a singular object
inspired by the CQRS pattern, enabling batch updates and flexible
change configurations, thereby simplifying change management.
- Remove the `I` prefix from related interfaces to align with new coding
standards.
- Refactor related code for better testability in isolation with
dependency injection.
- Repository:
- Move repository abstractions to the application layer.
- Improve repository abstraction to combine `ReadonlyRepository` and
`MutableRepository` interfaces.
- E2E testing:
- Introduce E2E tests to validate the correct batch selection
behavior.
- Add a specialized data attribute in `TheCodeArea.vue` for improved
testability.
- Reorganize shared Cypress functions for a more idiomatic Cypress
approach.
- Improve test documentation with related information.
- `SelectedScript`:
- Create an abstraction for simplified testability.
- Introduce `SelectedScriptStub` in tests as a substitute for the
actual object.
This commit is contained in:
@@ -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<ScriptSelectionChangeCommand>;
|
||||
|
||||
export class DebouncedScriptSelection implements ScriptSelection {
|
||||
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
|
||||
|
||||
private readonly scripts: Repository<string, SelectedScript>;
|
||||
|
||||
public readonly processChanges: ScriptSelection['processChanges'];
|
||||
|
||||
constructor(
|
||||
private readonly collection: ICategoryCollection,
|
||||
selectedScripts: ReadonlyArray<SelectedScript>,
|
||||
debounce: DebounceFunction = batchedDebounce,
|
||||
) {
|
||||
this.scripts = new InMemoryRepository<string, SelectedScript>();
|
||||
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<string, SelectedScript>,
|
||||
desiredScripts: readonly IScript[],
|
||||
): string[] {
|
||||
return desiredScripts
|
||||
.filter((script) => !existingItems.exists(script.id))
|
||||
.map((script) => script.id);
|
||||
}
|
||||
|
||||
function getScriptIdsToBeDeselected(
|
||||
existingItems: ReadonlyRepository<string, SelectedScript>,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user