Skip to content

Commit 559557c

Browse files
committed
Issue 53036: LKSM: Aliquot registration event in timeline polish
1 parent 7bddb03 commit 559557c

9 files changed

Lines changed: 192 additions & 14 deletions

File tree

packages/components/releaseNotes/components.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# @labkey/components
22
Components, models, actions, and utility functions for LabKey applications and pages
33

4+
### version 6.X
5+
*Released*: X September 2025
6+
- Issue 53036: LKSM: Aliquot registration event in timeline polish
7+
- Display "inherited" instead of NA for aliquot's parent fields in timeline view
8+
49
### version 6.62.3
510
*Released*: 18 September 2025
611
- Workflow: Change Required Template Fields to be Required at the start of a Job

packages/components/src/internal/components/auditlog/AuditDetails.test.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { renderWithAppContext } from '../../test/reactTestLibraryHelpers';
99
import { AuditDetails } from './AuditDetails';
1010

1111
import { AuditDetailsModel } from './models';
12+
import { AUDIT_DETAIL_FIELD_VALUE_INHERITED } from './constants';
1213

1314
describe('AuditDetails', () => {
1415
test('default props, empty', () => {
@@ -142,4 +143,89 @@ describe('AuditDetails', () => {
142143
expect(document.querySelector('.panel-body').textContent).toBe('afile.txtnew-1.txt');
143144
expect(document.querySelector('.original-value-icon')).toBeInTheDocument();
144145
});
146+
147+
test('with inheritedFieldMsg, without inherited value', () => {
148+
renderWithAppContext(
149+
<AuditDetails
150+
changeDetails={AuditDetailsModel.create({
151+
oldData: { a: 1 },
152+
newData: { a: 2 },
153+
})}
154+
inheritedFieldMsg="This value is inherited from a parent folder."
155+
rowId={1}
156+
user={TEST_USER_APP_ADMIN}
157+
/>,
158+
{ serverContext: { user: TEST_USER_APP_ADMIN } }
159+
);
160+
expect(document.querySelector('.panel-body').textContent).toBe('a12');
161+
expect(document.querySelectorAll('.fa-info-circle')).toHaveLength(0);
162+
});
163+
164+
test('with inherited value', () => {
165+
renderWithAppContext(
166+
<AuditDetails
167+
changeDetails={AuditDetailsModel.create({
168+
newData: { a: AUDIT_DETAIL_FIELD_VALUE_INHERITED },
169+
oldData: {}
170+
})}
171+
inheritedFieldMsg="This value is inherited from a parent folder."
172+
rowId={1}
173+
user={TEST_USER_APP_ADMIN}
174+
/>,
175+
{ serverContext: { user: TEST_USER_APP_ADMIN } }
176+
);
177+
expect(document.querySelector('.panel-body').textContent).toBe('aInherited');
178+
expect(document.querySelectorAll('.fa-info-circle')).toHaveLength(1);
179+
});
180+
181+
test('with inherited value, no inheritedFieldMsg', () => {
182+
renderWithAppContext(
183+
<AuditDetails
184+
changeDetails={AuditDetailsModel.create({
185+
newData: { a: AUDIT_DETAIL_FIELD_VALUE_INHERITED },
186+
oldData: {}
187+
})}
188+
rowId={1}
189+
user={TEST_USER_APP_ADMIN}
190+
/>,
191+
{ serverContext: { user: TEST_USER_APP_ADMIN } }
192+
);
193+
expect(document.querySelector('.panel-body').textContent).toBe('aInherited');
194+
expect(document.querySelectorAll('.fa-info-circle')).toHaveLength(0);
195+
});
196+
197+
test('with inherited value, with oldData', () => {
198+
renderWithAppContext(
199+
<AuditDetails
200+
changeDetails={AuditDetailsModel.create({
201+
oldData: { a: 1 },
202+
newData: { a: AUDIT_DETAIL_FIELD_VALUE_INHERITED },
203+
})}
204+
inheritedFieldMsg="This value is inherited from a parent folder."
205+
rowId={1}
206+
user={TEST_USER_APP_ADMIN}
207+
/>,
208+
{ serverContext: { user: TEST_USER_APP_ADMIN } }
209+
);
210+
expect(document.querySelector('.panel-body').textContent).toBe('a1Inherited');
211+
expect(document.querySelectorAll('.fa-info-circle')).toHaveLength(1);
212+
});
213+
214+
test('with inherited value, with oldData inherited', () => {
215+
renderWithAppContext(
216+
<AuditDetails
217+
changeDetails={AuditDetailsModel.create({
218+
oldData: { a: AUDIT_DETAIL_FIELD_VALUE_INHERITED },
219+
newData: { a: 1 },
220+
})}
221+
inheritedFieldMsg="This value is inherited from a parent folder."
222+
rowId={1}
223+
user={TEST_USER_APP_ADMIN}
224+
/>,
225+
{ serverContext: { user: TEST_USER_APP_ADMIN } }
226+
);
227+
expect(document.querySelector('.panel-body').textContent).toBe('aInherited1');
228+
expect(document.querySelectorAll('.fa-info-circle')).toHaveLength(0);
229+
});
230+
145231
});

packages/components/src/internal/components/auditlog/AuditDetails.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { UserLink } from '../user/UserLink';
1515
import { getEventDataValueDisplay } from './utils';
1616
import { AuditDetailsModel } from './models';
1717
import { LabelHelpTip } from '../base/LabelHelpTip';
18+
import { AUDIT_DETAIL_FIELD_VALUE_INHERITED } from './constants';
1819

1920
interface Props extends PropsWithChildren {
2021
changeDetails?: AuditDetailsModel;
@@ -26,6 +27,7 @@ interface Props extends PropsWithChildren {
2627
summary?: string;
2728
title?: string;
2829
user: User;
30+
inheritedFieldMsg?: string;
2931
}
3032

3133
export class AuditDetails extends Component<Props> {
@@ -38,9 +40,12 @@ export class AuditDetails extends Component<Props> {
3840
return ['created by', 'createdby', 'modified by', 'modifiedby'].indexOf(field.toLowerCase()) > -1;
3941
}
4042

41-
getValueDisplay = (field: string, value: string, hasProvidedValues: boolean): any => {
43+
getValueDisplay = (field: string, value: string, hasProvidedValues: boolean, isInherited?: boolean): any => {
4244
const { fieldValueRenderer } = this.props;
4345

46+
if (isInherited)
47+
return <span className="timeline-inherited-data display-light">Inherited</span>;
48+
4449
let displayVal: any = value;
4550
if (value == null || value === '') displayVal = 'NA';
4651

@@ -62,12 +67,15 @@ export class AuditDetails extends Component<Props> {
6267
isUpdate: boolean,
6368
isInsert: boolean
6469
): React.ReactNode {
65-
const { user } = this.props;
70+
const { user, inheritedFieldMsg } = this.props;
6671

6772
if (!user.isSignedIn && AuditDetails.isUserFieldLabel(field)) return null;
6873

69-
const oldValue = this.getValueDisplay(field, oldVal, !!(providedVal || providedDeltaVal));
70-
const newValue = this.getValueDisplay(field, newVal, !!(providedVal || providedDeltaVal));
74+
const oldValue = this.getValueDisplay(field, oldVal, !!(providedVal || providedDeltaVal), oldVal === AUDIT_DETAIL_FIELD_VALUE_INHERITED);
75+
76+
const isInherited = newVal === AUDIT_DETAIL_FIELD_VALUE_INHERITED;
77+
78+
const newValue = this.getValueDisplay(field, newVal, !!(providedVal || providedDeltaVal), isInherited);
7179
const changed = oldValue !== newValue;
7280
const providedVals = [];
7381
if (providedDeltaVal) {
@@ -89,6 +97,14 @@ export class AuditDetails extends Component<Props> {
8997
<div className="ws-pre-wrap">{providedVals}</div>
9098
</LabelHelpTip>
9199
)}
100+
{(isInherited && !!inheritedFieldMsg )&& (
101+
<LabelHelpTip
102+
iconComponent={<i className="fa fa-info-circle left-padding" />}
103+
placement="top"
104+
>
105+
<div className="ws-pre-wrap">{inheritedFieldMsg}</div>
106+
</LabelHelpTip>
107+
)}
92108
</span>
93109
</div>
94110
<div className="left-padding right-padding">

packages/components/src/internal/components/auditlog/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,5 @@ export const COMMON_AUDIT_QUERIES: AuditQuery[] = [
120120
];
121121

122122
export const EXPERIMENT_AUDIT_EVENT = 'experimentauditevent';
123+
124+
export const AUDIT_DETAIL_FIELD_VALUE_INHERITED = '$$aliquot-inherited-field$$';

packages/components/src/internal/components/auditlog/models.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { fromJS, List, Map, Record } from 'immutable';
66

77
import { ASSAYS_KEY, SAMPLES_KEY } from '../../app/constants';
8+
import { getAuditDetailMap } from './utils';
89

910
export class AuditDetailsModel extends Record({
1011
rowId: undefined,
@@ -101,7 +102,7 @@ export class TimelineEventModel extends Record({
101102
}
102103

103104
// timezoneStr used for jest test only, to accommodate teamcity timezone difference
104-
static create(raw: any, timezoneStr?: string): TimelineEventModel {
105+
static create(raw: any, timezoneStr?: string, inheritedFields?: string[]): TimelineEventModel {
105106
const fields = {} as TimelineEventModel;
106107
fields.rowId = raw['rowId'];
107108
fields.eventType = raw['eventType'];
@@ -126,8 +127,8 @@ export class TimelineEventModel extends Record({
126127
fields.metadata = fromJS(metaRows);
127128
}
128129

129-
if (raw.oldData) fields.oldData = fromJS(raw.oldData);
130-
if (raw.newData) fields.newData = fromJS(raw.newData);
130+
if (raw.oldData) fields.oldData = getAuditDetailMap(raw.oldData, inheritedFields);
131+
if (raw.newData) fields.newData = getAuditDetailMap(raw.newData, inheritedFields);
131132
if (raw.providedValues) fields.providedValues = raw.providedValues;
132133
if (raw.providedDeltaValues) fields.providedDeltaValues = raw.providedDeltaValues;
133134

packages/components/src/internal/components/auditlog/utils.test.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ import {
1313
TEST_LKSM_STARTER_MODULE_CONTEXT,
1414
} from '../../productFixtures';
1515

16-
import { getAuditQueries, getEventDataValueDisplay, getTimelineEntityUrl } from './utils';
16+
import { getAuditDetailMap, getAuditQueries, getEventDataValueDisplay, getTimelineEntityUrl } from './utils';
1717
import {
1818
ASSAY_AUDIT_QUERY,
19-
ASSAY_RESULT_AUDIT_QUERY,
19+
ASSAY_RESULT_AUDIT_QUERY, AUDIT_DETAIL_FIELD_VALUE_INHERITED,
2020
DATACLASS_DATA_UPDATE_AUDIT_QUERY,
2121
INVENTORY_AUDIT_QUERY,
2222
NOTEBOOK_AUDIT_QUERY,
@@ -160,4 +160,49 @@ describe('utils', () => {
160160
);
161161
expect(getTimelineEntityUrl({ urlType: 'inventoryBox', value: 101 }).toHref()).toEqual('/labkey/DefaultTestContainer/freezermanager-app.view#/boxes/101');
162162
});
163+
164+
describe('getAuditDetailMap', () => {
165+
it('data is empty', () => {
166+
expect(getAuditDetailMap(null).isEmpty()).toBe(true);
167+
expect(getAuditDetailMap(undefined).isEmpty()).toBe(true);
168+
expect(getAuditDetailMap({}).isEmpty()).toBe(true);
169+
expect(getAuditDetailMap({}, ['field1']).isEmpty()).toBe(true);
170+
});
171+
172+
it('when no inherited fields are provided', () => {
173+
const data = { field1: 'value1', field2: 'value2' };
174+
const result = getAuditDetailMap(data);
175+
expect(result.toJS()).toEqual(data);
176+
});
177+
178+
it('moves inherited fields to the bottom of the OrderedMap', () => {
179+
const data = { field1: 'value1', field2: null, field3: 'value3' };
180+
const inheritedFields = ['field2'];
181+
const result = getAuditDetailMap(data, inheritedFields);
182+
expect(result.toJS()).toEqual({
183+
field1: 'value1',
184+
field3: 'value3',
185+
field2: AUDIT_DETAIL_FIELD_VALUE_INHERITED,
186+
});
187+
});
188+
189+
it('case-insensitive inherited fields', () => {
190+
const data = { Field1: 'value1', FIELD2: null, field3: 'value3' };
191+
const inheritedFields = ['field2'];
192+
const result = getAuditDetailMap(data, inheritedFields);
193+
expect(result.toJS()).toEqual({
194+
Field1: 'value1',
195+
field3: 'value3',
196+
FIELD2: AUDIT_DETAIL_FIELD_VALUE_INHERITED,
197+
});
198+
});
199+
200+
it('absent inherited fields', () => {
201+
const data = { field1: 'value1', field2: 'value2' };
202+
const inheritedFields = ['field3'];
203+
const result = getAuditDetailMap(data, inheritedFields);
204+
expect(result.toJS()).toEqual(data);
205+
});
206+
});
207+
163208
});

packages/components/src/internal/components/auditlog/utils.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* any form or by any electronic or mechanical means without written permission from LabKey Corporation.
44
*/
55
import React, { ReactNode } from 'react';
6-
import { Map } from 'immutable';
6+
import { Map, OrderedMap } from 'immutable';
77

88
import { FREEZER_MANAGER_PRODUCT_ID, isSampleManagerEnabled } from '../../app/products';
99
import {
@@ -32,8 +32,9 @@ import {
3232
SOURCE_AUDIT_QUERY,
3333
WORKFLOW_AUDIT_QUERY,
3434
REPORT_AUDIT_QUERY,
35-
ASSAY_RESULT_AUDIT_QUERY,
35+
ASSAY_RESULT_AUDIT_QUERY, AUDIT_DETAIL_FIELD_VALUE_INHERITED,
3636
} from './constants';
37+
import { QueryKey } from '@labkey/api';
3738

3839
export function getAuditQueries(ctx: ModuleContext): AuditQuery[] {
3940
const queries = [...COMMON_AUDIT_QUERIES];
@@ -144,3 +145,25 @@ export function getTimelineEntityUrl(d: Record<string, any>): AppURL {
144145

145146
return url;
146147
}
148+
149+
export function getAuditDetailMap(data: Record<string, string>, inheritedFields?: string[]): OrderedMap<string, string> {
150+
if (inheritedFields?.length > 0) {
151+
// sort fields.newData so that inherited fields are at the bottom
152+
const inheritedFieldsLc = inheritedFields.map(f => f.toLowerCase());
153+
const newData = data ?? {};
154+
const sortedNewData = {} as any;
155+
const last = {};
156+
for (const field of Object.keys(newData)) {
157+
if (!newData[field] && inheritedFieldsLc.indexOf(QueryKey.encodePart(field).toLowerCase()) > -1) {
158+
last[field] = AUDIT_DETAIL_FIELD_VALUE_INHERITED;
159+
}
160+
else {
161+
sortedNewData[field] = newData[field];
162+
}
163+
}
164+
Object.assign(sortedNewData, last);
165+
return OrderedMap(sortedNewData);
166+
}
167+
168+
return OrderedMap(data);
169+
}

packages/components/src/internal/components/samples/APIWrapper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export interface SamplesAPIWrapper {
8383
sort: string | undefined
8484
) => Promise<ISelectRowsResult>;
8585

86-
getTimelineEvents: (sampleId: number, timezone?: string) => Promise<TimelineEventModel[]>;
86+
getTimelineEvents: (sampleId: number, timezone?: string, inheritedFields?: string[]) => Promise<TimelineEventModel[]>;
8787

8888
hasExistingSamples: (isRoot?: boolean, containerPath?: string) => Promise<boolean>;
8989

packages/components/src/internal/components/samples/actions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ export async function getLookupRowIdsFromSelection(
506506
}
507507

508508
// optional timezone param used for teamcity jest test only
509-
export function getTimelineEvents(sampleId: number, timezone?: string): Promise<TimelineEventModel[]> {
509+
export function getTimelineEvents(sampleId: number, timezone?: string, inheritedFields?: string[]): Promise<TimelineEventModel[]> {
510510
return new Promise((resolve, reject) => {
511511
Ajax.request({
512512
url: ActionURL.buildURL(SAMPLE_MANAGER_APP_PROPERTIES.controllerName, 'getTimeline.api'),
@@ -516,7 +516,7 @@ export function getTimelineEvents(sampleId: number, timezone?: string): Promise<
516516
const events: TimelineEventModel[] = [];
517517
if (response.events) {
518518
(response.events as []).forEach(event =>
519-
events.push(TimelineEventModel.create(event, timezone))
519+
events.push(TimelineEventModel.create(event, timezone, inheritedFields))
520520
);
521521
}
522522
resolve(events);

0 commit comments

Comments
 (0)