[OP-19544] Replace lodash set/dedup helpers

Set, dedup and difference helpers move from the global `_` to native
operations: `flatten` -> `flat()`, `uniq` -> `Array.from(new Set(...))`,
`without`/`difference` -> `filter` + `includes`, `uniqBy` ->
`filter` + `findIndex`, `differenceBy` -> `filter` + `some`. The global
`_` stays until the remaining buckets land.

`uniqBy` keeps the first occurrence of each key (via `findIndex`), as
lodash does — not the last, which a `Map`-based dedup would yield.

https://community.openproject.org/wp/OP-19544
This commit is contained in:
Alexander Brandon Coles
2026-06-13 18:50:47 +01:00
parent 8d1cbe8000
commit 8b292eabaf
25 changed files with 61 additions and 45 deletions
@@ -56,7 +56,7 @@ export class ApiV3RelationsPaths extends ApiV3ResourceCollection<RelationResourc
const chunks = _.chunk(workPackageIds, MAGIC_RELATION_SIZE);
return forkJoin(chunks.map((chunk) => this.loadInvolved(chunk)))
.pipe(
map((results) => _.flatten(results)),
map((results) => results.flat()),
);
}
@@ -70,7 +70,7 @@ export class ApiV3WorkPackagesPaths extends ApiV3Collection<WorkPackageResource,
return new Promise<undefined>((resolve, reject) => {
this
.loadCollectionsFor(_.uniq(ids))
.loadCollectionsFor(Array.from(new Set(ids)))
.then((pagedResults:WorkPackageCollectionResource[]) => {
_.each(pagedResults, (results) => {
if (results.schemas) {
@@ -94,20 +94,20 @@ export class ErrorResource extends HalResource {
}
public getInvolvedAttributes():string[] {
let columns = [];
let columns:ErrorResource[] = [];
if (this.details) {
columns = [{ details: this.details }];
columns = [{ details: this.details } as ErrorResource];
} else if (this.errors) {
columns = this.errors;
columns = this.errors as ErrorResource[];
}
return _.flatten(columns.map((resource:ErrorResource) => {
return columns.map((resource:ErrorResource):string => {
if (resource.errorIdentifier === v3ErrorIdentifierMultipleErrors) {
return this.extractMultiError(resource)[0];
}
return resource.details.attribute;
}));
return resource.details.attribute as string;
}).flat();
}
public getMessagesPerAttribute():Record<string, string[]> {
@@ -286,7 +286,7 @@ export class HalResource {
*/
public $embeddableKeys():string[] {
const properties = Object.keys(this.$source);
return _.without(properties, '_links', '_embedded', 'id');
return properties.filter((property) => !['_links', '_embedded', 'id'].includes(property));
}
/**
@@ -295,6 +295,6 @@ export class HalResource {
*/
public $linkableKeys():string[] {
const properties = Object.keys(this.$links);
return _.without(properties, 'self');
return properties.filter((property) => property !== 'self');
}
}
@@ -51,6 +51,6 @@ export class ProjectResource extends HalResource {
* Exclude the schema _link from the linkable Resources.
*/
public $linkableKeys():string[] {
return _.without(super.$linkableKeys(), 'schema');
return super.$linkableKeys().filter((key) => key !== 'schema');
}
}
@@ -56,7 +56,7 @@ export class TimeEntryResource extends HalResource {
* Exclude the schema _link from the linkable Resources.
*/
public $linkableKeys():string[] {
return _.without(super.$linkableKeys(), 'schema');
return super.$linkableKeys().filter((key) => key !== 'schema');
}
}
@@ -290,7 +290,7 @@ export class WorkPackageBaseResource extends HalResource {
* Exclude the schema _link from the linkable Resources.
*/
public $linkableKeys():string[] {
return _.without(super.$linkableKeys(), 'schema');
return super.$linkableKeys().filter((key) => key !== 'schema');
}
/**
@@ -69,6 +69,6 @@ export class InAppNotificationActorsLineComponent implements OnInit {
})
.filter((actor) => actor !== null) as PrincipalLike[];
this.actors = _.uniqBy(actors, (item) => item.href);
this.actors = actors.filter((item, index, self) => index === self.findIndex((other) => other.href === item.href));
}
}
@@ -133,7 +133,7 @@ export class WorkPackageCardDragAndDropService {
* Update current order
*/
private updateOrder(newOrder:string[]) {
newOrder = _.uniq(newOrder);
newOrder = Array.from(new Set(newOrder));
Promise
.all(newOrder.map((id) => this
@@ -132,7 +132,12 @@ export class HierarchyRenderPass extends PrimaryRenderPass {
// Append all new elements
elements = elements.concat(newElements);
// Remove duplicates (Regression #29652)
this.deferred[parent.id!] = _.uniqBy(elements, (el) => el.id!);
const seen = new Set<string>();
this.deferred[parent.id!] = elements.filter((el) => {
if (seen.has(el.id!)) { return false; }
seen.add(el.id!);
return true;
});
return true;
}
// Otherwise, continue the chain upwards
@@ -72,7 +72,7 @@ export class ChildRelationsRenderPass extends RelationsRenderPass {
}
private loadMissingTargets(ids:string[]) {
const uniqueIds = _.uniq(ids);
const uniqueIds = Array.from(new Set(ids));
if (uniqueIds.length === 0 || this.loadingMissingTargets) {
return;
@@ -168,7 +168,7 @@ export class DragAndDropTransformer {
* Update current rendered order
*/
private async updateRenderedOrder(order:string[]) {
order = _.uniq(order);
order = Array.from(new Set(order));
const mappedOrder = await Promise.all(
order.map(
@@ -69,7 +69,7 @@ export class WorkPackagesActivityService extends WorkPackageLinkedResourceCache<
}
protected sortedActivityList(activities:HalResource[], attr = 'createdAt'):HalResource[] {
const sorted = _.sortBy(_.flatten(activities), attr);
const sorted = _.sortBy(activities.flat(), attr);
if (this.isReversed) {
return sorted.reverse();
@@ -90,7 +90,7 @@ export class WpTableConfigurationSortByTabComponent implements TabComponent, OnI
// For whatever reason, even though the UI doesn't implement it,
// QuerySortByResources are doubled for each column (one for asc/desc direction)
this.allColumns = _.uniqBy(allColumns, 'href');
this.allColumns = allColumns.filter((col, index, self) => index === self.findIndex((other) => other.href === col.href));
this.getManualSortingOption();
@@ -122,7 +122,7 @@ export class WpTableConfigurationSortByTabComponent implements TabComponent, OnI
.filter((o) => o.column !== null)
.map((object:SortModalObject) => object.column);
this.availableColumns = _.sortBy(_.differenceBy(this.allColumns, usedColumns, 'href'), 'name');
this.availableColumns = _.sortBy(this.allColumns.filter((col) => !usedColumns.some((used) => used.href === col.href)), 'name');
}
public updateSortingMode(mode:SortingMode) {
@@ -120,7 +120,7 @@ export class WorkPackageTimelineCellsRenderer {
newCells.push(identifier);
});
_.difference(currentlyActive, newCells).forEach((identifier:string) => {
currentlyActive.filter((identifier) => !newCells.includes(identifier)).forEach((identifier:string) => {
this.cells[identifier].clear();
delete this.cells[identifier];
});
@@ -73,7 +73,7 @@ export class WorkPackageViewAdditionalElementsService {
this.requireWorkPackageShares(workPackageIds),
this.requireSumsSchema(results),
]).then((wpResults:string[][]) => {
this.loadAdditional(_.flatten(wpResults));
this.loadAdditional(wpResults.flat());
});
}
@@ -103,7 +103,7 @@ export class WorkPackageViewAdditionalElementsService {
.requireAll(rows)
.then(() => {
const ids = this.getInvolvedWorkPackages(rows.map((id) => this.wpRelations.state(id).value!));
return _.flatten(ids);
return ids.flat();
});
}
@@ -116,9 +116,7 @@ export class WorkPackageViewAdditionalElementsService {
return Promise.resolve([]);
}
const ids = _.flatten(
rows.map((el) => el.children?.map((child) => child.id!) || []),
);
const ids = rows.map((el) => el.children?.map((child) => child.id!) || []).flat();
return Promise.resolve(ids);
}
@@ -134,7 +132,7 @@ export class WorkPackageViewAdditionalElementsService {
}
const resultIds = rows.map((el:WorkPackageResource) => (el.id as string | number).toString());
const ids = _.flatten(rows.map((el) => el.ancestorIds))
const ids = rows.map((el) => el.ancestorIds).flat()
.filter((id) => !resultIds.includes(id));
return Promise.resolve(ids);
@@ -184,7 +182,7 @@ export class WorkPackageViewAdditionalElementsService {
map((elements) => {
const shares = elements as ShareResource[];
const sharedWpIds = _.uniq(shares.map((share) => share.entity.id!));
const sharedWpIds = Array.from(new Set(shares.map((share) => share.entity.id!)));
sharedWpIds.forEach((wpId) => {
this
@@ -65,7 +65,7 @@ export class WorkPackageViewColumnsService extends WorkPackageQueryStateService<
query.columns = cloneHalResourceCollection<QueryColumn>(toApply);
// We can avoid reloading even with relation columns if we only removed columns
const onlyRemoved = _.difference(newColumns, oldColumns).length === 0;
const onlyRemoved = newColumns.filter((column) => !oldColumns.includes(column)).length === 0;
// Reload the table visibly if adding relation or share columns.
return !onlyRemoved && (this.hasRelationColumns() || this.hasShareColumn());
@@ -283,7 +283,8 @@ export class WorkPackageViewColumnsService extends WorkPackageQueryStateService<
* Get columns not yet selected
*/
public get unused():QueryColumn[] {
return _.differenceBy(this.all, this.getColumns(), '$href');
const columns = this.getColumns();
return this.all.filter((column) => !columns.some((other) => other.$href === column.$href));
}
/**
@@ -313,7 +313,7 @@ export class WorkPackageViewFiltersService extends WorkPackageQueryStateService<
* Get all filters that are not in the current active set
*/
private remainingFilters(filters = this.rawFilters) {
return _.differenceBy(this.availableFilters, filters, (filter) => filter.id);
return this.availableFilters.filter((available) => !filters.some((filter) => filter.id === available.id));
}
isAvailable(el:QueryFilterInstanceResource):boolean {
@@ -91,7 +91,7 @@ export class WorkPackageViewHierarchyIdentationService {
// get the first element of the ancestor chain that workPackage is not in
const predecessor = await firstValueFrom(this.apiV3Service.work_packages.id(predecessorId).get());
const difference = _.difference(predecessor.ancestorIds, workPackage.ancestorIds);
const difference = predecessor.ancestorIds.filter((id) => !workPackage.ancestorIds.includes(id));
if (difference && difference.length > 0) {
newParentId = difference[0];
}
@@ -127,7 +127,15 @@ export class UserAutocompleterComponent extends OpAutocompleterComponent<IUserAu
.http
.get<IHALCollection<IUser>>(filteredURL.toString())
.pipe(
map((res) => _.uniqBy(res._embedded.elements, (el) => el._links.self?.href || el.id)),
map((res) => {
const seen = new Set<string|number>();
return res._embedded.elements.filter((el) => {
const key = el._links.self?.href || el.id;
if (seen.has(key)) { return false; }
seen.add(key);
return true;
});
}),
map((users) => {
const mapped:IUserAutocompleteItem[] = users.map((user) => {
return { id: user.id, name: user.name, href: user._links.self?.href, email: user.email };
@@ -307,11 +307,8 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl
filter((resource) => !!resource),
)
.subscribe((resource:HalResource&{ attachments:AttachmentCollectionResource }) => {
const missingAttachments = _.differenceBy(
this.attachments,
resource.attachments.elements,
(attachment:HalResource) => attachment.id,
);
const presentIds = new Set<string|null>(resource.attachments.elements.map((other:HalResource) => other.id));
const missingAttachments = this.attachments.filter((attachment:HalResource) => !presentIds.has(attachment.id));
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return
const removedUrls = missingAttachments.map((attachment) => attachment.downloadLocation.href);
@@ -327,7 +327,14 @@ export class SearchableProjectListService {
return forkJoin(extraFetches).pipe(
map((collections) => collections.map((collection) => collection._embedded.elements)),
map((collections) => projects.concat(...collections)),
map((allProjects) => _.uniqBy(allProjects, (p) => p.id)),
map((allProjects) => {
const seen = new Set<ID>();
return allProjects.filter((p) => {
if (seen.has(p.id)) { return false; }
seen.add(p.id);
return true;
});
}),
);
}
@@ -75,10 +75,10 @@ export class WorkPackageEmbeddedGraphComponent implements OnChanges {
}
private updateChartData() {
let uniqLabels = _.uniq(this.datasets.reduce((array, dataset) => {
let uniqLabels = Array.from(new Set(this.datasets.reduce((array, dataset) => {
const groups = (dataset.groups || []).map((group) => group.value) as any;
return array.concat(groups);
}, [])) as string[];
}, []))) as string[];
const labelCountMaps = this.datasets.map((dataset) => {
const countMap = (dataset.groups || []).reduce<any>((hash, group) => ({
@@ -563,11 +563,11 @@ export default class FiltersFormController extends Controller {
if (operator && this.daysOperators.includes(operator)) {
const dateValue = this.findTargetByName(filterName, this.daysTargets)?.value;
value = _.without([dateValue], '');
value = [dateValue].filter((v) => v !== '');
} else if (operator === this.onDateOperator) {
const dateValue = this.findTargetById(filterName, this.singleDayTargets)?.value;
value = _.without([dateValue], '');
value = [dateValue].filter((v) => v !== '');
} else if (operator === this.betweenDatesOperator) {
const rangeValue = this.findTargetById(filterName, this.dateRangeTargets)?.value;
const [fromValue, toValue] = rangeValue?.split(' - ') ?? [];
@@ -273,7 +273,7 @@ export default class PreviewController extends DialogPreviewController {
const fieldDates = _.compact([this.currentStartDate, this.currentDueDate])
.map((date) => this.timezoneService.utcDateToISODateString(date));
const diff = _.difference(flatPickrDates, fieldDates);
const diff = flatPickrDates.filter((date) => !fieldDates.includes(date));
return this.toDate(diff[0]);
}