Skip to content
Open
4 changes: 2 additions & 2 deletions packages/components/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@labkey/components",
"version": "7.14.0",
"version": "7.14.1-jobActionsUiUpdate.1",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
4 changes: 4 additions & 0 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages

### version TBD
*Released*: TBD
- Update `FilterStatus` to optionally include an "Add Filter" button

### version 7.14.0
*Released*: 30 January 2026
- Update `withQueryModels` to track and cancel requests for `loadRows`, `loadSelections` and `loadTotalCount`
Expand Down
18 changes: 10 additions & 8 deletions packages/components/src/internal/url/AppURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,19 @@ export class AppURL {
}
}

const stringPart = parts[i].toString();
const newPart = encodeURIComponent(stringPart);

if (i === 0) {
if (stringPart.indexOf('/') === 0) {
basePath += newPart;
if (parts[i]) {
const stringPart = parts[i].toString();
const newPart = encodeURIComponent(stringPart);

if (i === 0) {
if (stringPart.indexOf('/') === 0) {
basePath += newPart;
} else {
basePath += '/' + newPart;
}
} else {
basePath += '/' + newPart;
}
} else {
basePath += '/' + newPart;
}
}

Expand Down
29 changes: 29 additions & 0 deletions packages/components/src/public/QueryModel/FilterStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('FilterStatus', () => {
};
const sortAction = {
action: new SortAction(),
value: 'sort',
};

function validate(valueCount: number, filterCount: number): void {
Expand Down Expand Up @@ -132,4 +133,32 @@ describe('FilterStatus', () => {
expect(document.querySelectorAll('.fa-close')).toHaveLength(0);
expect(document.querySelectorAll('.remove-all-filters')).toHaveLength(0);
});

test('with add filter', async () => {
render(
<FilterStatus
{...DEFAULT_PROPS}
actionValues={[filterAction1]}
lockReadOnlyForDelete
onAddFilterClick={jest.fn()}
/>
);
expect(document.querySelectorAll('.fa-table')).toHaveLength(0);
expect(document.querySelectorAll('.fa-search')).toHaveLength(0);
expect(document.querySelectorAll('.fa-filter')).toHaveLength(2);
expect(document.querySelectorAll('.remove-all-filters')).toHaveLength(0);
});

test('without actionValues, with add', async () => {
render(
<FilterStatus
{...DEFAULT_PROPS}
actionValues={undefined}
lockReadOnlyForDelete
onAddFilterClick={jest.fn()}
/>
);
expect(document.querySelectorAll('.filter-status-value')).toHaveLength(0);
expect(document.querySelectorAll('.fa-filter')).toHaveLength(1);
});
});
90 changes: 54 additions & 36 deletions packages/components/src/public/QueryModel/FilterStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,75 @@ import React, { FC, memo } from 'react';
import { ActionValue } from './grid/actions/Action';
import { Value } from './grid/Value';
import { filterActionValuesByType } from './grid/utils';
import classNames from 'classnames';

interface Props {
actionValues: ActionValue[];
lockReadOnlyForDelete?: boolean;
onAddFilterClick?: () => void;
onClick: (actionValue: ActionValue, event: any) => void;
onRemove: (actionValueIndex: number, event: any) => void;
onRemoveAll?: () => void;
}

export const FilterStatus: FC<Props> = memo(props => {
const { actionValues, onClick, onRemove, onRemoveAll, lockReadOnlyForDelete } = props;
const showRemoveAll = filterActionValuesByType(actionValues, 'filter', lockReadOnlyForDelete).length > 1;
const { actionValues, onClick, onRemove, onRemoveAll, lockReadOnlyForDelete, onAddFilterClick } = props;
const filterCount = actionValues?.filter(a => a.action.keyword === 'filter').length;
const showRemoveAll = actionValues
? filterActionValuesByType(actionValues, 'filter', lockReadOnlyForDelete).length > 1
: false;

return (
<div className="grid-panel__filter-status">
{actionValues
.sort((a, b) => {
// sort the view actions to the front
if (a.action.keyword !== b.action.keyword) {
return a.action.keyword === 'view' ? -1 : b.action.keyword === 'view' ? 1 : 0;
}
{actionValues &&
actionValues
.sort((a, b) => {
// sort the view actions to the front
if (a.action.keyword !== b.action.keyword) {
return a.action.keyword === 'view' ? -1 : b.action.keyword === 'view' ? 1 : 0;
}

// then sort by filter display value
const aDisplayValue = a.displayValue ?? a.value;
const bDisplayValue = b.displayValue ?? b.value;
return aDisplayValue > bDisplayValue ? 1 : aDisplayValue < bDisplayValue ? -1 : 0;
})
.map((actionValue, index) => {
// loop over all actionValues so that the index remains consistent, but don't show sort actions
if (actionValue.action.keyword === 'sort') {
return null;
}
// then sort by filter display value
const aDisplayValue = a.displayValue ?? a.value;
const bDisplayValue = b.displayValue ?? b.value;
return aDisplayValue > bDisplayValue ? 1 : aDisplayValue < bDisplayValue ? -1 : 0;
})
.map((actionValue, index) => {
// loop over all actionValues so that the index remains consistent, but don't show sort actions
if (actionValue.action.keyword === 'sort') {
return null;
}

// only FilterActions can be edited via click
const _onClick = actionValue.action.keyword === 'filter' ? onClick : undefined;
// search and filter actions can be removed via click
const _onRemove =
actionValue.action.keyword === 'filter' || actionValue.action.keyword === 'search'
? onRemove
: undefined;
// only FilterActions can be edited via click
const _onClick = actionValue.action.keyword === 'filter' ? onClick : undefined;
// search and filter actions can be removed via click
const _onRemove =
actionValue.action.keyword === 'filter' || actionValue.action.keyword === 'search'
? onRemove
: undefined;

return (
<Value
actionValue={actionValue}
index={index}
key={index}
lockReadOnlyForDelete={lockReadOnlyForDelete}
onClick={_onClick}
onRemove={_onRemove}
/>
);
})}
return (
<Value
actionValue={actionValue}
index={index}
key={index}
lockReadOnlyForDelete={lockReadOnlyForDelete}
onClick={_onClick}
onRemove={_onRemove}
/>
);
})}

{onAddFilterClick && (
<button
className={classNames('btn btn-default', { 'margin-left': filterCount > 0 })}
onClick={onAddFilterClick}
type="button"
>
<span className="fa fa-filter grid-panel__menu-icon" />
Add Filter
</button>
)}
{onRemoveAll && showRemoveAll && (
<a className="remove-all-filters" onClick={onRemoveAll}>
Remove all
Expand All @@ -63,3 +80,4 @@ export const FilterStatus: FC<Props> = memo(props => {
</div>
);
});
FilterStatus.displayName = 'FilterStatus';
Loading