[OP-19543] Replace lodash null/empty helpers

Null/empty/type predicates move from the global `_` to native checks:
`isNil` -> `== null`, `isNaN` -> `Number.isNaN`, `defaultTo` -> `??`,
`compact` -> `filter(Boolean)`. `isEmpty`, `castArray`, `isObject` and
`reject` are replaced per call site with the equivalent native form for
the concrete collection type. The global `_` stays until the remaining
buckets land.

`compact` sites whose result feeds a non-nullable type use a narrowing
`(x): x is NonNullable<typeof x>` predicate, since `filter(Boolean)`
does not narrow in TypeScript.

https://community.openproject.org/wp/OP-19543
This commit is contained in:
Alexander Brandon Coles
2026-06-13 18:45:55 +01:00
parent 8d1cbe8000
commit 80a8353565
38 changed files with 70 additions and 73 deletions
@@ -186,7 +186,7 @@ export class ApiV3Service {
* @param projectIdentifier
*/
public withOptionalProject(projectIdentifier:string|number|null|undefined):ApiV3ProjectPaths|this {
if (_.isNil(projectIdentifier)) {
if (projectIdentifier == null) {
return this;
}
return this.projects.id(projectIdentifier);
@@ -100,7 +100,7 @@ export class ApiV3ResourceCollection<V, T extends ApiV3GettableResource<V>> exte
}
public withOptionalId(id?:string|number|null):this|T {
if (_.isNil(id)) {
if (id == null) {
return this;
}
return this.id(id);
@@ -26,7 +26,7 @@ export class SimpleResourceCollection<T = SimpleResource> {
* @param id
*/
public withOptionalId(id?:string|number):this|T {
if (_.isNil(id)) {
if (id == null) {
return this;
}
return this.id(id);
@@ -80,7 +80,7 @@ export class CurrentUserService {
.principalFilter$()
.pipe(
map((userFilter) => {
const filters:ApiV3ListFilter[] = _.compact([userFilter]);
const filters:ApiV3ListFilter[] = [userFilter].filter((x):x is NonNullable<typeof x> => Boolean(x));
if (projectContext) {
filters.push(['context', '=', [projectContext === 'global' || projectContext === 'projects' ? 'g' : `w${projectContext}`]]);
@@ -101,7 +101,7 @@ export class CurrentUserService {
* in the provided context.
*/
public hasCapabilities$(action:string|string[], projectContext:string|null):Observable<boolean> {
const actions = _.castArray(action);
const actions = Array.isArray(action) ? action : [action];
return this
.capabilities$(actions, projectContext)
.pipe(
@@ -118,7 +118,7 @@ export class CurrentUserService {
* has any of the required capabilities in the provided context.
*/
public hasAnyCapabilityOf$(actions:string|string[], projectContext:string|null):Observable<boolean> {
const actionsToFilter = _.castArray(actions);
const actionsToFilter = Array.isArray(actions) ? actions : [actions];
return this
.capabilities$(actionsToFilter, projectContext)
.pipe(
@@ -6,14 +6,14 @@ import { OpenprojectHalModuleHelpers } from 'core-app/features/hal/helpers/lazy-
import { HalSource } from 'core-app/features/hal/interfaces';
export function cloneHalResourceCollection<T extends HalResource>(values:T[]|undefined):T[] {
if (_.isNil(values)) {
if (values == null) {
return [];
}
return values.map((v) => v.$copy<T>());
}
export function cloneHalResource<T extends HalResource>(value:T|undefined):T|undefined {
if (_.isNil(value)) {
if (value == null) {
return value;
}
return value.$copy<T>();
@@ -38,7 +38,7 @@ export function initializeHalProperties<T extends HalResource>(halResourceServic
}
function asHalResource(value?:HalSource, loaded = true):HalResource|HalSource|undefined|null {
if (_.isNil(value)) {
if (value == null) {
return value;
}
@@ -122,7 +122,7 @@ export function initializeHalProperties<T extends HalResource>(halResourceServic
const sourceName = `_${name}`;
const sourceObj:any = halResource.$source[sourceName];
if (_.isObject(sourceObj)) {
if (typeof sourceObj === 'object' && sourceObj !== null) {
Object.keys(sourceObj).forEach((propName) => {
OpenprojectHalModuleHelpers.lazy((halResource)[instanceName],
propName,
@@ -147,7 +147,7 @@ export function initializeHalProperties<T extends HalResource>(halResourceServic
return element.map((source) => asHalResource(source, true));
}
if (_.isObject(element)) {
if (typeof element === 'object' && element !== null) {
_.each(element, (child:any, name:string) => {
if (child && (child._embedded || child._links)) {
OpenprojectHalModuleHelpers.lazy(element as any,
@@ -33,7 +33,7 @@ export namespace OpenprojectHalModuleHelpers {
property:string,
getter:() => any,
setter?:(value:any) => void):void {
if (_.isObject(obj)) {
if (typeof obj === 'object' && obj !== null) {
let done = false;
let value:any;
const config:any = {
@@ -216,9 +216,7 @@ export class HalResourceService {
* @returns {HalResource}
*/
public createHalResource<T extends HalResource = HalResource>(source:any, loaded = true):T {
if (_.isNil(source)) {
source = HalResource.getEmptyResource();
}
source ??= HalResource.getEmptyResource();
const type = source._type || 'HalResource';
return this.createHalResourceOfType<T>(type, source, loaded);
@@ -325,7 +325,7 @@ export class IanCenterService extends UntilDestroyedMixin {
const promise = this
.apiV3Service
.work_packages
.requireAll(_.compact(wpIds));
.requireAll(wpIds.filter(Boolean));
wpIds.forEach((id) => {
cache.clearAndLoad(
@@ -87,7 +87,7 @@ export class FilterToggledMultiselectValueComponent implements OnInit, AfterView
}
public setValues(val:HalResource[]|string[]|string|HalResource):void {
this.filter.values = _.castArray(val) as HalResource[]|string[];
this.filter.values = (Array.isArray(val) ? val : [val]) as HalResource[]|string[];
this.filterChanged.emit(this.filter);
this.cdRef.detectChanges();
}
@@ -140,7 +140,7 @@ export class WorkPackageFilterValues {
*/
private filterAlreadyApplied(change:WorkPackageChangeset|Record<string, unknown>, filter:{ id:string, values:unknown[] }):boolean {
const value:unknown = change instanceof WorkPackageChangeset ? change.projectedResource[filter.id] : change[filter.id];
const current = _.castArray(value);
const current = Array.isArray(value) ? value : [value];
for (let i = 0; i < filter.values.length; i++) {
for (let j = 0; j < current.length; j++) {
@@ -35,7 +35,7 @@ export class GroupedRowsBuilder extends RowsBuilder {
* The hierarchy builder is only applicable if the hierarchy mode is active
*/
public isApplicable(table:WorkPackageTable) {
return !_.isEmpty(this.groups);
return this.groups.length > 0;
}
/**
@@ -229,7 +229,7 @@ export class UrlParamsHelperService {
if (query.timelineVisible) {
paramsData.tv = query.timelineVisible;
if (!_.isEmpty(query.timelineLabels)) {
if (Object.keys(query.timelineLabels ?? {}).length > 0) {
paramsData.tll = JSON.stringify(query.timelineLabels);
}
@@ -82,7 +82,7 @@ export class WpRelationInlineAddExistingComponent {
};
public addExisting() {
if (_.isNil(this.selectedWpId)) {
if (this.selectedWpId == null) {
return;
}
@@ -109,7 +109,7 @@ export class WorkPackageRelationQueryComponent extends WorkPackageRelationQueryB
// When relations have changed, refresh this table
this.wpRelations.observe(this.workPackage.id!)
.pipe(
filter((val) => !_.isEmpty(val)),
filter((val) => !(val == null || (Array.isArray(val) ? val.length === 0 : Object.keys(val).length === 0))),
this.untilDestroyed(),
)
.subscribe(() => this.refreshTable());
@@ -75,7 +75,7 @@ export class WpTableConfigurationSortByTabComponent implements TabComponent, OnI
}
sortElements = sortElements.map((object) => this.getMatchingSort(object.column.href!, object.direction));
this.wpTableSortBy.update(_.compact(sortElements));
this.wpTableSortBy.update(sortElements.filter((x):x is NonNullable<typeof x> => Boolean(x)));
}
ngOnInit() {
@@ -32,6 +32,6 @@ export class OpTableActionsService {
*/
public render(workPackage:WorkPackageResource):HTMLElement[] {
const built = this.actions.map((factory) => factory(this.injector, workPackage).buildElement());
return _.compact(built);
return built.filter((x):x is NonNullable<typeof x> => Boolean(x));
}
}
@@ -85,7 +85,7 @@ export class TimelineCellRenderer {
public isEmpty(wp:WorkPackageResource) {
const start = moment(wp.startDate);
const due = moment(wp.dueDate);
const noStartAndDueValues = _.isNaN(start.valueOf()) && _.isNaN(due.valueOf());
const noStartAndDueValues = Number.isNaN(start.valueOf()) && Number.isNaN(due.valueOf());
return noStartAndDueValues;
}
@@ -246,21 +246,21 @@ export class TimelineCellRenderer {
let start = moment(change.projectedResource.startDate);
let due = moment(change.projectedResource.dueDate);
if (_.isNaN(start.valueOf()) && _.isNaN(due.valueOf())) {
if (Number.isNaN(start.valueOf()) && Number.isNaN(due.valueOf())) {
element.style.visibility = 'hidden';
} else {
element.style.visibility = 'visible';
}
// only start date, fade out bar to the right
if (_.isNaN(due.valueOf()) && !_.isNaN(start.valueOf())) {
if (Number.isNaN(due.valueOf()) && !Number.isNaN(start.valueOf())) {
// Set due date to today
due = moment();
bar.setAttribute('style', 'background-image: linear-gradient(90deg, rgba(255,255,255,0) 0%, #F1F1F1 100%) !important');
}
// only finish date, fade out bar to the left
if (_.isNaN(start.valueOf()) && !_.isNaN(due.valueOf())) {
if (Number.isNaN(start.valueOf()) && !Number.isNaN(due.valueOf())) {
start = due.clone();
bar.setAttribute('style', 'background-image: linear-gradient(90deg, #F1F1F1 0%, rgba(255,255,255,0) 80%) !important');
}
@@ -336,7 +336,7 @@ export class TimelineCellRenderer {
let start = moment(projection.startDate);
const due = moment(projection.dueDate);
start = _.isNaN(start.valueOf()) ? due.clone() : start;
start = Number.isNaN(start.valueOf()) ? due.clone() : start;
const offsetStart = start.diff(renderInfo.viewParams.dateDisplayStart, 'days');
@@ -349,8 +349,8 @@ export class TimelineCellRenderer {
let start = moment(projection.startDate);
let due = moment(projection.dueDate);
start = _.isNaN(start.valueOf()) ? due.clone() : start;
due = _.isNaN(due.valueOf()) ? start.clone() : due;
start = Number.isNaN(start.valueOf()) ? due.clone() : start;
due = Number.isNaN(due.valueOf()) ? start.clone() : due;
const offsetStart = start.diff(renderInfo.viewParams.dateDisplayStart, 'days');
const duration = due.diff(start, 'days') + 1;
@@ -461,7 +461,7 @@ export class TimelineCellRenderer {
element.style.width = calculatePositionValueForDayCount(viewParams, duration);
// ensure minimum width
if (!_.isNaN(start.valueOf()) || !_.isNaN(due.valueOf())) {
if (!Number.isNaN(start.valueOf()) || !Number.isNaN(due.valueOf())) {
const minWidth = _.max([renderInfo.viewParams.pixelPerDay, 2]);
element.style.minWidth = `${minWidth}px`;
}
@@ -31,7 +31,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
public isEmpty(wp:WorkPackageResource) {
const date = moment(wp.date);
return _.isNaN(date.valueOf());
return Number.isNaN(date.valueOf());
}
public canMoveDates(wp:WorkPackageResource) {
@@ -120,7 +120,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
const date = moment(renderInfo.change.projectedResource.date);
// abort if no date
if (_.isNaN(date.valueOf())) {
if (Number.isNaN(date.valueOf())) {
return false;
}
@@ -92,10 +92,10 @@ export class WorkPackageTimelineCell {
canConnectRelations():boolean {
const wp = this.latestRenderInfo.workPackage;
if (this.schemaCache.of(wp).isMilestone) {
return !_.isNil(wp.date);
return wp.date != null;
}
return !_.isNil(wp.startDate) || !_.isNil(wp.dueDate);
return wp.startDate != null || wp.dueDate != null;
}
public clear() {
@@ -130,7 +130,7 @@ export class WorkPackageTableTimelineRelations extends UntilDestroyedMixin imple
)
.subscribe((list) => {
// ... make sure that the corresponding relations are loaded ...
const wps = _.compact(list.map((row) => row.workPackageId));
const wps = list.map((row) => row.workPackageId).filter((x):x is NonNullable<typeof x> => Boolean(x));
void this.wpRelations.requireAll(wps);
});
@@ -161,7 +161,7 @@ export class WorkPackageTableTimelineRelations extends UntilDestroyedMixin imple
private renderWorkPackagesRelations(workPackageIds:string[]) {
workPackageIds.forEach((workPackageId) => {
const workPackageWithRelation = this.workPackagesWithRelations[workPackageId];
if (_.isNil(workPackageWithRelation)) {
if (workPackageWithRelation == null) {
return;
}
@@ -164,9 +164,9 @@ export function getTimeSlicesForHeader(vp:TimelineViewParameters,
const firstRest:[Moment, Moment] = rest.splice(0, 1)[0];
const lastRest:[Moment, Moment] = rest.pop()!;
const inViewportAndBoundaries = _.concat(
[firstRest].filter((e) => !_.isNil(e)),
[firstRest].filter((e) => e != null),
inViewport,
[lastRest].filter((e) => !_.isNil(e)),
[lastRest].filter((e) => e != null),
);
return {
@@ -175,7 +175,7 @@ export class WorkPackageViewColumnsService extends WorkPackageQueryStateService<
public setColumnsById(columnIds:string[]) {
const mapped = columnIds.map((id) => _.find(this.all, (c) => c.id === id));
this.setColumns(_.compact(mapped));
this.setColumns(mapped.filter((x):x is NonNullable<typeof x> => Boolean(x)));
}
/**
@@ -283,7 +283,7 @@ export class WorkPackageViewFiltersService extends WorkPackageQueryStateService<
const invisibleFilters = new Set(this.hidden);
invisibleFilters.delete('search');
return _.reject(this.current, (filter) => invisibleFilters.has(filter.id));
return this.current.filter((filter) => !invisibleFilters.has(filter.id));
}
/**
@@ -76,7 +76,7 @@ export class WorkPackageViewHighlightingService extends WorkPackageQueryStateSer
}
private filteredValue(value:WorkPackageViewHighlight):WorkPackageViewHighlight {
if (_.isEmpty(value.selectedAttributes)) {
if (!value.selectedAttributes?.length) {
value.selectedAttributes = undefined;
}
@@ -166,7 +166,7 @@ export class WorkPackageViewOrderService extends WorkPackageQueryStateService<Qu
const { value } = this.positions;
// Remove empty or stale values given we can reload them
if ((_.isEmpty(value) || this.positions.isValueOlderThan(60000))) {
if (((value == null || Object.keys(value).length === 0) || this.positions.isValueOlderThan(60000))) {
this.positions.clear('Clearing old positions value');
}
@@ -75,7 +75,7 @@ workPackage:WorkPackageResource,
}
// Only if any relations exist for this work package
if (_.isNil(relations)) {
if (relations == null) {
return;
}
@@ -105,7 +105,7 @@ this.relationsForColumn(workPackage, relations, column),
* @return The filtered relations
*/
public relationsForColumn(workPackage:WorkPackageResource, relations:RelationsStateValue|undefined, column:QueryColumn) {
if (_.isNil(relations)) {
if (relations == null) {
return [];
}
@@ -44,7 +44,7 @@ export class WorkPackageViewTimelineService extends WorkPackageQueryStateService
...this.defaultState,
visible: query.timelineVisible,
zoomLevel: query.timelineZoomLevel,
labels: query.timelineLabels,
labels: query.timelineLabels ?? this.defaultLabels,
};
}
@@ -90,7 +90,7 @@ export class WorkPackageViewTimelineService extends WorkPackageQueryStateService
}
public get labels() {
if (_.isEmpty(this.current.labels)) {
if (Object.keys(this.current.labels).length === 0) {
return this.defaultLabels;
}
@@ -354,7 +354,7 @@ export class OpAutocompleterComponent<T extends IAutocompleteItem = IAutocomplet
if (!this.model) {
return '';
} else if (Array.isArray(this.model)) {
const mappedValues = this.model.map((el) => (_.isObject(el) ? el[this.inputBindValue as 'id'] : el) as string);
const mappedValues = this.model.map((el) => ((typeof el === 'object' && el !== null) ? el[this.inputBindValue as 'id'] : el) as string);
return mappedValues.length > 0 ? mappedValues : [''];
} else {
return this.model[this.inputBindValue as 'id'] as string || '';
@@ -562,7 +562,7 @@ export class OpAutocompleterComponent<T extends IAutocompleteItem = IAutocomplet
protected defaultCompareWithFunction():null|((a:unknown, b:unknown) => boolean) {
return (a, b) => {
if (this.bindValue && !_.isObject(b)) {
if (this.bindValue && !(typeof b === 'object' && b !== null)) {
return (a as Record<string, unknown>)[this.bindValue] === b;
}
@@ -87,7 +87,7 @@ export function comparableDate(date?:DateOption):number|null {
export function setDates(dates:DateOption|DateOption[], datePicker:DatePicker, enforceDate?:Date):void {
const { currentMonth, currentYear, selectedDates } = datePicker.datepickerInstance;
const [newStart, newEnd] = _.castArray(dates);
const [newStart, newEnd] = Array.isArray(dates) ? dates : [dates];
const [selectedStart, selectedEnd] = selectedDates;
// In case the new times match the current times, do not try to update
@@ -206,7 +206,7 @@ export class OpWpDatePickerInstanceComponent extends UntilDestroyedMixin impleme
}
private currentDates():string[] {
const compactedDates = _.compact([this.startDateValue, this.dueDateValue]);
const compactedDates = [this.startDateValue, this.dueDateValue].filter((x):x is NonNullable<typeof x> => Boolean(x));
return this.timezoneService.utcDatesToISODateStrings(compactedDates);
}
@@ -246,7 +246,7 @@ export class OpWpDatePickerInstanceComponent extends UntilDestroyedMixin impleme
minDate: this.minDate,
} as flatpickr.Options.Options;
return _.omitBy(options, (v) => _.isNil(v));
return _.omitBy(options, (v) => v == null);
}
private onFlatpickrChange(dates:Date[], _datestr:string, _instance:flatpickr.Instance) {
@@ -459,7 +459,7 @@ export class ResourceChangeset<T extends HalResource = HalResource> {
protected getLinkedValue(val:any, fieldSchema:IFieldSchema) {
// Links should always be nullified as { href: null }, but
// this wasn't always the case, so ensure null values are returned as such.
if (_.isNil(val)) {
if (val == null) {
return { href: null };
}
@@ -30,7 +30,7 @@ import { cssClassCustomOption, DisplayField } from 'core-app/shared/components/f
export class ResourcesDisplayField extends DisplayField {
public isEmpty():boolean {
return _.isEmpty(this.value);
return this.value == null || (Array.isArray(this.value) ? this.value.length === 0 : Object.keys(this.value as Record<string, unknown>).length === 0);
}
public get stringValue():string[] {
@@ -101,7 +101,7 @@ export abstract class EditForm<T extends HalResource = HalResource> {
* Return whether this form has any active fields
*/
public hasActiveFields():boolean {
return !_.isEmpty(this.activeFields);
return Object.keys(this.activeFields).length > 0;
}
/**
@@ -110,7 +110,7 @@ export class MultiSelectEditFieldComponent extends EditFieldComponent implements
*/
public buildSelectedOption() {
const value:HalResource[] = this.resource[this.name];
return value ? _.castArray(value).map((val) => this.findValueOption(val)) : [];
return value ? (Array.isArray(value) ? value : [value]).map((val) => this.findValueOption(val)) : [];
}
public get selectedOption() {
@@ -135,7 +135,7 @@ export class MultiSelectEditFieldComponent extends EditFieldComponent implements
return option;
};
this.resource[this.name] = _.castArray(val).map((el) => mapper(el));
(this.resource as Record<string, ValueOption[]>)[this.name] = (Array.isArray(val) ? val : [val]).map((el) => mapper(el) as ValueOption);
}
public onOpen() {
@@ -100,14 +100,14 @@ export class ConfirmDialogModalComponent extends OpModalComponent {
this.options = (this.locals.options ?? {}) as ConfirmDialogOptions;
this.dangerHighlighting = _.defaultTo(this.options.dangerHighlighting, false);
this.showListData = _.defaultTo(this.options.showListData, false);
this.refreshOnCancel = _.defaultTo(this.options.refreshOnCancel, false);
this.listTitle = _.defaultTo(this.options.listTitle, '');
this.warningText = _.defaultTo(this.options.warningText, '');
this.passedData = _.defaultTo(this.options.passedData, []);
this.showClose = _.defaultTo(this.options.showClose, true);
this.divideContent = _.defaultTo(this.options.divideContent, false);
this.dangerHighlighting = (this.options.dangerHighlighting ?? false);
this.showListData = (this.options.showListData ?? false);
this.refreshOnCancel = (this.options.refreshOnCancel ?? false);
this.listTitle = (this.options.listTitle ?? '');
this.warningText = (this.options.warningText ?? '');
this.passedData = (this.options.passedData ?? []);
this.showClose = (this.options.showClose ?? true);
this.divideContent = (this.options.divideContent ?? false);
// override default texts and icons if any
this.text = _.defaults(this.options.text, this.text);
this.icon = _.defaults(this.options.icon, this.icon);
@@ -91,7 +91,7 @@ export default class ProjectLifeCycleFormController extends FormPreviewControlle
}
private updateFlatpickrCalendar() {
const dates:Date[] = _.compact(this.dateInputFields.map((field) => this.toDate(field.value)));
const dates:Date[] = this.dateInputFields.map((field) => this.toDate(field.value)).filter((x):x is NonNullable<typeof x> => Boolean(x));
const ignoreNonWorkingDays = false;
const mode = 'range';
@@ -29,7 +29,6 @@
*/
import { Controller } from '@hotwired/stimulus';
import { compact } from 'lodash';
export default class SortByConfigController extends Controller {
static targets = [
@@ -74,7 +73,7 @@ export default class SortByConfigController extends Controller {
return null;
});
return JSON.stringify(compact(filters));
return JSON.stringify(filters.filter(Boolean));
}
// Tries to find the parent form in the DOM. If present and the form contains a `page` field marked
@@ -270,12 +269,12 @@ export default class SortByConfigController extends Controller {
}
getAllSelectedFields(...excludedRows:HTMLElement[]):string[] {
return compact(this.inputRowTargets.map((row) => {
return this.inputRowTargets.map((row) => {
if (!excludedRows.includes(row)) {
return this.getSelectedField(row);
}
return null;
}));
}).filter((x):x is NonNullable<typeof x> => Boolean(x));
}
moveRowToBottom(row:HTMLElement):void {
@@ -250,7 +250,7 @@ export default class PreviewController extends DialogPreviewController {
}
private updateFlatpickrCalendar() {
const dates:Date[] = _.compact([this.currentStartDate, this.currentDueDate]);
const dates:Date[] = [this.currentStartDate, this.currentDueDate].filter((x):x is NonNullable<typeof x> => Boolean(x));
const ignoreNonWorkingDays = this.currentIgnoreNonWorkingDays;
const mode = this.mode();
@@ -271,7 +271,7 @@ export default class PreviewController extends DialogPreviewController {
return this.toDate(flatPickrDates[0]);
}
const fieldDates = _.compact([this.currentStartDate, this.currentDueDate])
const fieldDates = [this.currentStartDate, this.currentDueDate].filter((x):x is NonNullable<typeof x> => Boolean(x))
.map((date) => this.timezoneService.utcDateToISODateString(date));
const diff = _.difference(flatPickrDates, fieldDates);
return this.toDate(diff[0]);