Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.55.2",
"version": "7.55.3",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { ActionURL, Ajax, Utils } from '@labkey/api';

import { Container } from '../base/models/Container';
import { handleRequestFailure } from '../../request';
import { handleRequestFailure, request } from '../../request';
import { SAMPLE_MANAGER_APP_PROPERTIES } from '../../app/constants';
import { FolderConfigurableDataType } from '../entities/models';
import { getFolderDataTypeExclusions } from '../entities/actions';
Expand Down Expand Up @@ -54,7 +54,7 @@ export interface FolderAPIWrapper {
excludeArchived?: boolean
) => Promise<Container[]>;
getDataTypeExcludedContainers: (dataType: FolderConfigurableDataType, dataTypeRowId: number) => Promise<string[]>;
getFolderDataTypeExclusions: (excludedContainer?: string, reload?: boolean) => Promise<{ [key: string]: number[] }>;
getFolderDataTypeExclusions: (excludedContainer?: string, reload?: boolean) => Promise<Record<string, number[]>>;
getMultipleDataTypeExcludedContainers: (
dataType: FolderConfigurableDataType,
dataTypeRowIds: number[]
Expand Down Expand Up @@ -87,7 +87,7 @@ export class ServerFolderAPIWrapper implements FolderAPIWrapper {
});
};

archiveFolder = (archive: boolean = true, containerPath?: string): Promise<Container> => {
archiveFolder = (archive = true, containerPath?: string): Promise<Container> => {
return new Promise((resolve, reject) => {
Ajax.request({
url: ActionURL.buildURL(
Expand Down Expand Up @@ -126,22 +126,16 @@ export class ServerFolderAPIWrapper implements FolderAPIWrapper {
};

getAuditSettings = (containerPath?: string): Promise<AuditSettingsResponse> => {
return new Promise((resolve, reject) => {
Ajax.request({
url: ActionURL.buildURL('audit', 'getAuditSettings', containerPath),
method: 'POST',
success: Utils.getCallbackWrapper(response => {
resolve(response);
}),
failure: handleRequestFailure(reject, 'Failed to retrieve audit settings.'),
});
return request<AuditSettingsResponse>({
url: ActionURL.buildURL('audit', 'getAuditSettings.api', containerPath),
errorLogMsg: 'Failed to retrieve audit settings.',
});
};

setAuditCommentsRequired = (requireUserComments: boolean, containerPath?: string): Promise<void> => {
return new Promise((resolve, reject) => {
Ajax.request({
url: ActionURL.buildURL('audit', 'saveAuditSettings', containerPath),
url: ActionURL.buildURL('audit', 'saveAuditSettings.api', containerPath),
method: 'POST',
jsonData: { requireUserComments },
success: Utils.getCallbackWrapper(() => {
Expand Down Expand Up @@ -197,7 +191,6 @@ export class ServerFolderAPIWrapper implements FolderAPIWrapper {
return new Promise((resolve, reject) => {
Ajax.request({
url: ActionURL.buildURL(SAMPLE_MANAGER_APP_PROPERTIES.controllerName, 'getDataTypeExclusion.api'),
method: 'GET',
params: {
dataType,
dataTypeRowId,
Expand Down Expand Up @@ -230,7 +223,7 @@ export class ServerFolderAPIWrapper implements FolderAPIWrapper {
}

return new Promise((resolve, reject) => {
const promises: Array<Promise<Record<string, string[]>>> = [];
const promises: Promise<Record<string, string[]>>[] = [];

dataTypeRowIds.forEach(id => {
promises.push(this.getDataTypeExcludedContainersAsRecord(dataType, id));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// This file was originally derived from the "formsy-react" package, specifically, v2.3.2.
// Credit: Christian Alfoni and the Formsy Authors
// Repository: https://github.com/formsy/formsy-react/tree/0226fab133a25
import React, { act, FC, memo, PropsWithChildren, useCallback, useRef, useState } from 'react';
import React, { act, FC, memo, PropsWithChildren, useCallback, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { createEvent, fireEvent, render } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
Expand Down Expand Up @@ -448,6 +448,55 @@ describe('Formsy', () => {
});
});

describe('validation messages', () => {
// Mirrors an input whose error message is enriched by data that only starts loading once the validation
// failure is known, so the message changes while the rules and the value stay put.
const AsyncMessageForm: FC = () => {
const [isRegistered, setIsRegistered] = useState(false);
const [canAssociate, setCanAssociate] = useState(false);
const validations = useMemo(() => ({ isNotRegistered: isRegistered }), [isRegistered]);
const validationErrors = useMemo(
() => ({ isNotRegistered: canAssociate ? 'Already registered. Associate?' : 'Already registered.' }),
[canAssociate]
);
const onRegister = useCallback(() => setIsRegistered(true), []);
const onAssociate = useCallback(() => setCanAssociate(true), []);

return (
<>
<Formsy>
<TestInput
name="one"
testId="test-input"
validationErrors={validationErrors}
validations={validations}
value="foo"
/>
</Formsy>
<button data-testid="register-btn" onClick={onRegister} type="button" />
<button data-testid="associate-btn" onClick={onAssociate} type="button" />
</>
);
};

it('should re-resolve messages when the validationErrors prop changes', () => {
addFormsyRule<string>('isNotRegistered', (_values, _value, isRegistered: boolean) => !isRegistered);

const screen = render(<AsyncMessageForm />);
const input = screen.getByTestId('test-input');

expect(input).toHaveAttribute('data-error-messages', '');

// The rule starts failing, so the message resolved at that moment is displayed.
fireEvent.click(screen.getByTestId('register-btn'));
expect(input).toHaveAttribute('data-error-messages', 'Already registered.');

// Only the message changes here. It should surface without waiting for another validation pass.
fireEvent.click(screen.getByTestId('associate-btn'));
expect(input).toHaveAttribute('data-error-messages', 'Already registered. Associate?');
});
});

describe('onChange', () => {
it('should not trigger onChange when form is mounted', () => {
const hasChanged = jest.fn();
Expand Down Expand Up @@ -507,6 +556,40 @@ describe('Formsy', () => {

expect(hasChanged).toHaveBeenCalledTimes(1);
});

it('should not trigger onChange when only the validations prop changes', () => {
addFormsyRule<string>('isNotConflicted', (_values, _value, isConflicted: boolean) => !isConflicted);
const hasChanged = jest.fn();

const TestForm: FC = () => {
const [isConflicted, setIsConflicted] = useState(true);
const validations = useMemo(() => ({ isNotConflicted: isConflicted }), [isConflicted]);
const resolveConflict = useCallback(() => setIsConflicted(false), []);

return (
<>
<Formsy onChange={hasChanged}>
<TestInput name="one" testId="test-input" validations={validations} value="foo" />
</Formsy>
<button data-testid="resolve-btn" onClick={resolveConflict} type="button" />
</>
);
};

const screen = render(<TestForm />);
const input = screen.getByTestId('test-input');

fireEvent.change(input, { target: { value: 'bar' } });
expect(hasChanged).toHaveBeenCalledTimes(1);
expect(input).toHaveAttribute('data-is-valid', 'false');

fireEvent.click(screen.getByTestId('resolve-btn'));
expect(hasChanged).toHaveBeenCalledTimes(1);
expect(input).toHaveAttribute('data-is-valid', 'true');

fireEvent.change(input, { target: { value: 'baz' } });
expect(hasChanged).toHaveBeenCalledTimes(2);
});
});

describe('Update a form', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,9 +371,9 @@ export class Formsy extends Component<FormsyProps, FormsyState> {
// Use the bound values and the actual input value to
// validate the input and set its state. Then check the
// state of the form itself
validate = (component: InputComponent<any>): void => {
validate = (component: InputComponent<any>, notifyChange = true): void => {
if (!this._mounted) return;
this.triggerChange();
if (notifyChange) this.triggerChange();

// Run through the validations, split them up and call
// the validator IF there is a value or it is required
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export interface FormsyContextInterface {
isFormDisabled: boolean;
isValidValue: (component: InputComponent<any>, value: any) => boolean;
runValidation: (component: InputComponent<any>, value?: any) => RunValidationResponse;
validate: (component: InputComponent<any>) => void;
validate: (component: InputComponent<any>, notifyChange?: boolean) => void;
}

export type OnSubmitCallback = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const VALUES = [null, ...TYPES.isDate, ...TYPES.isFunction, ...TYPES.isObject, .
describe('utils', () => {
// For each function in types
Object.keys(TYPES).forEach(isFn => {
// Create a test for that functiojn
// Create a test for that function
it(isFn, () => {
// For each value in values
VALUES.forEach(value => {
Expand Down Expand Up @@ -101,4 +101,34 @@ describe('utils', () => {
success: [],
});
});

describe('isShallowSame', () => {
it('compares message maps by key set and value identity', () => {
const message = 'This sequence has already been registered.';

expect(utils.isShallowSame({ isUnique: message }, { isUnique: message })).toBe(true);
expect(utils.isShallowSame({ isUnique: message }, { isUnique: 'Something else' })).toBe(false);
expect(utils.isShallowSame({ isUnique: message }, { isUnique: message, isRequired: message })).toBe(false);
expect(utils.isShallowSame({ isUnique: message }, { isValid: message })).toBe(false);
});

it('treats structurally equal but distinct values as changed, unlike isSame()', () => {
// Stands in for a ReactNode message. Elements are rebuilt on every render, so identity is the only
// signal that can be trusted -- isSame() would walk into React internals and report a false match.
const messages = { isUnique: { type: 'span', props: { children: 'Already registered.' } } };
const rebuilt = { isUnique: { type: 'span', props: { children: 'Already registered.' } } };

expect(utils.isSame(messages, rebuilt)).toBe(true);
expect(utils.isShallowSame(messages, rebuilt)).toBe(false);
});

it('compares resolved message arrays element-wise by identity', () => {
const message = { type: 'span', props: { children: 'Already registered.' } };

expect(utils.isShallowSame([message], [message])).toBe(true);
expect(utils.isShallowSame([message], [{ ...message }])).toBe(false);
expect(utils.isShallowSame(['Required'], ['Required'])).toBe(true);
expect(utils.isShallowSame([], ['Required'])).toBe(false);
});
});
});
20 changes: 20 additions & 0 deletions packages/components/src/internal/components/forms/formsy/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,26 @@ export function isSame(a: unknown, b: unknown): boolean {
return a === b;
}

export function isShallowSame(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) {
return true;
}

if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((item, index) => Object.is(item, b[index]));
}

if (isObject(a) && isObject(b)) {
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every(key => b.hasOwnProperty(key) && Object.is(a[key], b[key]))
);
}

return false;
}

interface RulesResult {
errors: ValidationError[];
failed: string[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
WrapperState,
} from './types';

import { isSame, isString } from './utils';
import { isSame, isShallowSame, isString } from './utils';
import { isDefaultRequiredValue } from './formsyRules';

function convertValidationsToObject<V>(validations: false | Validations<V>): Validations<V> {
Expand Down Expand Up @@ -109,7 +109,8 @@ export function withFormsy<T, V>(
};

componentDidUpdate = (prevProps: WrappedProps): void => {
const { required, value, validations, validate } = this.props;
const { required, runValidation, value, validate, validationError, validationErrors, validations } =
this.props;

// If the value passed has changed, set it. If a value is not passed, it will internally update, and this
// will never run. Skip when the input already holds the value: a parent that owns the value and echoes it
Expand All @@ -122,7 +123,24 @@ export function withFormsy<T, V>(
// If validations or required is changed, run a new validation
if (!isSame(validations, prevProps.validations) || !isSame(required, prevProps.required)) {
this.setValidations(validations, required);
validate(this);
// The rules changed, not the value: revalidate without emitting a change event
validate(this, false);
return;
}

// Validation messages are resolved when validation runs and are then held in state, so a change to the
// messages alone -- a message whose content depends on data that loads asynchronously, for example --
// would not reach the user until some later interaction happened to trigger the next validation pass.
// Re-resolve the messages against the current props here. The rules and the value are unchanged, so this
// cannot change the validity of this input and the form does not need to revalidate.
if (
!isShallowSame(validationError, prevProps.validationError) ||
!isShallowSame(validationErrors, prevProps.validationErrors)
) {
const validationState = runValidation(this);
if (!isShallowSame(validationState.validationError, this.state.validationError)) {
this.setState(validationState);
}
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@
* Copyright (c) 2024-2026 LabKey Corporation. All rights reserved. No portion of this work may be reproduced
* in any form or by any electronic or mechanical means without written permission from LabKey Corporation.
*/
import { useEffect, useState } from 'react';
import { useCallback } from 'react';
import { useServerContext } from '../../base/ServerContext';
import { getAppHomeFolderPath } from '../../../app/utils';
import { useAppContext } from '../../../AppContext';
import { Loader, useLoadableState } from '../../../useLoadableState';
import { LoadingState } from '../../../../public/LoadingState';

export const useDataChangeCommentsRequired = (): { requiresUserComment: boolean } => {
export type DataChangeCommentsRequired = {
loadingState: LoadingState;
requiresUserComment: boolean;
};

export const useDataChangeCommentsRequired = (): DataChangeCommentsRequired => {
const { container, moduleContext } = useServerContext();
const { api } = useAppContext();
const [requiresUserComment, setRequiresUserComment] = useState<boolean>(false);

useEffect(
() => {
(async () => {
const path = getAppHomeFolderPath(container, moduleContext);
try {
const response = await api.folder.getAuditSettings(path);
setRequiresUserComment(!!response?.requireUserComments);
} catch (error) {
console.error('Unable to retrieve audit log settings for ' + path, error);
}
})();
}, [ /** on load only */ ]);
const loader = useCallback<Loader<boolean>>(async () => {
const path = getAppHomeFolderPath(container, moduleContext);
const response = await api.folder.getAuditSettings(path);
return !!response?.requireUserComments;
}, [api.folder, container, moduleContext]);

const { loadingState, value: requiresUserComment } = useLoadableState(loader);
Comment thread
labkey-nicka marked this conversation as resolved.

return { requiresUserComment };
return { loadingState, requiresUserComment };
};
Loading