-
-
- {children}
-
- {helperBody && {helperBody} }
-
+ return (
+
+
+
+ {children}
+
+ {helperBody && {helperBody} }
- );
- }
-}
+
+ );
+});
+ActionButton.displayName = 'ActionButton';
diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx
index bb8103f1d8..2496fd1c23 100644
--- a/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx
+++ b/packages/components/src/internal/components/buttons/WizardNavButtons.test.tsx
@@ -7,31 +7,51 @@ import React from 'react';
import { render } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
+import { useModalFooter } from '../../ModalFooterContext';
+import { useFormStepActive } from '../forms/FormStep';
+
import { WizardNavButtons } from './WizardNavButtons';
-describe('
', () => {
+jest.mock('../../ModalFooterContext', () => ({
+ ...jest.requireActual('../../ModalFooterContext'),
+ useModalFooter: jest.fn(),
+}));
+
+jest.mock('../forms/FormStep', () => ({
+ ...jest.requireActual('../forms/FormStep'),
+ useFormStepActive: jest.fn(),
+}));
+
+const mockUseModalFooter = useModalFooter as jest.MockedFunction
;
+const mockUseFormStepActive = useFormStepActive as jest.MockedFunction;
+
+describe('WizardNavButtons', () => {
+ beforeEach(() => {
+ mockUseModalFooter.mockReturnValue(null);
+ mockUseFormStepActive.mockReturnValue(true);
+ });
test('default props', () => {
render( );
- expect(document.querySelectorAll('button').length === 2);
- expect(document.querySelectorAll('button')[0].textContent).toEqual('Cancel');
- expect(document.querySelectorAll('button')[1].textContent).toEqual('Next');
- expect(document.querySelectorAll('button')[1].hasAttribute('disabled')).toEqual(false);
+ expect(document.querySelectorAll('button')).toHaveLength(2);
+ expect(document.querySelectorAll('button')[0]).toHaveTextContent('Cancel');
+ expect(document.querySelectorAll('button')[1]).toHaveTextContent('Next');
+ expect(document.querySelectorAll('button')[1]).not.toBeDisabled();
});
test('finish props', () => {
render(
);
- expect(document.querySelectorAll('button').length).toEqual(2);
- expect(document.querySelectorAll('button')[0].textContent).toEqual('Cancel');
- expect(document.querySelectorAll('button')[1].textContent).toEqual('Custom Finish');
- expect(document.querySelectorAll('button')[1].hasAttribute('disabled')).toEqual(true);
+ expect(document.querySelectorAll('button')).toHaveLength(2);
+ expect(document.querySelectorAll('button')[0]).toHaveTextContent('Cancel');
+ expect(document.querySelectorAll('button')[1]).toHaveTextContent('Custom Finish');
+ expect(document.querySelectorAll('button')[1]).toBeDisabled();
});
test('with children', () => {
@@ -42,16 +62,37 @@ describe(' ', () => {
);
- expect(document.querySelectorAll('button').length).toEqual(3);
- expect(document.querySelectorAll('button')[0].textContent).toEqual('Cancel');
- expect(document.querySelectorAll('button')[1].textContent).toEqual('My Additional Button');
+ expect(document.querySelectorAll('button')).toHaveLength(3);
+ expect(document.querySelectorAll('button')[0]).toHaveTextContent('Cancel');
+ expect(document.querySelectorAll('button')[1]).toHaveTextContent('My Additional Button');
+ });
+
+ test('formId applies the form attribute to the next button', () => {
+ render( );
+ const buttons = document.querySelectorAll('button');
+ expect(buttons[1]).toHaveTextContent('Next');
+ expect(buttons[1]).toHaveAttribute('form', 'my-form');
+ expect(buttons[0]).not.toHaveAttribute('form');
+ });
+
+ test('formId applies the form attribute to the finish button', () => {
+ render( );
+ const buttons = document.querySelectorAll('button');
+ expect(buttons[1]).toHaveTextContent('Finish');
+ expect(buttons[1]).toHaveAttribute('form', 'my-form');
+ });
+
+ test('no form attribute when formId is omitted', () => {
+ render( );
+ const buttons = document.querySelectorAll('button');
+ expect(buttons[1]).not.toHaveAttribute('form');
});
test('onClick handlers', async () => {
const cancelFn = jest.fn();
const prevFn = jest.fn();
const nextFn = jest.fn();
- render( );
+ render( );
expect(cancelFn).toHaveBeenCalledTimes(0);
expect(prevFn).toHaveBeenCalledTimes(0);
expect(nextFn).toHaveBeenCalledTimes(0);
@@ -71,4 +112,40 @@ describe(' ', () => {
expect(prevFn).toHaveBeenCalledTimes(1);
expect(nextFn).toHaveBeenCalledTimes(1);
});
+
+ test('portals into the modal footer when one is provided', () => {
+ const footer = document.createElement('div');
+ footer.className = 'modal-footer modal-buttons';
+ document.body.appendChild(footer);
+ mockUseModalFooter.mockReturnValue(footer);
+
+ render( );
+
+ expect(document.querySelector('.form-buttons--sticky')).toBeNull();
+ expect(footer.querySelector('.form-buttons')).not.toBeNull();
+
+ const buttons = footer.querySelectorAll('button');
+ expect(buttons).toHaveLength(2);
+ expect(buttons[0]).toHaveTextContent('Cancel');
+ expect(buttons[1]).toHaveTextContent('Next');
+
+ document.body.removeChild(footer);
+ });
+
+ test('does not portal into the footer when its form step is inactive', () => {
+ // A wizard mounts every visited step at once, so an inactive step must contribute nothing to the shared
+ // modal footer.
+ const footer = document.createElement('div');
+ footer.className = 'modal-footer modal-buttons';
+ document.body.appendChild(footer);
+ mockUseModalFooter.mockReturnValue(footer);
+ mockUseFormStepActive.mockReturnValue(false);
+
+ render( );
+
+ expect(footer.querySelectorAll('button')).toHaveLength(0);
+ expect(document.querySelectorAll('button')).toHaveLength(0);
+
+ document.body.removeChild(footer);
+ });
});
diff --git a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx
index c5c63077f9..88034f2f5f 100644
--- a/packages/components/src/internal/components/buttons/WizardNavButtons.tsx
+++ b/packages/components/src/internal/components/buttons/WizardNavButtons.tsx
@@ -3,18 +3,22 @@
* any form or by any electronic or mechanical means without written permission from LabKey Corporation.
*/
import React, { FC, memo, PropsWithChildren } from 'react';
+import { createPortal } from 'react-dom';
import { FormButtons } from '../../FormButtons';
+import { useModalFooter } from '../../ModalFooterContext';
+import { useFormStepActive } from '../forms/FormStep';
interface Props extends PropsWithChildren {
canCancel?: boolean;
+ cancel: () => void;
+ cancelText?: string;
canFinish?: boolean;
canNextStep?: boolean;
canPreviousStep?: boolean;
- cancel: () => void;
- cancelText?: string;
finish?: boolean;
finishText?: string;
+ formId?: string;
isFinished?: boolean;
isFinishedText?: string;
isFinishing?: boolean;
@@ -35,6 +39,7 @@ export const WizardNavButtons: FC = memo(props => {
children,
finish = false,
finishText = 'Finish',
+ formId,
isFinished,
isFinishedText = 'Finished',
isFinishing,
@@ -43,37 +48,55 @@ export const WizardNavButtons: FC = memo(props => {
previousStep,
singularNoun,
} = props;
+ const footerEl = useModalFooter();
+ const stepActive = useFormStepActive();
- let submitButton;
-
- if (finish) {
- submitButton = (
-
- {isFinished ? isFinishedText : isFinishing ? isFinishingText : finishText}
- {singularNoun ? ' ' + singularNoun : null}
-
- );
- } else {
- submitButton = (
-
- Next
-
- );
- }
-
- return (
-
+ const formButtons = (
+
{cancelText}
{previousStep !== undefined && (
-
+
Back
)}
{children}
- {submitButton}
+ {finish && (
+
+ {isFinished ? isFinishedText : isFinishing ? isFinishingText : finishText}
+ {singularNoun ? ' ' + singularNoun : null}
+
+ )}
+ {!finish && (
+
+ Next
+
+ )}
);
+
+ // When rendered inside a modal that provides a footer element (see ModalFooterContext), portal the buttons into
+ // the actual footer so the footer stays a true sibling of the modal body rather than nested within it.
+ // If used in combination with FormStep, only render the buttons for the active step. If not in a FormStep, then
+ // stepActive will be true.
+ if (footerEl) {
+ if (!stepActive) return null;
+ return createPortal(formButtons, footerEl);
+ }
+
+ return formButtons;
});
WizardNavButtons.displayName = 'WizardNavButtons';
diff --git a/packages/components/src/internal/components/editable/utils.ts b/packages/components/src/internal/components/editable/utils.ts
index 6ca5bf471f..3040a8d1f2 100644
--- a/packages/components/src/internal/components/editable/utils.ts
+++ b/packages/components/src/internal/components/editable/utils.ts
@@ -313,6 +313,7 @@ export const gridCellSelectInputProps: Partial = {
export const gridCellQuerySelectProps: Partial = {
...gridCellSelectInputProps,
+ allowAddEntities: false,
notFoundValuesEnabled: false,
showLoading: false,
};
diff --git a/packages/components/src/internal/components/forms/AddEntitiesModal.test.tsx b/packages/components/src/internal/components/forms/AddEntitiesModal.test.tsx
new file mode 100644
index 0000000000..75e6ebf06e
--- /dev/null
+++ b/packages/components/src/internal/components/forms/AddEntitiesModal.test.tsx
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 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 React, { FC } from 'react';
+import { render, renderHook, screen } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import { Query } from '@labkey/api';
+
+import { SchemaQuery } from '../../../public/SchemaQuery';
+import { ModalRendererProps, registerModalRenderer } from '../../ModalRenderFactory';
+
+import { AddEntitiesFooter, AddEntitiesModal, useIsAddEntitiesEnabled } from './AddEntitiesModal';
+
+const RegisteredRenderer: FC = ({ schemaQuery }) => (
+ {schemaQuery.toString(false)}
+);
+
+describe('AddEntitiesModal', () => {
+ describe('useIsAddEntitiesEnabled', () => {
+ test('returns false when schemaQuery is undefined', () => {
+ const { result } = renderHook(() => useIsAddEntitiesEnabled(undefined));
+ expect(result.current).toBe(false);
+ });
+
+ test('returns false when no modal renderer is registered', () => {
+ const { result } = renderHook(() =>
+ useIsAddEntitiesEnabled(new SchemaQuery('hook.unregistered', 'NoRenderer'))
+ );
+ expect(result.current).toBe(false);
+ });
+
+ test('returns true when a modal renderer is registered and no provider is rendered', () => {
+ const sq = new SchemaQuery('hook.registered', 'HasRenderer');
+ registerModalRenderer(sq, RegisteredRenderer);
+
+ const { result } = renderHook(() => useIsAddEntitiesEnabled(sq));
+ expect(result.current).toBe(true);
+ });
+
+ test('returns false for a registered renderer rendered within an AddEntitiesModal', () => {
+ const sq = new SchemaQuery('hook.nested', 'NestedRenderer');
+ const NestedRenderer: FC = ({ schemaQuery }) => {
+ const enabled = useIsAddEntitiesEnabled(schemaQuery);
+ return {String(enabled)}
;
+ };
+ registerModalRenderer(sq, NestedRenderer);
+
+ render(
+
+ );
+
+ expect(document.querySelector('.nested-enabled')).toHaveTextContent('false');
+ });
+ });
+
+ describe('AddEntitiesFooter', () => {
+ test('renders and invokes onClick', async () => {
+ const onClick = jest.fn();
+ render( );
+
+ const footer = document.querySelector('.add-entities-footer');
+ expect(footer).toHaveTextContent('Add New');
+ expect(footer.querySelector('.fa-plus-circle')).not.toBeNull();
+
+ await userEvent.click(footer);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ test('reflects keyboard focus via the "focused" prop', () => {
+ const { rerender } = render( );
+ expect(document.querySelector('.add-entities-footer')).not.toHaveClass('is-focused');
+
+ rerender( );
+ expect(document.querySelector('.add-entities-footer')).toHaveClass('is-focused');
+ });
+ });
+
+ describe('component', () => {
+ test('renders a fallback modal when no renderer is registered', async () => {
+ const onCancel = jest.fn();
+ render(
+
+ );
+
+ expect(document.querySelector('.modal-title')).toHaveTextContent('Add New Entities');
+ expect(screen.getByText(/not registered for/)).toHaveTextContent('modal.unregistered.MissingQuery');
+
+ await userEvent.click(document.querySelector('button.close'));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ test('renders the registered renderer with all props passed through', () => {
+ const sq = new SchemaQuery('modal.registered', 'PassThrough');
+ const onCancel = jest.fn();
+ const onComplete = jest.fn();
+ const PropCapture = jest.fn().mockReturnValue(null);
+ registerModalRenderer(sq, PropCapture);
+
+ render(
+
+ );
+
+ expect(PropCapture).toHaveBeenCalledTimes(1);
+ expect(PropCapture.mock.calls[0][0]).toEqual({
+ containerFilter: Query.ContainerFilter.currentAndSubfolders,
+ containerPath: '/project/folder',
+ onCancel,
+ onComplete,
+ schemaQuery: sq,
+ });
+ });
+
+ test('does not render the fallback modal when a renderer is registered', () => {
+ const sq = new SchemaQuery('modal.rendered', 'RendersRenderer');
+ registerModalRenderer(sq, RegisteredRenderer);
+
+ render(
+
+ );
+
+ expect(document.querySelector('.registered-renderer')).toHaveTextContent(sq.toString(false));
+ expect(document.querySelector('.modal-title')).toBeNull();
+ });
+ });
+});
diff --git a/packages/components/src/internal/components/forms/AddEntitiesModal.tsx b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx
new file mode 100644
index 0000000000..3e1beca450
--- /dev/null
+++ b/packages/components/src/internal/components/forms/AddEntitiesModal.tsx
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 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 React, { createContext, FC, useContext, useMemo } from 'react';
+import classNames from 'classnames';
+
+import { SchemaQuery } from '../../../public/SchemaQuery';
+import { Modal } from '../../Modal';
+import { ModalRendererProps, resolveModalRenderer } from '../../ModalRenderFactory';
+
+export type AddEntitiesModalContext = {
+ addEntitiesEnabled: boolean;
+ inModal: boolean;
+};
+
+const AddEntitiesModalContext = createContext({ addEntitiesEnabled: true, inModal: false });
+
+export function useIsAddEntitiesEnabled(schemaQuery: SchemaQuery): boolean {
+ const ctx = useContext(AddEntitiesModalContext);
+ return useMemo(
+ // If the context it not available/rendered, then default to true
+ () =>
+ schemaQuery !== undefined &&
+ (ctx?.addEntitiesEnabled ?? true) &&
+ resolveModalRenderer(schemaQuery) !== undefined,
+ [ctx, schemaQuery]
+ );
+}
+
+export function useIsInModal(): boolean {
+ const ctx = useContext(AddEntitiesModalContext);
+ return !!ctx?.inModal;
+}
+
+interface AddEntitiesMenuFooterProps {
+ focused?: boolean;
+ onClick: () => void;
+}
+
+export const AddEntitiesFooter: FC = ({ focused, onClick }) => (
+
+
+ Add New
+
+);
+AddEntitiesFooter.displayName = 'AddEntitiesFooter';
+
+export const AddEntitiesModal: FC = props => {
+ const { containerFilter, containerPath, onCancel, onComplete, schemaQuery } = props;
+ const ModalRenderer = useMemo(() => resolveModalRenderer(schemaQuery), [schemaQuery]);
+ const value = useMemo(() => ({ addEntitiesEnabled: false, inModal: true }), []);
+
+ if (!ModalRenderer) {
+ return (
+
+ Add entities modal not registered for {schemaQuery.schemaName}.{schemaQuery.queryName}
+
+ );
+ }
+
+ return (
+
+ {/* eslint-disable-next-line react-hooks/static-components */}
+
+
+ );
+};
+AddEntitiesModal.displayName = 'AddEntitiesModal';
diff --git a/packages/components/src/internal/components/forms/FormStep.test.tsx b/packages/components/src/internal/components/forms/FormStep.test.tsx
index 0ce4b8c31f..328aef10d7 100644
--- a/packages/components/src/internal/components/forms/FormStep.test.tsx
+++ b/packages/components/src/internal/components/forms/FormStep.test.tsx
@@ -8,8 +8,8 @@ import { render } from '@testing-library/react';
import { FormStep, FormTabs, withFormSteps, WithFormStepsProps } from './FormStep';
interface OwnProps {
- step?: number;
initialStep?: number;
+ step?: number;
}
type Props = OwnProps & WithFormStepsProps;
@@ -41,7 +41,7 @@ const FormStepTest = withFormSteps(FormStepTestImpl, {
hasDependentSteps: true,
});
-describe(' ', () => {
+describe('FormStep', () => {
test('default props', () => {
const { container } = render( );
const tabs = container.querySelectorAll('.form-step-tab');
diff --git a/packages/components/src/internal/components/forms/FormStep.tsx b/packages/components/src/internal/components/forms/FormStep.tsx
index d597f6a6e1..a9fc583073 100644
--- a/packages/components/src/internal/components/forms/FormStep.tsx
+++ b/packages/components/src/internal/components/forms/FormStep.tsx
@@ -18,6 +18,12 @@ const FormStepContext = React.createContext(undefined);
const FormStepContextProvider = FormStepContext.Provider;
const FormStepContextConsumer = FormStepContext.Consumer;
+export const FormStepActiveContext = React.createContext(true);
+
+export function useFormStepActive(): boolean {
+ return useContext(FormStepActiveContext);
+}
+
interface ActiveStepProps extends PropsWithChildren {
active?: boolean;
}
@@ -60,7 +66,9 @@ export class FormStep extends React.Component {
if (furthestStep >= stepIndex) {
return (
- {trackActive ?
{children} : children}
+
+ {trackActive ? {children} : children}
+
);
}
diff --git a/packages/components/src/internal/components/forms/QuerySelect.tsx b/packages/components/src/internal/components/forms/QuerySelect.tsx
index 7077547e5a..4607a989e3 100644
--- a/packages/components/src/internal/components/forms/QuerySelect.tsx
+++ b/packages/components/src/internal/components/forms/QuerySelect.tsx
@@ -16,21 +16,35 @@ import { QueryInfo } from '../../../public/QueryInfo';
import { isTestEnv } from '../../util/utils';
-import { useTimeout } from '../../hooks';
+import { useModalState, useTimeout } from '../../hooks';
-import { SelectInput, SelectInputChange, SelectInputOption, SelectInputProps } from './input/SelectInput';
+import {
+ SelectInput,
+ SelectInputChange,
+ SelectInputOnFocus,
+ SelectInputOnKeyDown,
+ SelectInputOption,
+ SelectInputProps,
+} from './input/SelectInput';
import { resolveDetailFieldLabel } from './utils';
import {
fetchSearchResults,
+ fetchSelectedValues,
formatResults,
formatSavedResults,
+ getAddedSelectionValue,
initSelect,
parseSelectedQuery,
QuerySelectModel,
saveSearchResults,
setSelection,
+ setSelectionWithResults,
+ valuesAreLoaded,
} from './model';
import { DELIMITER } from './constants';
+import { AddEntitiesFooter, AddEntitiesModal, useIsAddEntitiesEnabled } from './AddEntitiesModal';
+import { AddEntitiesComplete } from '../../ModalRenderFactory';
+import { Key } from '../../../public/useEnterEscape';
function getValue(model: QuerySelectModel, multiple: boolean): any {
const { rawSelectedValue } = model;
@@ -83,47 +97,45 @@ const OptionRenderer: FC = props => {
const { OptionComponent, label, model, value } = props;
const { allResults, queryInfo } = model;
- if (queryInfo && allResults.size) {
- const columns = queryInfo.getLookupViewColumns(model.displayColumn);
- const item = allResults.find(result => value === result.getIn([model.valueColumn, 'value']));
+ if (!queryInfo || !allResults.size) {
+ return null;
+ }
- if (OptionComponent) {
- return ;
- }
+ const item = allResults.find(result => value === result.getIn([model.valueColumn, 'value']));
- return (
- <>
- {columns.map((column, i) => {
- if (item !== undefined) {
- let text = resolveDetailFieldLabel(item.get(column.name));
- if (!Utils.isString(text)) {
- if (text == null)
- text = '';
- else if (Array.isArray(text))
- text = text.join(', ');
- }
-
- return (
-
- {columns.length > 1 && (
- {column.caption ?? column.name}:
- )}
- {text}
-
- );
- }
+ if (OptionComponent) {
+ return ;
+ }
- return (
-
- {label}
-
- );
- })}
- >
+ if (!item) {
+ return (
+
+ {label}
+
);
}
- return null;
+ const columns = queryInfo.getLookupViewColumns(model.displayColumn);
+
+ return (
+ <>
+ {columns.map(column => {
+ let text = resolveDetailFieldLabel(item.get(column.name));
+ if (!Utils.isString(text)) {
+ text = Array.isArray(text) ? text.join(', ') : (text ?? '');
+ }
+
+ return (
+
+ {columns.length > 1 && (
+ {column.caption ?? column.name}:
+ )}
+ {text}
+
+ );
+ })}
+ >
+ );
};
OptionRenderer.displayName = 'OptionRenderer';
@@ -147,9 +159,10 @@ type InheritedSelectInputProps = Omit<
SelectInputProps,
| 'allowCreate'
| 'autoValue'
+ | 'cacheKey' // used by QuerySelect to invalidate cached options when new entities are added.
| 'cacheOptions'
- | 'defaultOptions' // utilized by QuerySelect to support "preLoad" and "loadOnFocus" behaviors.
- | 'isLoading' // utilized by QuerySelect to support "loadOnFocus" behavior.
+ | 'defaultOptions' // used by QuerySelect to support "preLoad" and "loadOnFocus" behaviors.
+ | 'isLoading' // used by QuerySelect to support "loadOnFocus" behavior.
| 'labelKey'
| 'loadOptions'
| 'onChange' // overridden by QuerySelect. See onQSChange().
@@ -160,6 +173,7 @@ type InheritedSelectInputProps = Omit<
>;
export interface QuerySelectOwnProps extends InheritedSelectInputProps {
+ allowAddEntities?: boolean;
autoInit?: boolean;
containerFilter?: Query.ContainerFilter;
/** The path to the LK container that the queries should be scoped to. */
@@ -171,7 +185,7 @@ export interface QuerySelectOwnProps extends InheritedSelectInputProps {
groupByColumn?: string;
loadOnFocus?: boolean;
maxRows?: number;
- /** When enabled "not found" (i.e. unresolved) values will be processed as selectable items. */
+ /** When enabled "not found" (i.e., unresolved) values will be processed as selectable items. */
notFoundValuesEnabled?: boolean;
onInitValue?: (value: any, selectedValues: List) => void;
onQSChange?: QuerySelectChange;
@@ -195,7 +209,9 @@ type Search = {
export const QuerySelect: FC = memo(props => {
const {
+ /* eslint-disable @typescript-eslint/no-unused-vars */
OptionComponent,
+ allowAddEntities = true,
// Prevent initialization in test environments in lieu of mocking APIWrapper in all test locations
autoInit = !isTestEnv(),
containerFilter,
@@ -234,6 +250,7 @@ export const QuerySelect: FC = memo(props => {
menuPosition,
multiple,
name,
+ onKeyDown,
onToggleDisable,
openMenuOnFocus,
required,
@@ -244,7 +261,9 @@ export const QuerySelect: FC = memo(props => {
// See note in onFocus() regarding support for "loadOnFocus"
preLoad !== false ? true : loadOnFocus ? [] : true
);
+ const [cacheKey, setCacheKey] = useState(0);
const [error, setError] = useState();
+ const [footerFocused, setFooterFocused] = useState(false);
const [loadOnFocusLock, setLoadOnFocusLock] = useState(false);
const [isLoading, setIsLoading] = useState(undefined);
const [model, setModel] = useState(
@@ -258,7 +277,7 @@ export const QuerySelect: FC = memo(props => {
);
// This persists all searches done prior to the select being fully initialized. Once initialized,
// these searches are cleared out and resolved. The reason we need to retain these is the underlying
- // SelectInput retains these search results, however, we need be fully initialized to complete a search.
+ // SelectInput retains these search results; however, we need to be fully initialized to complete a search.
const [searches, setSearches] = useState([]);
const debounceTO = useTimeout();
const shouldLoadOnFocus = loadOnFocus && !loadOnFocusLock;
@@ -280,6 +299,8 @@ export const QuerySelect: FC = memo(props => {
return { notFoundValues: notFoundValues_, selectedOptions: options };
}, [model]);
+ const { close: closeModal, open: openModal, show: showModal } = useModalState();
+ const isAddEntitiesEnabled = useIsAddEntitiesEnabled(schemaQuery) && allowAddEntities;
useEffect(() => {
if (!autoInit) return;
@@ -345,7 +366,7 @@ export const QuerySelect: FC = memo(props => {
});
}, []);
- // Any searches (i.e. calls to loadOptions()) made prior to the select being fully
+ // Any searches (i.e., calls to loadOptions()) made prior to the select being fully
// initialized are resolved here after the model has been initialized.
useEffect(() => {
if (model.isInit && searches.length > 0) {
@@ -356,7 +377,7 @@ export const QuerySelect: FC = memo(props => {
const loadOptions = useCallback(
(input: string): Promise => {
- // If loadOptions occurs prior to call to "onFocus" then there is no need to "loadOnFocus".
+ // If loadOptions occurs prior to call to "onFocus", then there is no need to "loadOnFocus".
if (shouldLoadOnFocus) {
setLoadOnFocusLock(true);
}
@@ -387,8 +408,45 @@ export const QuerySelect: FC = memo(props => {
[model, onQSChange]
);
- const onFocus = useCallback(async () => {
- // NK: To support loading the select upon focus (a.k.a. "loadOnFocus") we have to explicitly utilize
+ const onAddEntitiesComplete = useCallback(
+ async resultsMap => {
+ closeModal();
+ const result = resultsMap.get(schemaQuery.getKey());
+ if (!model.isInit || !result?.rows?.length) return;
+
+ const nextValue = getAddedSelectionValue(model, result.rows);
+
+ try {
+ let model_: QuerySelectModel;
+
+ if (valuesAreLoaded(model, nextValue)) {
+ model_ = setSelection(model, nextValue);
+ } else {
+ // Load the added value(s) into the model, applying the model's configured columns and
+ // filters, and then select them.
+ setIsLoading(true);
+ const results = await fetchSelectedValues(model, nextValue);
+ model_ = setSelectionWithResults(model, results, nextValue, notFoundValuesEnabled);
+ }
+
+ setModel(model_);
+
+ // The underlying SelectInput's cached options do not include the newly added entities.
+ setCacheKey(k => k + 1);
+ setDefaultOptions(true);
+
+ onQSChange?.(name, model_.rawSelectedValue, model_.selectedOptions, props, model_.selectedItems);
+ } catch (e) {
+ setError(resolveErrorMessage(e) ?? 'Failed to load the newly added value.');
+ } finally {
+ setIsLoading(undefined);
+ }
+ },
+ [closeModal, model, name, notFoundValuesEnabled, onQSChange, props, schemaQuery]
+ );
+
+ const onFocus = useCallback(async () => {
+ // NK: To support loading the select upon focus (a.k.a. "loadOnFocus"), we have to explicitly use
// the "defaultOptions" and "isLoading" properties of ReactSelect. These properties, in tandem with
// "loadOptions", allow for an asynchronous ReactSelect to defer requesting the initial options until
// desired. This follows the pattern outlined here:
@@ -409,6 +467,61 @@ export const QuerySelect: FC = memo(props => {
}
}, [loadOptions, shouldLoadOnFocus]);
+ // Support keyboard interaction with footer
+ const handleKeyDown = useCallback(
+ (event, select) => {
+ onKeyDown?.(event, select);
+ if (event.defaultPrevented || !isAddEntitiesEnabled || !select.props.menuIsOpen) return;
+ const { key } = event;
+
+ if (footerFocused) {
+ switch (key) {
+ case Key.ARROW_DOWN:
+ // Wrap back around to the first option.
+ event.preventDefault();
+ setFooterFocused(false);
+ select.focusOption('first');
+ break;
+ case Key.ARROW_UP:
+ // Move back up to the last option.
+ event.preventDefault();
+ setFooterFocused(false);
+ select.focusOption('last');
+ break;
+ case Key.ENTER:
+ event.preventDefault();
+ setFooterFocused(false);
+ openModal();
+ break;
+ default:
+ // Drop footer focus and let react-select handle the key.
+ setFooterFocused(false);
+ break;
+ }
+ return;
+ }
+
+ // Enter the footer by wrapping around the option list: arrowing down from the last option or up from
+ // the first option (or either direction when the list has no options).
+ if (key === Key.ARROW_DOWN || key === Key.ARROW_UP) {
+ const options = select.getFocusableOptions();
+ const { focusedOption } = select.state;
+
+ const shouldFocusFooter =
+ options.length === 0 ||
+ (key === Key.ARROW_DOWN && focusedOption === options.at(-1)) ||
+ (key === Key.ARROW_UP && focusedOption === options[0]);
+
+ if (shouldFocusFooter) {
+ event.preventDefault();
+ setFooterFocused(true);
+ select.setState({ focusedOption: null });
+ }
+ }
+ },
+ [footerFocused, isAddEntitiesEnabled, onKeyDown, openModal]
+ );
+
const optionRenderer = useCallback(
option => (
@@ -416,7 +529,7 @@ export const QuerySelect: FC = memo(props => {
[OptionComponent, model]
);
- // Issue 52773: If a value is specified, but we are unable to resolve the value then display a warning to the user.
+ // Issue 52773: If a value is specified, but we are unable to resolve the value, then display a warning to the user.
const warning = useMemo(() => {
if (notFoundValues.size === 0) return undefined;
const warningValue = notFoundValues.size === 1 ? Array.from(notFoundValues)[0] : 'multiple values';
@@ -452,28 +565,42 @@ export const QuerySelect: FC = memo(props => {
}
return (
- 0 ? false : required}
- selectedOptions={displaySelectedOptions ? selectedOptions : undefined}
- value={getValue(model, multiple)} // needed to initialize the Formsy "value" properly
- warning={warning}
- />
+ <>
+ }
+ onChange={onChange}
+ onFocus={onFocus}
+ onKeyDown={handleKeyDown}
+ optionRenderer={optionRenderer}
+ options={undefined} // prevent override
+ // Issue 52773: Allow for submission of required fields whose value is not found
+ required={notFoundValues.size > 0 ? false : required}
+ selectedOptions={displaySelectedOptions ? selectedOptions : undefined}
+ value={getValue(model, multiple)} // needed to initialize the Formsy "value" properly
+ warning={warning}
+ />
+ {showModal && (
+
+ )}
+ >
);
});
QuerySelect.displayName = 'QuerySelect';
diff --git a/packages/components/src/internal/components/forms/formsy/Formsy.test.tsx b/packages/components/src/internal/components/forms/formsy/Formsy.test.tsx
index 9d50a3ec9a..7ee494259e 100644
--- a/packages/components/src/internal/components/forms/formsy/Formsy.test.tsx
+++ b/packages/components/src/internal/components/forms/formsy/Formsy.test.tsx
@@ -2,6 +2,7 @@
// Credit: Christian Alfoni and the Formsy Authors
// Repository: https://github.com/formsy/formsy-react/tree/0226fab133a25
import React, { act, FC, PropsWithChildren, memo, useCallback, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
import { createEvent, fireEvent, render } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
@@ -624,6 +625,32 @@ describe('Formsy', () => {
expect(isCalled).toHaveBeenCalled();
});
+
+ it('should ignore submit events bubbled from a nested form rendered in a portal', () => {
+ const onOuterSubmit = jest.fn();
+ const onInnerSubmit = jest.fn();
+
+ function TestForm() {
+ return (
+
+
+ {createPortal(
+
+
+ ,
+ document.body
+ )}
+
+ );
+ }
+
+ const screen = render( );
+
+ fireEvent.submit(screen.getByTestId('inner-form'));
+
+ expect(onInnerSubmit).toHaveBeenCalled();
+ expect(onOuterSubmit).not.toHaveBeenCalled();
+ });
});
describe('value === false', () => {
diff --git a/packages/components/src/internal/components/forms/formsy/Formsy.tsx b/packages/components/src/internal/components/forms/formsy/Formsy.tsx
index e1ba6c0527..5372dedd05 100644
--- a/packages/components/src/internal/components/forms/formsy/Formsy.tsx
+++ b/packages/components/src/internal/components/forms/formsy/Formsy.tsx
@@ -8,13 +8,13 @@ import { debounce } from '../../../util/utils';
import { FormsyContext } from './FormsyContext';
import {
FormsyContextInterface,
+ FormsyInjectedProps,
IModel,
InputComponent,
IResetModel,
IUpdateInputsWithError,
IUpdateInputsWithValue,
OnSubmitCallback,
- FormsyInjectedProps,
RunValidationResponse,
Values,
} from './types';
@@ -66,7 +66,7 @@ export class Formsy extends Component {
validationErrors: {},
};
- inputs: Array>>;
+ inputs: InstanceType>[];
emptyArray: any[];
private _mounted = true;
prevInputNames: any[] | null = null;
@@ -116,7 +116,6 @@ export class Formsy extends Component {
// Keep the disabled value in state/context the same as from props
if (disabled !== prevProps.disabled) {
if (!this._mounted) return;
- // eslint-disable-next-line
this.setState(state => ({
contextValue: { ...state.contextValue, isFormDisabled: disabled },
}));
@@ -274,7 +273,11 @@ export class Formsy extends Component {
this.setState({ isValid });
- isValid ? onValid() : onInvalid();
+ if (isValid) {
+ onValid();
+ } else {
+ onInvalid();
+ }
};
setInputValidationErrors = (errors): void => {
@@ -296,6 +299,10 @@ export class Formsy extends Component {
// Update model, submit to url prop and send the model
submit = (event?: React.SyntheticEvent): void => {
+ // Ignore submit events bubbled from a nested form (e.g., a form rendered in a Modal, which propagates events
+ // through the React tree via its portal); this form's own submissions always have target === currentTarget.
+ if (event && event.target !== event.currentTarget) return;
+
const { onSubmit, onValidSubmit, onInvalidSubmit, preventDefaultSubmit } = this.props;
const { isValid } = this.state;
@@ -304,7 +311,7 @@ export class Formsy extends Component {
}
// Trigger form as not pristine.
- // If any inputs have not been touched yet this will make them dirty
+ // If any inputs have not been touched yet, this will make them dirty,
// so validation becomes visible (if based on isPristine)
this.setFormPristine(false);
const model = this.getModel();
@@ -323,8 +330,8 @@ export class Formsy extends Component {
}
};
- // Go through errors from server and grab the components
- // stored in the inputs map. Change their state to invalid
+ // Go through errors from the server and grab the components
+ // stored in the input map. Change their state to invalid
// and set the serverError message
updateInputsWithError: IUpdateInputsWithError = (errors, invalidate): void => {
if (!this._mounted) return;
@@ -359,7 +366,7 @@ export class Formsy extends Component {
});
};
- // Use the binded values and the actual input value to
+ // 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): void => {
diff --git a/packages/components/src/internal/components/forms/input/AmountUnitInput.test.tsx b/packages/components/src/internal/components/forms/input/AmountUnitInput.test.tsx
index 4c6c6e7678..e151e5b90d 100644
--- a/packages/components/src/internal/components/forms/input/AmountUnitInput.test.tsx
+++ b/packages/components/src/internal/components/forms/input/AmountUnitInput.test.tsx
@@ -4,67 +4,49 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
-import { AmountUnitInput } from './AmountUnitInput';
import { ExtendedMap } from '../../../../public/ExtendedMap';
import { QueryColumn } from '../../../../public/QueryColumn';
import { Formsy } from '../formsy/index';
+import { AmountUnitInput } from './AmountUnitInput';
+import { InputRendererProps } from './types';
describe('AmountUnitInput', () => {
- const amountCol = { name: 'StoredAmount', caption: 'amount', fieldKey: 'amountKey' };
- const unitCol = {
+ const amountCol = new QueryColumn({ name: 'StoredAmount', caption: 'amount', fieldKey: 'amountKey' });
+ const unitCol = new QueryColumn({
name: 'Units',
caption: 'unit',
fieldKey: 'unitKey',
lookup: {
hasQueryFilters: jest.fn(),
- displayColumn: new QueryColumn({ caption: 'test' }),
+ displayColumn: 'test',
},
- };
- const data = { StoredAmount: 12.5, Units: 'mg' };
- const allColumns = new ExtendedMap({
- [amountCol.fieldKey]: amountCol,
- [unitCol.fieldKey]: unitCol,
});
- const CAN_DISABLE: any = {
- allowFieldDisable: true,
- onSelectChange: jest.fn(),
- onToggleDisable: jest.fn(),
- initiallyDisabled: false,
- containerFilter: undefined,
- containerPath: undefined,
- allColumns,
- data,
- queryFilters: {},
- };
-
- const DISABLED: any = {
- ...CAN_DISABLE,
- initiallyDisabled: true,
- };
-
- const NOT_DISABLABLE: any = {
- ...CAN_DISABLE,
- allowFieldDisable: false,
- };
+ function defaultProps(): InputRendererProps {
+ return {
+ allColumns: new ExtendedMap({
+ [amountCol.fieldKey]: amountCol,
+ [unitCol.fieldKey]: unitCol,
+ }),
+ allowFieldDisable: true,
+ col: undefined,
+ containerFilter: undefined,
+ containerPath: undefined,
+ data: { StoredAmount: 12.5, Units: 'mg' },
+ initiallyDisabled: false,
+ onSelectChange: jest.fn(),
+ onToggleDisable: jest.fn(),
+ queryFilters: {},
+ value: undefined,
+ };
+ }
test('returns null when required columns are missing', () => {
// Missing unit column
-
const someColumns = new ExtendedMap({ [amountCol.fieldKey]: amountCol });
const { container } = render(
-
+
);
@@ -75,102 +57,106 @@ describe('AmountUnitInput', () => {
test('with amount and unit column, can disable', () => {
render(
-
+
);
expect(document.querySelectorAll('.form-group.row')).toHaveLength(1);
expect(document.querySelectorAll('.control-label')).toHaveLength(1);
- expect(document.querySelectorAll('.control-label')[0].textContent).toBe('Amount and Units');
+ expect(document.querySelectorAll('.control-label')[0]).toHaveTextContent('Amount and Units');
expect(document.querySelectorAll('label')).toHaveLength(0);
expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(1);
expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(0);
const inputs = document.querySelectorAll('input');
expect(inputs).toHaveLength(4);
- expect(inputs[0].getAttribute('value')).toBe('true');
- expect(inputs[0].getAttribute('type')).toBe('hidden');
- expect(inputs[0].getAttribute('name')).toBe('StoredAmount::enabled');
- expect(inputs[1].getAttribute('value')).toBe('12.5');
- expect(inputs[1].getAttribute('name')).toBe('amountKey');
- expect(inputs[2].getAttribute('role')).toBe('combobox');
- expect(inputs[3].getAttribute('name')).toBe('Units::enabled');
- expect(inputs[3].getAttribute('value')).toBe('true');
- expect(inputs[3].getAttribute('type')).toBe('hidden');
- expect(inputs[1].getAttribute('placeholder')).toBe('Enter amount');
- expect(document.querySelector('.select-input__placeholder').textContent).toBe('Select or type to search...');
+ expect(inputs[0]).toHaveAttribute('value', 'true');
+ expect(inputs[0]).toHaveAttribute('type', 'hidden');
+ expect(inputs[0]).toHaveAttribute('name', 'StoredAmount::enabled');
+ expect(inputs[1]).toHaveAttribute('value', '12.5');
+ expect(inputs[1]).toHaveAttribute('name', 'amountKey');
+ expect(inputs[1]).toHaveAttribute('placeholder', 'Enter amount');
+ expect(inputs[2]).toHaveAttribute('role', 'combobox');
+ expect(inputs[3]).toHaveAttribute('name', 'Units::enabled');
+ expect(inputs[3]).toHaveAttribute('value', 'true');
+ expect(inputs[3]).toHaveAttribute('type', 'hidden');
+ expect(document.querySelector('.select-input__placeholder')).toHaveTextContent('Select or type to search...');
});
test('with amount and unit column, can disable and disabled', () => {
render(
-
+
);
expect(document.querySelectorAll('.form-group.row')).toHaveLength(1);
expect(document.querySelectorAll('.control-label')).toHaveLength(1);
- expect(document.querySelectorAll('.control-label')[0].textContent).toBe('Amount and Units');
+ expect(document.querySelectorAll('.control-label')[0]).toHaveTextContent('Amount and Units');
expect(document.querySelectorAll('label')).toHaveLength(0);
expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(0);
expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(1);
const inputs = document.querySelectorAll('input');
expect(inputs).toHaveLength(4);
- expect(inputs[0].getAttribute('value')).toBe('false');
- expect(inputs[0].getAttribute('type')).toBe('hidden');
- expect(inputs[0].getAttribute('name')).toBe('StoredAmount::enabled');
- expect(inputs[1].getAttribute('value')).toBe('12.5');
- expect(inputs[1].getAttribute('name')).toBe('amountKey');
- expect(inputs[1].getAttribute('placeholder')).toBe('Enter amount');
- expect(inputs[2].getAttribute('role')).toBe('combobox');
- expect(inputs[3].getAttribute('name')).toBe('Units::enabled');
- expect(inputs[3].getAttribute('value')).toBe('false');
- expect(inputs[3].getAttribute('type')).toBe('hidden');
- expect(document.querySelector('.select-input__placeholder').textContent).toBe('Select or type to search...');
+ expect(inputs[0]).toHaveAttribute('value', 'false');
+ expect(inputs[0]).toHaveAttribute('type', 'hidden');
+ expect(inputs[0]).toHaveAttribute('name', 'StoredAmount::enabled');
+ expect(inputs[1]).toHaveAttribute('value', '12.5');
+ expect(inputs[1]).toHaveAttribute('name', 'amountKey');
+ expect(inputs[1]).toHaveAttribute('placeholder', 'Enter amount');
+ expect(inputs[2]).toHaveAttribute('role', 'combobox');
+ expect(inputs[3]).toHaveAttribute('name', 'Units::enabled');
+ expect(inputs[3]).toHaveAttribute('value', 'false');
+ expect(inputs[3]).toHaveAttribute('type', 'hidden');
+ expect(document.querySelector('.select-input__placeholder')).toHaveTextContent('Select or type to search...');
});
test('with amount and unit column, can disable and disabled, has mixed value', () => {
render(
-
+
);
expect(document.querySelectorAll('.form-group.row')).toHaveLength(1);
expect(document.querySelectorAll('.control-label')).toHaveLength(1);
- expect(document.querySelectorAll('.control-label')[0].textContent).toBe('Amount and Units');
+ expect(document.querySelectorAll('.control-label')[0]).toHaveTextContent('Amount and Units');
expect(document.querySelectorAll('label')).toHaveLength(0);
expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(0);
expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(1);
const inputs = document.querySelectorAll('input');
expect(inputs).toHaveLength(4);
- expect(inputs[0].getAttribute('value')).toBe('false');
- expect(inputs[0].getAttribute('type')).toBe('hidden');
- expect(inputs[0].getAttribute('name')).toBe('StoredAmount::enabled');
- expect(inputs[1].getAttribute('value')).toBe('12.5');
- expect(inputs[1].getAttribute('name')).toBe('amountKey');
- expect(inputs[1].getAttribute('placeholder')).toBe('[Mixed]');
- expect(inputs[2].getAttribute('role')).toBe('combobox');
- expect(inputs[3].getAttribute('name')).toBe('Units::enabled');
- expect(inputs[3].getAttribute('value')).toBe('false');
- expect(inputs[3].getAttribute('type')).toBe('hidden');
- expect(document.querySelector('.select-input__placeholder').textContent).toBe('[Mixed]');
+ expect(inputs[0]).toHaveAttribute('value', 'false');
+ expect(inputs[0]).toHaveAttribute('type', 'hidden');
+ expect(inputs[0]).toHaveAttribute('name', 'StoredAmount::enabled');
+ expect(inputs[1]).toHaveAttribute('value', '12.5');
+ expect(inputs[1]).toHaveAttribute('name', 'amountKey');
+ expect(inputs[1]).toHaveAttribute('placeholder', '[Mixed]');
+ expect(inputs[2]).toHaveAttribute('role', 'combobox');
+ expect(inputs[3]).toHaveAttribute('name', 'Units::enabled');
+ expect(inputs[3]).toHaveAttribute('value', 'false');
+ expect(inputs[3]).toHaveAttribute('type', 'hidden');
+ expect(document.querySelector('.select-input__placeholder')).toHaveTextContent('[Mixed]');
});
test('with amount and unit column, cannot disable', () => {
render(
-
+
);
expect(document.querySelectorAll('.form-group.row')).toHaveLength(1);
expect(document.querySelectorAll('.control-label')).toHaveLength(1);
- expect(document.querySelectorAll('.control-label')[0].textContent).toBe('Amount and Units');
+ expect(document.querySelectorAll('.control-label')[0]).toHaveTextContent('Amount and Units');
expect(document.querySelectorAll('label')).toHaveLength(0);
expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(0);
expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(0);
const inputs = document.querySelectorAll('input');
expect(inputs).toHaveLength(2);
- expect(inputs[0].getAttribute('value')).toBe('12.5');
- expect(inputs[0].getAttribute('name')).toBe('amountKey');
- expect(inputs[1].getAttribute('role')).toBe('combobox');
- expect(inputs[0].getAttribute('placeholder')).toBe('Enter amount');
- expect(document.querySelector('.select-input__placeholder').textContent).toBe('Select or type to search...');
+ expect(inputs[0]).toHaveAttribute('value', '12.5');
+ expect(inputs[0]).toHaveAttribute('name', 'amountKey');
+ expect(inputs[0]).toHaveAttribute('placeholder', 'Enter amount');
+ expect(inputs[1]).toHaveAttribute('role', 'combobox');
+ expect(document.querySelector('.select-input__placeholder')).toHaveTextContent('Select or type to search...');
});
});
diff --git a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx
index b6d4e2e36c..7ea8e33ef8 100644
--- a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx
+++ b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx
@@ -52,7 +52,7 @@ export const AmountUnitInput: FC = memo(props => {
onToggleDisable?.(newDisabled);
return newDisabled;
});
- }, [setDisabled]);
+ }, [onToggleDisable]);
const onAmountChange = useCallback((name: string, value: any) => {
const errorMsg = getInvalidSampleAmountMessage(value);
@@ -99,14 +99,14 @@ export const AmountUnitInput: FC = memo(props => {
hasMixedValue={hasMixedAmountValue}
onChange={onAmountChange}
queryColumn={amountCol}
- rowClassName={'col-sm-5 col-xs-6'}
+ rowClassName="col-sm-5 col-xs-6"
showLabel={false}
type="number"
validations="sampleAmount"
value={amountValue ? String(amountValue) : amountValue}
/>
= memo(props => {
hasMixedValue={hasMixedUnitValue}
id={id}
inputClass={''}
- name={unitCol.fieldKey}
maxRows={LOOKUP_DEFAULT_SIZE}
+ name={unitCol.fieldKey}
onQSChange={onSelectChange}
placeholder="Select or type to search..."
queryFilters={queryFilter}
@@ -141,5 +141,4 @@ export const AmountUnitInput: FC = memo(props => {
>
);
});
-
AmountUnitInput.displayName = 'AmountUnitInput';
diff --git a/packages/components/src/internal/components/forms/input/FileInput.tsx b/packages/components/src/internal/components/forms/input/FileInput.tsx
index 039310a12c..721ea587ff 100644
--- a/packages/components/src/internal/components/forms/input/FileInput.tsx
+++ b/packages/components/src/internal/components/forms/input/FileInput.tsx
@@ -21,6 +21,7 @@ import { fileMatchesAcceptedFormat } from '../../files/actions';
import { getTransferItemDirectoryEntry } from '../../files/FileAttachmentContainer';
import { DisableableInput, DisableableInputProps, DisableableInputState } from './DisableableInput';
+import { generateId } from '../../../util/utils';
type FileInputData = Map | string | undefined;
@@ -70,6 +71,7 @@ interface State extends DisableableInputState {
class FileInputImpl extends DisableableInput {
fileInput: RefObject;
+ inputId: string;
static defaultProps = {
...DisableableInput.defaultProps,
@@ -84,6 +86,8 @@ class FileInputImpl extends DisableableInput {
super(props);
this.toggleDisabled = this.toggleDisabled.bind(this);
+ // Issue 53394: Distinct input ID so it does not collide with other elements on the page
+ this.inputId = generateId('fileUpload-');
this.fileInput = React.createRef();
const { data, formValue } = initializeValue(props.initialValue);
@@ -101,8 +105,6 @@ class FileInputImpl extends DisableableInput {
}
getInputName(): string {
- // FIXME if there's more than one of these on the page with the same inputName
- // files will go to the wrong place when uploaded unless the names are unique
return this.props.name ?? this.props.queryColumn.fieldKey;
}
@@ -199,14 +201,14 @@ class FileInputImpl extends DisableableInput {
} = this.props;
const { data, error, file, isDisabled, isHover } = this.state;
- const name = this.getInputName();
- const inputId = `${name}-fileUpload`; // Issue 53394: needs to be a distinct input id so it doesn't collide with other elements on the page for this fieldKey
- let body;
+ let body: ReactNode;
if (file || typeof data === 'string') {
body = (
{
- {/* We render a label here so click and drag events propagate to the input above */}
+ {/* We render a label here, so click and drag events propagate to the input above */}
{
const labelOverlayProps = {
addLabelAsterisk,
- dataKey: inputId,
- // While this component supports binding Formsy it does not use a Formsy component
+ dataKey: this.getInputName(),
+ // While this component supports binding Formsy, it does not use a Formsy component
// to render the associated label. As such, the label overlay is always configured as isFormsy={false}.
isFormsy: false,
labelClass: labelClassName,
diff --git a/packages/components/src/internal/components/forms/input/SelectInput.test.tsx b/packages/components/src/internal/components/forms/input/SelectInput.test.tsx
index ed5b5d1b85..1227f02482 100644
--- a/packages/components/src/internal/components/forms/input/SelectInput.test.tsx
+++ b/packages/components/src/internal/components/forms/input/SelectInput.test.tsx
@@ -3,18 +3,29 @@
* in any form or by any electronic or mechanical means without written permission from LabKey Corporation.
*/
import React from 'react';
-import { render } from '@testing-library/react';
+import { render, waitFor } from '@testing-library/react';
-
-import { initOptions, SelectInputImpl, SelectInputProps } from './SelectInput';
+import { initOptions, SelectInputImpl, SelectInputImplProps } from './SelectInput';
describe('SelectInput', () => {
- function getDefaultProps(): Partial {
+ function defaultProps(): SelectInputImplProps {
return {
+ errorMessage: undefined,
+ errorMessages: undefined,
formsy: true,
- getErrorMessage: jest.fn(),
- getValue: jest.fn(),
+ hasValue: true,
+ isFormDisabled: false,
+ isFormSubmitted: false,
+ isPristine: true,
+ isRequired: false,
+ isValid: true,
+ isValidValue: jest.fn(),
+ name: 'select-input-field',
+ resetValue: jest.fn(),
setValue: jest.fn(),
+ setValidations: jest.fn(),
+ showError: false,
+ showRequired: false,
};
}
@@ -22,11 +33,9 @@ describe('SelectInput', () => {
const containerCls = 'container-class-test';
const inputCls = 'input-class-test';
- render(
-
- );
- expect(document.querySelectorAll('.' + containerCls).length).toBe(1);
- expect(document.querySelectorAll('.' + inputCls).length).toBe(1);
+ render( );
+ expect(document.querySelectorAll('.' + containerCls)).toHaveLength(1);
+ expect(document.querySelectorAll('.' + inputCls)).toHaveLength(1);
});
// TODO convert those 2 tests?
@@ -81,26 +90,62 @@ describe('SelectInput', () => {
const customLabel = 'Jest Custom Label Test';
test('renderFieldLabel', () => {
- const component = render( );
+ const component = render( );
validateFieldLabel(component, defaultLabel + ' ');
});
test('renderFieldLabel, customLabel', () => {
- const component = render( {customLabel}
} />);
+ const component = render(
+ {customLabel}
}
+ showLabel
+ />
+ );
validateFieldLabel(component, customLabel);
});
test('renderFieldLabel, required', () => {
- const component = render( );
+ const component = render(
+
+ );
validateFieldLabel(component, defaultLabel + ' * ');
});
test('renderFieldLabel, showLabel=false', () => {
- const component = render( );
+ const component = render(
+
+ );
validateFieldLabel(component);
});
-
});
+ describe('cacheKey', () => {
+ test('reloads async options when changed', async () => {
+ const loadOptions = jest.fn().mockResolvedValue([]);
+ const props = defaultProps();
+
+ const { rerender } = render( );
+ await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
+
+ // Re-rendering with an unchanged cacheKey should not reload options
+ rerender( );
+ expect(loadOptions).toHaveBeenCalledTimes(1);
+
+ // Changing the cacheKey remounts the underlying async select, reloading the default options
+ rerender( );
+ await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
+ });
+
+ test('ignored for non-async configurations', () => {
+ const props = defaultProps();
+
+ const { rerender } = render( );
+ rerender( );
+
+ expect(document.querySelectorAll('.select-input')).toHaveLength(1);
+ });
+ });
describe('initOptions', () => {
test('empty values', () => {
diff --git a/packages/components/src/internal/components/forms/input/SelectInput.tsx b/packages/components/src/internal/components/forms/input/SelectInput.tsx
index f7ccbf47c6..146df4298d 100644
--- a/packages/components/src/internal/components/forms/input/SelectInput.tsx
+++ b/packages/components/src/internal/components/forms/input/SelectInput.tsx
@@ -2,8 +2,18 @@
* Copyright (c) 2019-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 React, { Component, CSSProperties, FC, FocusEvent, KeyboardEvent, ReactNode } from 'react';
-import ReactSelect, { components } from 'react-select';
+import React, {
+ Component,
+ CSSProperties,
+ FC,
+ FocusEvent,
+ KeyboardEvent,
+ ReactNode,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from 'react';
+import ReactSelect, { components, MenuListProps, SelectInstance } from 'react-select';
import AsyncSelect from 'react-select/async';
import AsyncCreatableSelect from 'react-select/async-creatable';
import CreatableSelect from 'react-select/creatable';
@@ -122,6 +132,45 @@ const CustomOption = props => {
);
};
+interface MenuListWithFooterProps extends MenuListProps {
+ footer: ReactNode;
+}
+
+// ReactSelect's computes a "maxHeight" for the menu by measuring the rendered menu element (which
+// includes our footer); however, it only applies that value to the scrollable menu list. Anything rendered alongside
+// the list, such as our footer, is additive which causes the menu to extend past the space that was budgeted for it
+// (e.g., off the bottom of the viewport). Here we measure the footer and subtract its height from the "maxHeight"
+// given to the list so the menu, as a whole, honors the computed budget.
+const MenuListWithFooter: FC = ({ footer, ...menuListProps }) => {
+ const footerRef = useRef(null);
+ const [footerHeight, setFooterHeight] = useState(0);
+
+ useLayoutEffect(() => {
+ const el = footerRef.current;
+ if (!el) return undefined;
+
+ const measure = (): void => setFooterHeight(el.getBoundingClientRect().height);
+ measure();
+
+ const observer = new ResizeObserver(measure);
+ observer.observe(el);
+
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+ <>
+
+ {menuListProps.children}
+
+
+ {footer}
+
+ >
+ );
+};
+MenuListWithFooter.displayName = 'MenuListWithFooter';
+
// Molded from @types/react-select/src/filter.d.ts
export interface SelectInputOption extends Record {
data?: any;
@@ -138,6 +187,9 @@ export type SelectInputChange = (
props: Partial
) => void;
+export type SelectInputOnFocus = (event: FocusEvent, select: SelectInstance) => void;
+export type SelectInputOnKeyDown = (event: KeyboardEvent, select: SelectInstance) => void;
+
// Copied from @types/react-select/src/Select.d.ts
export type FilterOption = ((option: SelectInputOption, rawInput: string) => boolean) | null;
@@ -161,7 +213,7 @@ function initOptionFromPrimitive(value: number | string, props: SelectInputProps
}
// Used to initialize the selected options in `state` when `autoValue` is enabled.
-// This will accept a primitive value (e.g. 5) and resolve it to an option (e.g. { label: 'Awesome', value: 5 })
+// This will accept a primitive value (e.g., 5) and resolve it to an option (e.g., { label: 'Awesome', value: 5 })
// if the option is available. Supports mapping single or multiple values.
export function initOptions(props: SelectInputProps): SelectInputOption | SelectInputOption[] {
const { value, options } = props;
@@ -200,6 +252,13 @@ export interface SelectInputProps {
autoFocus?: boolean;
autoValue?: boolean;
backspaceRemovesValue?: boolean;
+ /**
+ * When the value of this prop changes, the underlying asynchronous React Select is remounted, clearing
+ * its cached options and reloading the default options. Use this to invalidate previously loaded options
+ * when the option set is known to have changed (e.g., a new option was created). Only applies to
+ * asynchronous configurations (i.e., when "loadOptions" is provided).
+ */
+ cacheKey?: number | string;
cacheOptions?: boolean;
clearable?: boolean;
clearCacheOnChange?: boolean;
@@ -231,6 +290,7 @@ export interface SelectInputProps {
labelClass?: string;
labelKey?: string;
loadOptions?: (input: string) => Promise;
+ menuFooter?: ReactNode;
menuPlacement?: string;
menuPosition?: string;
multiple?: boolean;
@@ -239,8 +299,8 @@ export interface SelectInputProps {
onBlur?: (event: FocusEvent) => void;
// TODO: this is getting confused with formsy on change, need to separate
onChange?: SelectInputChange;
- onFocus?: (event: FocusEvent, selectRef) => void;
- onKeyDown?: (event: KeyboardEvent) => void;
+ onFocus?: SelectInputOnFocus;
+ onKeyDown?: SelectInputOnKeyDown;
onToggleDisable?: (disabled: boolean) => void;
openMenuOnClick?: boolean;
openMenuOnFocus?: boolean;
@@ -266,10 +326,10 @@ export interface SelectInputProps {
warning?: ReactNode;
}
-type SelectInputImplProps = FormsyInjectedProps & SelectInputProps;
+export type SelectInputImplProps = FormsyInjectedProps & SelectInputProps;
interface State {
- // This state property is used in conjunction with the prop "clearCacheOnChange" which when true
+ // This state property is used in conjunction with the prop "clearCacheOnChange", which when true,
// is intended to clear the underlying asynchronous React Select's cache.
// See https://github.com/JedWatson/react-select/issues/1879
asyncKey: number;
@@ -299,7 +359,7 @@ export class SelectInputImpl extends Component {
labelClass: INPUT_LABEL_CLASS_NAME,
menuPlacement: 'auto',
// Default to 'fixed' because 'absolute' causes issues in several scenarios (Modals, EditableGrid) but it's too
- // difficult to manually set it to fixed in all of these situations (e.g. we don't always know we're in a modal)
+ // difficult to manually set it to fixed in all of these situations (e.g., we don't always know we're in a modal)
menuPosition: 'fixed',
openMenuOnFocus: false,
saveOnBlur: false,
@@ -315,7 +375,7 @@ export class SelectInputImpl extends Component {
private _isMounted: boolean;
private _defaultValueLoaded = false;
private CHANGE_LOCK = false;
- private reactSelect: React.RefObject;
+ private reactSelect: React.RefObject;
constructor(props: SelectInputImplProps) {
super(props);
@@ -347,6 +407,10 @@ export class SelectInputImpl extends Component {
this.setState({ originalOptions: selectedOptions, selectedOptions });
}
+ if (this.isAsync() && prevProps.cacheKey !== this.props.cacheKey) {
+ this.setState(state => ({ asyncKey: state.asyncKey + 1 }));
+ }
+
this.CHANGE_LOCK = false;
}
@@ -411,10 +475,14 @@ export class SelectInputImpl extends Component {
}
};
- handleFocus = (event): void => {
+ handleFocus = (event: FocusEvent): void => {
this.props.onFocus?.(event, this.reactSelect.current);
};
+ handleKeyDown = (event: KeyboardEvent): void => {
+ this.props.onKeyDown?.(event, this.reactSelect.current);
+ };
+
isAsync = (): boolean => {
return !!this.props.loadOptions;
};
@@ -570,14 +638,14 @@ export class SelectInputImpl extends Component {
getOptionValue = (option: SelectInputOption): any => option[this.props.valueKey];
Input = inputProps => {
- // React-select in an async configuration has a bug where when a defaultInputValue prop is supplied it
+ // React-select in an async configuration has a bug where when a defaultInputValue prop is supplied, it
// does not fire an onChange event on the underlying input which results in loadOptions() never being called
// with the supplied value. Here we simulate an onChange() event ourselves to induce the expected loading logic.
// See https://github.com/JedWatson/react-select/issues/3047
if (!this._defaultValueLoaded) {
this._defaultValueLoaded = true;
if (this.props.defaultInputValue && this.isAsync()) {
- // To avoid performing updates during the render cycle utilize a setTimeout() to defer execution.
+ // To avoid performing updates during the render cycle, utilize a setTimeout() to defer execution.
// Normally, this could be done in componentDidMount(), however, we need access to the inputProps.
setTimeout(() => {
const inputEl = document.getElementById(inputProps.id);
@@ -595,10 +663,14 @@ export class SelectInputImpl extends Component {
// Marking input as "required" is not natively supported by react-select post-v1. Here we can mark
// the underlying input as required, however, this is not the value input but rather the user visible
- // input so we manually check if a value is set.
+ // input, so we manually check if a value is set.
return ;
};
+ MenuList = (menuListProps: MenuListProps) => {
+ return ;
+ };
+
Option = optionProps => {this.props.optionRenderer(optionProps)} ;
noOptionsMessage = (): ReactNode => this.props.noResultsText;
@@ -623,11 +695,11 @@ export class SelectInputImpl extends Component {
isLoading,
isValidNewOption,
labelKey,
+ menuFooter,
menuPlacement,
menuPosition,
multiple,
name,
- onKeyDown,
openMenuOnClick,
openMenuOnFocus,
optionRenderer,
@@ -654,6 +726,8 @@ export class SelectInputImpl extends Component {
if (!showDropdownMenu) {
components.Menu = nullComponent;
+ } else if (menuFooter) {
+ components.MenuList = this.MenuList;
}
if (optionRenderer) {
@@ -702,7 +776,7 @@ export class SelectInputImpl extends Component {
onBlur: this.handleBlur,
onChange: this.handleChange,
onFocus: this.handleFocus,
- onKeyDown,
+ onKeyDown: this.handleKeyDown,
openMenuOnClick,
openMenuOnFocus,
options,
diff --git a/packages/components/src/internal/components/forms/model.test.ts b/packages/components/src/internal/components/forms/model.test.ts
index adc6311c2b..48dfeda310 100644
--- a/packages/components/src/internal/components/forms/model.test.ts
+++ b/packages/components/src/internal/components/forms/model.test.ts
@@ -2,14 +2,37 @@
* Copyright (c) 2022-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 { fromJS } from 'immutable';
+import { fromJS, List } from 'immutable';
import { Filter } from '@labkey/api';
import { QueryInfo } from '../../../public/QueryInfo';
import { ExtendedMap } from '../../../public/ExtendedMap';
import { QueryColumn } from '../../../public/QueryColumn';
-
-import { buildValueFilter, findNotFoundValues, parseSelectedQuery, QuerySelectModel, queryColumnNames } from './model';
+import { SchemaQuery } from '../../../public/SchemaQuery';
+
+import { ISelectRowsResult, selectRowsDeprecated } from '../../query/api';
+
+import { Row } from '../../query/selectRows';
+
+import {
+ appendMultiValues,
+ buildValueFilter,
+ fetchSelectedValues,
+ findNotFoundValues,
+ getAddedSelectionValue,
+ parseRawValue,
+ parseSelectedQuery,
+ queryColumnNames,
+ QuerySelectModel,
+ setSelection,
+ setSelectionWithResults,
+ valuesAreLoaded,
+} from './model';
+
+jest.mock('../../query/api', () => ({
+ ...jest.requireActual('../../query/api'),
+ selectRowsDeprecated: jest.fn(),
+}));
describe('form actions', () => {
const setSelectionModel = new QuerySelectModel({
@@ -209,4 +232,256 @@ describe('form actions', () => {
expect(findNotFoundValues(mixedTypes, filter([1, 2, 3]), 'id')).toEqual(['3']);
});
});
+
+ describe('parseRawValue', () => {
+ test('empty values', () => {
+ expect(parseRawValue(undefined, false, ',')).toEqual([]);
+ expect(parseRawValue(null, true, ',')).toEqual([]);
+ expect(parseRawValue('', true, ',')).toEqual([]);
+ });
+
+ test('scalar values', () => {
+ expect(parseRawValue(5, false, ',')).toEqual([5]);
+ expect(parseRawValue('word', false, ',')).toEqual(['word']);
+ expect(parseRawValue(false, false, ',')).toEqual([false]);
+ });
+
+ test('array and List values', () => {
+ expect(parseRawValue([1, 2], true, ',')).toEqual([1, 2]);
+ expect(parseRawValue(List([1, 2]), true, ',')).toEqual([1, 2]);
+ });
+
+ test('delimited string values', () => {
+ expect(parseRawValue('a,b', true, ',')).toEqual(['a', 'b']);
+ expect(parseRawValue('a;b', true, ';')).toEqual(['a', 'b']);
+ // when not multiple, strings are not split
+ expect(parseRawValue('a,b', false, ',')).toEqual(['a,b']);
+ });
+ });
+
+ describe('appendMultiValues', () => {
+ test('empty existing selection', () => {
+ expect(appendMultiValues(undefined, [1, 2], ',')).toEqual('1,2');
+ expect(appendMultiValues(null, [5], ',')).toEqual('5');
+ expect(appendMultiValues('', [5], ',')).toEqual('5');
+ });
+
+ test('appends new values', () => {
+ expect(appendMultiValues('1,2', [3], ',')).toEqual('1,2,3');
+ expect(appendMultiValues([1, 2], [3, 4], ',')).toEqual('1,2,3,4');
+ });
+
+ test('skips values already selected (string/number equality)', () => {
+ // 2 is already selected (as the string "2"), so it is not appended again
+ expect(appendMultiValues('1,2', [2], ',')).toEqual('1,2');
+ expect(appendMultiValues('1,2', [2, 3], ',')).toEqual('1,2,3');
+ expect(appendMultiValues([1, 2], [2, 3], ',')).toEqual('1,2,3');
+ });
+
+ test('de-dupes repeats within addedValues', () => {
+ expect(appendMultiValues('1', [4, 4], ',')).toEqual('1,4');
+ });
+
+ test('empty additions returns the existing selection', () => {
+ expect(appendMultiValues('1,2', [], ',')).toEqual('1,2');
+ expect(appendMultiValues('1,2', undefined, ',')).toEqual('1,2');
+ });
+
+ test('respects the delimiter', () => {
+ expect(appendMultiValues('a;b', ['c'], ';')).toEqual('a;b;c');
+ expect(appendMultiValues('a;b', ['b', 'c'], ';')).toEqual('a;b;c');
+ });
+ });
+
+ const loadedResults = fromJS({
+ '1': { RowId: { value: 1 }, Name: { value: 'Alpha' } },
+ '2': { RowId: { value: 2 }, Name: { value: 'Beta' } },
+ });
+
+ const singleModel = new QuerySelectModel({
+ allResults: loadedResults,
+ delimiter: ',',
+ displayColumn: 'Name',
+ isInit: true,
+ valueColumn: 'RowId',
+ });
+
+ const multiModel = singleModel.merge({ multiple: true }) as QuerySelectModel;
+
+ const KEY = new SchemaQuery('test', 'query').getKey();
+
+ function makeResult(rows: Record): ISelectRowsResult {
+ return {
+ key: KEY,
+ models: { [KEY]: rows },
+ orderedModels: List(Object.keys(rows)),
+ queries: {},
+ rowCount: Object.keys(rows).length,
+ };
+ }
+
+ describe('valuesAreLoaded', () => {
+ test('empty value', () => {
+ expect(valuesAreLoaded(singleModel, undefined)).toBe(true);
+ expect(valuesAreLoaded(singleModel, null)).toBe(true);
+ expect(valuesAreLoaded(singleModel, '')).toBe(true);
+ });
+
+ test('single value', () => {
+ expect(valuesAreLoaded(singleModel, 1)).toBe(true);
+ expect(valuesAreLoaded(singleModel, '1')).toBe(true);
+ expect(valuesAreLoaded(singleModel, 3)).toBe(false);
+ });
+
+ test('multiple values', () => {
+ expect(valuesAreLoaded(multiModel, [1, 2])).toBe(true);
+ expect(valuesAreLoaded(multiModel, '1,2')).toBe(true);
+ expect(valuesAreLoaded(multiModel, [1, 3])).toBe(false);
+ expect(valuesAreLoaded(multiModel, '1,3')).toBe(false);
+ });
+
+ test('resolves against selectedItems', () => {
+ const model = new QuerySelectModel({
+ delimiter: ',',
+ displayColumn: 'Name',
+ isInit: true,
+ selectedItems: fromJS({ '9': { RowId: { value: 9 }, Name: { value: 'Iota' } } }),
+ valueColumn: 'RowId',
+ });
+ expect(valuesAreLoaded(model, 9)).toBe(true);
+ expect(valuesAreLoaded(model, 1)).toBe(false);
+ });
+ });
+
+ describe('getAddedSelectionValue', () => {
+ const makeResponse = (values: (number | string)[]): Row[] => values.map(value => ({ RowId: { value } }));
+
+ test('single-select returns the first added value', () => {
+ expect(getAddedSelectionValue(singleModel, makeResponse([7, 8]))).toEqual(7);
+ });
+
+ test('multi-select appends added values to the current selection', () => {
+ const model = multiModel.merge({ rawSelectedValue: '1,2' }) as QuerySelectModel;
+ expect(getAddedSelectionValue(model, makeResponse([3, 4]))).toEqual('1,2,3,4');
+ });
+
+ test('multi-select skips added values already selected', () => {
+ const model = multiModel.merge({ rawSelectedValue: '1,2' }) as QuerySelectModel;
+ expect(getAddedSelectionValue(model, makeResponse([2, 3]))).toEqual('1,2,3');
+ });
+
+ test('multi-select with no current selection joins the added values', () => {
+ expect(getAddedSelectionValue(multiModel, makeResponse([3, 4]))).toEqual('3,4');
+ });
+ });
+
+ describe('setSelection', () => {
+ test('resolves single value across types', () => {
+ const model = setSelection(singleModel, '2');
+ expect(model.rawSelectedValue).toBe('2');
+ expect(model.selectedItems.size).toBe(1);
+ expect(model.selectedItems.getIn(['2', 'RowId', 'value'])).toBe(2);
+ expect(model.selectedQuery).toBe('Beta');
+ });
+
+ test('clears selection', () => {
+ const model = setSelection(setSelection(singleModel, 1), undefined);
+ expect(model.selectedItems.size).toBe(0);
+ expect(model.selectedQuery).toBe('');
+ });
+ });
+
+ describe('setSelectionWithResults', () => {
+ const gammaRow = { RowId: { value: 3 }, Name: { value: 'Gamma' } };
+
+ test('single value not previously loaded', () => {
+ const model = setSelectionWithResults(singleModel, makeResult({ '3': gammaRow }), 3, true);
+
+ expect(model.rawSelectedValue).toBe(3);
+ expect(model.allResults.size).toBe(3);
+ expect(model.selectedItems.size).toBe(1);
+ expect(model.selectedItems.getIn(['3', 'Name', 'value'])).toBe('Gamma');
+ expect(model.selectedQuery).toBe('Gamma');
+ });
+
+ test('multiple values appended to loaded values', () => {
+ const model = setSelectionWithResults(multiModel, makeResult({ '3': gammaRow }), '1,3', true);
+
+ expect(model.rawSelectedValue).toBe('1,3');
+ expect(model.allResults.size).toBe(3);
+ expect(model.selectedItems.size).toBe(2);
+ expect(model.selectedQuery).toBe('Alpha,Gamma');
+ // The previously loaded row resolves locally and is not marked as "not found"
+ expect(model.selectedItems.getIn(['1', 'RowId', 'notFound'])).toBeUndefined();
+ });
+
+ test('unresolved value marked as not found', () => {
+ const model = setSelectionWithResults(singleModel, makeResult({}), 99, true);
+
+ expect(model.rawSelectedValue).toBe(99);
+ expect(model.selectedItems.size).toBe(1);
+ expect(model.selectedItems.getIn(['99', 'RowId', 'notFound'])).toBe(true);
+ expect(model.selectedItems.getIn(['99', 'RowId', 'displayValue'])).toBe('<99>');
+ });
+
+ test('unresolved value skipped when notFoundValuesEnabled is false', () => {
+ const model = setSelectionWithResults(singleModel, makeResult({}), 99, false);
+
+ expect(model.rawSelectedValue).toBe(99);
+ expect(model.selectedItems.size).toBe(0);
+ });
+
+ test('partially resolved multiple values', () => {
+ const model = setSelectionWithResults(multiModel, makeResult({ '3': gammaRow }), '3,99', true);
+
+ expect(model.selectedItems.size).toBe(2);
+ expect(model.selectedItems.getIn(['3', 'Name', 'value'])).toBe('Gamma');
+ expect(model.selectedItems.getIn(['99', 'RowId', 'notFound'])).toBe(true);
+ });
+ });
+
+ describe('fetchSelectedValues', () => {
+ const selectRowsDeprecatedMock = selectRowsDeprecated as jest.Mock;
+
+ const fetchModel = singleModel.merge({
+ containerPath: '/Fetch/Test',
+ queryInfo: new QueryInfo({ pkCols: ['RowId'] }),
+ schemaQuery: new SchemaQuery('exp', 'samples'),
+ }) as QuerySelectModel;
+
+ beforeEach(() => {
+ selectRowsDeprecatedMock.mockReset();
+ selectRowsDeprecatedMock.mockResolvedValue(makeResult({}));
+ });
+
+ test('single value', async () => {
+ await fetchSelectedValues(fetchModel, 3);
+
+ expect(selectRowsDeprecatedMock).toHaveBeenCalledTimes(1);
+ const options = selectRowsDeprecatedMock.mock.calls[0][0];
+ expect(options.schemaName).toBe('exp');
+ expect(options.queryName).toBe('samples');
+ expect(options.containerPath).toBe('/Fetch/Test');
+ expect(options.columns).toEqual(expect.arrayContaining(['RowId', 'Name']));
+ expect(options.filterArray).toHaveLength(1);
+ expect(options.filterArray[0].getColumnName()).toBe('RowId');
+ expect(options.filterArray[0].getValue()).toBe(3);
+ });
+
+ test('multiple values with queryFilters', async () => {
+ const model = fetchModel.merge({
+ multiple: true,
+ queryFilters: List([Filter.create('Status', 'Active')]),
+ }) as QuerySelectModel;
+
+ await fetchSelectedValues(model, [1, 3]);
+
+ const options = selectRowsDeprecatedMock.mock.calls[0][0];
+ expect(options.filterArray).toHaveLength(2);
+ expect(options.filterArray[0].getColumnName()).toBe('Status');
+ expect(options.filterArray[1].getColumnName()).toBe('RowId');
+ expect(options.filterArray[1].getValue()).toEqual([1, 3]);
+ expect(options.filterArray[1].getFilterType().getURLSuffix()).toBe(Filter.Types.IN.getURLSuffix());
+ });
+ });
});
diff --git a/packages/components/src/internal/components/forms/model.ts b/packages/components/src/internal/components/forms/model.ts
index c67bd10bc4..22653bc518 100644
--- a/packages/components/src/internal/components/forms/model.ts
+++ b/packages/components/src/internal/components/forms/model.ts
@@ -11,7 +11,7 @@ import { SchemaQuery } from '../../../public/SchemaQuery';
import { getQueryDetails, ISelectRowsResult, searchRows, selectRowsDeprecated } from '../../query/api';
import { similaritySortFactory } from '../../util/similaritySortFactory';
-import { caseInsensitive, splitMultiValueForImport } from '../../util/utils';
+import { caseInsensitive, joinMultiValueForExport, splitMultiValueForImport } from '../../util/utils';
import { naturalSort } from '../../../public/sort';
@@ -94,7 +94,7 @@ function formatGroupedResults(model: QuerySelectModel, results: Map
}
/**
- * Given a model this method returns "options" that are consumable by a ReactSelect.
+ * Given a model, this method returns "options" that are consumable by a ReactSelect.
* @param model for which results are formatted
* @param result select rows result
* @param token an optional search token that will be used to sort the results
@@ -130,6 +130,45 @@ export function saveSearchResults(model: QuerySelectModel, result: ISelectRowsRe
}) as QuerySelectModel;
}
+/** Normalizes a raw selection value into an array of values. */
+export function parseRawValue(value: any, multiple: boolean, delimiter: string): any[] {
+ if (!validValue(value)) return [];
+ if (Array.isArray(value)) return value;
+ if (List.isList(value)) return (value as List).toArray();
+ if (multiple && typeof value === 'string') return splitMultiValueForImport(value, delimiter);
+ return [value];
+}
+
+/**
+ * Appends added value(s) to a raw multi-value selection, skipping values already present and de-duping repeats.
+ * Returns the joined delimited value.
+ */
+export function appendMultiValues(rawSelectedValue: any, addedValues: any[], delimiter: string): string {
+ const existing = parseRawValue(rawSelectedValue, true, delimiter);
+ const seen = new Set(existing.map(v => v?.toString()));
+ const additions = (addedValues ?? []).filter(v => {
+ const key = v?.toString();
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ return joinMultiValueForExport(existing.concat(additions), delimiter);
+}
+
+/**
+ * Resolves the selection value to apply after entities are added: for multi-select, the added rows' values
+ * appended to the current selection; for single-select, the first added row's value.
+ */
+export function getAddedSelectionValue(model: QuerySelectModel, rows: Row[]): string | string[] {
+ const addedValues = rows.map(row => resolveDetailFieldValue(caseInsensitive(row, model.valueColumn)));
+
+ if (model.multiple) {
+ return appendMultiValues(model.rawSelectedValue, addedValues, model.delimiter);
+ }
+
+ return addedValues[0];
+}
+
function getSelectedOptions(model: QuerySelectModel, value: any): Map {
// if no "value", just return currently selectedItems
if (value === undefined || value === null || value === '') {
@@ -142,7 +181,7 @@ function getSelectedOptions(model: QuerySelectModel, value: any): Map v.toString());
return sources
.filter(result => {
const resultValue = result.getIn(keyPath);
@@ -152,7 +191,7 @@ function getSelectedOptions(model: QuerySelectModel, value: any): Map source.getIn(keyPath) === value).toMap();
+ return sources.filter(source => source.getIn(keyPath)?.toString() === value.toString()).toMap();
}
// "selectedQuery" should match against displayColumn as that is what the user is typing against
@@ -374,31 +413,132 @@ function validValue(value: any): boolean {
return value !== undefined && value !== null && value !== '';
}
-function initSelectedItems(
- props: QuerySelectOwnProps,
- queryInfo: QueryInfo,
- valueColumn: string,
- displayColumn: string,
- groupByColumn: string,
- filter: Filter.IFilter
-): Promise {
- const filters = props.queryFilters ? props.queryFilters.toArray() : [];
+interface SelectValueRowsOptions {
+ columns: string[];
+ containerFilter?: Query.ContainerFilter;
+ containerPath?: string;
+ queryFilters?: List;
+ queryParams?: Record;
+ schemaQuery: SchemaQuery;
+}
+
+function selectValueRows(options: SelectValueRowsOptions, filter: Filter.IFilter): Promise {
+ const filters = options.queryFilters ? options.queryFilters.toArray() : [];
filters.push(filter);
- const { queryName, schemaName, viewName } = props.schemaQuery;
+ const { queryName, schemaName, viewName } = options.schemaQuery;
return selectRowsDeprecated({
- columns: queryColumnNames(queryInfo, displayColumn, valueColumn, props.requiredColumns, groupByColumn),
- containerFilter: props.containerFilter,
- containerPath: props.containerPath,
+ columns: options.columns,
+ containerFilter: options.containerFilter,
+ containerPath: options.containerPath,
filterArray: filters,
- parameters: props.queryParams,
+ parameters: options.queryParams,
queryName,
schemaName,
viewName,
});
}
+/**
+ * Fetches the row(s) matching the given selection value(s) using the model's configured columns and filters.
+ * Useful for resolving a value that is not present in the model's locally loaded results.
+ * @see setSelectionWithResults
+ */
+export function fetchSelectedValues(model: QuerySelectModel, value: any): Promise {
+ const { filter } = buildValueFilter(value, model.valueColumn, model.multiple, model.delimiter);
+
+ return selectValueRows(
+ {
+ columns: model.queryColumnNames,
+ containerFilter: model.containerFilter,
+ containerPath: model.containerPath,
+ queryFilters: model.queryFilters,
+ queryParams: model.queryParams,
+ schemaQuery: model.schemaQuery,
+ },
+ filter
+ );
+}
+
+function notFoundRow(valueColumn: string, value: string): Record {
+ return { [valueColumn]: { displayValue: `<${value}>`, notFound: true, value } };
+}
+
+/** Mutates "selectedRows" adding placeholder rows for any expected values that failed to resolve. */
+function applyNotFoundValues(
+ selectedRows: Record,
+ filter: Filter.IFilter,
+ valueColumn: string,
+ expectedValueCount: number
+): void {
+ if (!selectedRows || Object.keys(selectedRows).length === expectedValueCount) return;
+
+ findNotFoundValues(selectedRows, filter, valueColumn).forEach(v => {
+ if (!selectedRows.hasOwnProperty(v)) {
+ selectedRows[v] = notFoundRow(valueColumn, v);
+ }
+ });
+}
+
+/**
+ * Merges the rows from the given result into the model's results and then applies the selection of the
+ * given value. This leaves the model in a consistent state when selecting value(s) whose backing rows
+ * were not previously loaded into the model's local results.
+ * @see fetchSelectedValues
+ */
+export function setSelectionWithResults(
+ model: QuerySelectModel,
+ result: ISelectRowsResult,
+ value: any,
+ notFoundValuesEnabled?: boolean
+): QuerySelectModel {
+ let allResults = model.allResults.merge(fromJS(result.models[result.key]));
+ let updated = setSelection(model.merge({ allResults }) as QuerySelectModel, value);
+
+ if (notFoundValuesEnabled) {
+ const { expectedValueCount, filter } = buildValueFilter(
+ value,
+ model.valueColumn,
+ model.multiple,
+ model.delimiter
+ );
+
+ // Values that fail to resolve against the combined (fetched + previously loaded) results
+ // are marked with "not found" placeholder rows.
+ if (updated.selectedItems.size !== expectedValueCount) {
+ const placeholders: Record = {};
+ findNotFoundValues(updated.selectedItems.toJS(), filter, model.valueColumn).forEach(v => {
+ placeholders[v] = notFoundRow(model.valueColumn, v);
+ });
+
+ if (Object.keys(placeholders).length > 0) {
+ allResults = allResults.merge(fromJS(placeholders));
+ updated = setSelection(model.merge({ allResults }) as QuerySelectModel, value);
+ }
+ }
+ }
+
+ return updated;
+}
+
+/** Returns true if all the requested value(s) can be resolved from results already loaded into the model. */
+export function valuesAreLoaded(model: QuerySelectModel, value: any): boolean {
+ const values = parseRawValue(value, model.multiple, model.delimiter).filter(validValue);
+ if (values.length === 0) return true;
+
+ // model.valueColumn is fieldKey, not column name
+ const keyPath = [QueryKey.decodePart(model.valueColumn), 'value'];
+ const loaded = model.allResults
+ .merge(model.selectedItems)
+ .map(row => row.getIn(keyPath))
+ .filter(validValue)
+ .map(v => v.toString())
+ .toSet();
+
+ return values.every(v => loaded.has(v.toString()));
+}
+
export async function initSelect(props: QuerySelectOwnProps): Promise> {
const { delimiter, multiple, notFoundValuesEnabled, value } = props;
const { queryInfo, valueColumn, displayColumn, groupByColumn } = await initQueryInfoWithColumns(props);
@@ -407,16 +547,20 @@ export async function initSelect(props: QuerySelectOwnProps): Promise {
- if (!selectedRows.hasOwnProperty(v)) {
- selectedRows[v] = { [valueColumn]: { displayValue: `<${v}>`, notFound: true, value: v } };
- }
- });
+ selectedItems = await selectValueRows(
+ {
+ columns: queryColumnNames(queryInfo, displayColumn, valueColumn, props.requiredColumns, groupByColumn),
+ containerFilter: props.containerFilter,
+ containerPath: props.containerPath,
+ queryFilters: props.queryFilters,
+ queryParams: props.queryParams,
+ schemaQuery: props.schemaQuery,
+ },
+ filter
+ );
+
+ if (notFoundValuesEnabled) {
+ applyNotFoundValues(selectedItems.models[selectedItems.key], filter, valueColumn, expectedValueCount);
}
}
diff --git a/packages/components/src/internal/constants.ts b/packages/components/src/internal/constants.ts
index f9f20a4306..d63bacd9f6 100644
--- a/packages/components/src/internal/constants.ts
+++ b/packages/components/src/internal/constants.ts
@@ -44,6 +44,7 @@ export enum EDIT_METHOD {
DETAIL_EDIT = 'DetailEdit',
DETAIL_EDIT_LINEAGE = 'DetailEditLineage',
FORM_INSERT = 'FormInsert',
+ FORM_INSERT_MODAL = 'FormInsertModal',
GRID_EDIT = 'GridEdit',
GRID_INSERT = 'GridInsert',
STORAGE_VIEW_ACTION = 'StorageViewAction',
diff --git a/packages/components/src/internal/query/api.ts b/packages/components/src/internal/query/api.ts
index eb4a9719e7..8ffe6c7bde 100644
--- a/packages/components/src/internal/query/api.ts
+++ b/packages/components/src/internal/query/api.ts
@@ -10,7 +10,7 @@ import { ActionURL, Ajax, AuditBehaviorTypes, Filter, Query, QueryDOM, Utils } f
import { ExtendedMap } from '../../public/ExtendedMap';
import { getQueryMetadata } from '../global';
-import { resolveKeyFromJson, SchemaQuery } from '../../public/SchemaQuery';
+import { resolveKeyFromJson, SchemaQuery, SchemaQueryKey } from '../../public/SchemaQuery';
import {
isAllProductFoldersFilteringEnabled,
isProductFoldersDataListingScopedToFolder,
@@ -449,7 +449,7 @@ export function isSelectRowMetadataRequired(includeMetadata?: boolean, columns?:
}
export interface ISelectRowsResult {
- key: string;
+ key: SchemaQueryKey;
messages?: List>;
models: any;
orderedModels: List;
diff --git a/packages/components/src/public/ExtendedMap.test.ts b/packages/components/src/public/ExtendedMap.test.ts
index 5d00efcc56..944281a498 100644
--- a/packages/components/src/public/ExtendedMap.test.ts
+++ b/packages/components/src/public/ExtendedMap.test.ts
@@ -3,7 +3,7 @@
* in any form or by any electronic or mechanical means without written permission from LabKey Corporation.
*/
import { OrderedMap } from 'immutable';
-import { ExtendedMap } from './ExtendedMap';
+import { ExtendedMap, KeyType } from './ExtendedMap';
const KEYS_ONE = ['one', 'two', 'three'];
const KEYS_TWO = ['four', 'five', 'six'];
@@ -28,14 +28,14 @@ ORDERED_TWO.set('five', 5);
ORDERED_TWO.set('six', 6);
describe('ExtendedMap', () => {
- function expectOrder(map: ExtendedMap, keys: any[], values: any[]) {
+ function expectOrder(map: ExtendedMap, keys: K[], values: V[]) {
expect(Array.from(map.keys())).toStrictEqual(keys);
expect(Array.from(map.values())).toStrictEqual(values);
expect(map.keyArray).toStrictEqual(keys);
expect(map.valueArray).toStrictEqual(values);
}
- function expectValues(map, keys: any[], values: any[]) {
+ function expectValues(map: ExtendedMap, keys: K[], values: V[]) {
let idx = 0;
for (const key of keys) {
expect(map.get(key)).toEqual(values[idx]);
@@ -44,7 +44,7 @@ describe('ExtendedMap', () => {
}
test('Constructor - empty args', () => {
- const em = new ExtendedMap();
+ const em = new ExtendedMap();
expectOrder(em, [], []);
em.set('one', 1);
em.set('two', 2);
diff --git a/packages/components/src/public/ExtendedMap.ts b/packages/components/src/public/ExtendedMap.ts
index b04b72468a..03ee4fb8f7 100644
--- a/packages/components/src/public/ExtendedMap.ts
+++ b/packages/components/src/public/ExtendedMap.ts
@@ -2,20 +2,20 @@
* Copyright (c) 2023-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.
*/
-type KeyType = string | number | symbol;
-type MapType = Record | Map;
+export type KeyType = number | string | symbol;
+type MapType = Map | Record;
type Mapper = (value: V, key: K, original: ExtendedMap) => T;
type ArrayMapper = (value: V, index: number, array: V[]) => T;
type FilterFn = (value: V, key: K, original: ExtendedMap) => boolean;
type Reducer = (result: T, value: V, key: K, original: ExtendedMap) => T;
/**
- * ExtendedMap is an extended version of the built in Map class. It has an improved constructor (that takes Records,
+ * ExtendedMap is an extended version of the built-in Map class. It has an improved constructor (that takes Records,
* Map, or ExtendedMap objects), as well as several convenience methods for mapping, reducing, and filtering the map or
- * values. This class is an Ordered Map, because it extends the Map class which is ordered.
+ * values. This class is an ordered map because it extends the Map class which is ordered.
*/
-export class ExtendedMap extends Map {
- constructor(...data: Array | Map>) {
+export class ExtendedMap extends Map {
+ constructor(...data: (Map | Record)[]) {
super();
for (const dataObject of data) {
@@ -23,12 +23,12 @@ export class ExtendedMap extends Map {
if (dataObject instanceof Map) {
for (const [key, value] of dataObject) {
- this.set(key, value);
+ this.set(key as K, value);
}
} else {
// Assume Record type
for (const key of Object.keys(dataObject)) {
- this.set(key, dataObject[key]);
+ this.set(key as K, dataObject[key]);
}
}
}
@@ -36,7 +36,7 @@ export class ExtendedMap extends Map {
/**
* Use this when you want to map or reduce over the values of the Map, or otherwise need an array. If you just need
- * to iterate through the values you should be able to use the values() method.
+ * to iterate through the values, you should be able to use the values() method.
*/
get valueArray(): V[] {
return Array.from(this.values());
@@ -44,7 +44,7 @@ export class ExtendedMap extends Map {
/**
* Use this when you want to map or reduce over the keys of the Map, or otherwise need an array. If you just need
- * to iterate through the keys you should be able to use the keys() method.
+ * to iterate through the keys, you should be able to use the keys() method.
*/
get keyArray(): KeyType[] {
return Array.from(this.keys());
@@ -67,7 +67,7 @@ export class ExtendedMap extends Map {
/**
* Iterates over the ExtendedMap, calling the provided Reducer function for each key/value. Allows you to completely
- * transform the ExtendedMap object into something else (e.g. a string, a filtered version of the map).
+ * transform the ExtendedMap object into something else (e.g., a string, a filtered version of the map).
* @param reducer
* @param initialReduction
*/
@@ -82,8 +82,8 @@ export class ExtendedMap extends Map {
}
/**
- * Creates a new ExtendedMap based on the filter function a passed in. Iterates through all of the values of the map
- * and calls the filter function with the key, value, and whole map. If the filter function returns true we include
+ * Creates a new ExtendedMap based on the filter function a passed in. Iterates through all the values of the map
+ * and calls the filter function with the key, value, and whole map. If the filter function returns true, we include
* the key/value pair in the new map.
* @param filterFn
*/
@@ -113,18 +113,18 @@ export class ExtendedMap extends Map {
* Creates a new ExtendedMap based on this map and the map passed in as an argument.
* @param otherMap
*/
- merge(otherMap: MapType | ExtendedMap): ExtendedMap {
+ merge(otherMap: ExtendedMap | MapType): ExtendedMap {
return new ExtendedMap(this, otherMap);
}
/**
- * Inserts the contents of a map at the designated index. If the given index is out of range of the existing map we
- * return a copy of the current map. If the given index is equal to the current size of the map we append the
+ * Inserts the contents of a map at the designated index. If the given index is out of range of the existing map, we
+ * return a copy of the current map. If the given index is equal to the current size of the map, we append the
* incoming otherMap.
- * @param index: the index where to insert the otherMap
- * @param otherMap: the otherMap to insert
+ * @param index the index where to insert the otherMap
+ * @param otherMap the otherMap to insert
*/
- mergeAt(index: number, otherMap: MapType | ExtendedMap): ExtendedMap {
+ mergeAt(index: number, otherMap: ExtendedMap | MapType): ExtendedMap {
// Invalid, return a copy of this map
if (index < 0 || index > this.size) return new ExtendedMap(this);
diff --git a/packages/components/src/public/SchemaQuery.ts b/packages/components/src/public/SchemaQuery.ts
index 4cbf503641..2a6496a4c8 100644
--- a/packages/components/src/public/SchemaQuery.ts
+++ b/packages/components/src/public/SchemaQuery.ts
@@ -16,7 +16,7 @@ function stripSelectionSnapshotId(value: string): string {
return value;
}
-// 36009: Case-insensitive variant of QueryKey.decodePart
+// Issue 36009: Case-insensitive variant of QueryKey.decodePart
export function decodePart(s: string): string {
if (!s) return s;
@@ -30,7 +30,7 @@ export function decodePart(s: string): string {
.replace(/\$D/gi, '$');
}
-// 36009: Case-insensitive variant of QueryKey.encodePart
+// Issue 36009: Case-insensitive variant of QueryKey.encodePart
export function encodePart(s: string): string {
if (!s) return s;
@@ -44,7 +44,9 @@ export function encodePart(s: string): string {
.replace(/\./gi, '$P');
}
-export function resolveKey(schema: string, query: string, viewName?: string): string {
+export type SchemaQueryKey = string & { __schemaQueryKey: never };
+
+export function resolveKey(schema: string, query: string, viewName?: string): SchemaQueryKey {
/*
It's questionable if we really need to encodePart schema here and the suspicion is that this would result in
double encoding. Since schema is not recognisable by api when not encoded, it would be reasonable to assume the
@@ -53,23 +55,19 @@ export function resolveKey(schema: string, query: string, viewName?: string): st
*/
const parts = [encodePart(schema), encodePart(query)];
if (viewName) parts.push(encodePart(viewName));
- return parts.join('/').toLowerCase();
+ return parts.join('/').toLowerCase() as SchemaQueryKey;
}
-export function resolveKeyFromJson(json: { queryName: string; schemaName: string[]; viewName?: string }): string {
+export function resolveKeyFromJson(json: {
+ queryName: string;
+ schemaName: string[];
+ viewName?: string;
+}): SchemaQueryKey {
// if schema parts contain '.', replace with $P, to distinguish from '.' used to separate schema parts
// similarly, encode '/' in schema parts, to distinguish from '/' used to separate schema and query parts
// schemaName ['assay', 'general', 'a.b/c'] will be will processed to 'assay.general.a$pb$sc'
// resolveKey will then further encode schema to assay$pgeneral$pa$dpb$sc
- return resolveKey(
- json.schemaName
- .map(schemaPart => {
- return encodePart(schemaPart);
- })
- .join('.'),
- json.queryName,
- json.viewName
- );
+ return resolveKey(json.schemaName.map(encodePart).join('.'), json.queryName, json.viewName);
}
export interface IParsedSelectionKey {
@@ -107,15 +105,19 @@ export class SchemaQuery {
return !!schemaName && equalsIgnoreCase(this.schemaName, schemaName);
}
- getKey(includeViewName = true): string {
+ getKey(includeViewName = true): SchemaQueryKey {
return resolveKey(this.schemaName, this.queryName, includeViewName ? this.viewName : undefined);
}
+ static fromKey(encodedKey: SchemaQueryKey): SchemaQuery {
+ return getSchemaQuery(encodedKey);
+ }
+
static parseSelectionKey(selectionKey: string): IParsedSelectionKey {
selectionKey = stripSelectionSnapshotId(selectionKey);
const parts = selectionKey.split('|');
// first part will be app page model key, which we skip
- const schemaQueryKey = parts[1];
+ const schemaQueryKey = parts[1] as SchemaQueryKey;
// there may be a view name between the schemaQueryKey and the provided entity keys
const keys = parts.length > 2 ? parts[parts.length - 1] : undefined;
diff --git a/packages/components/src/theme/fields.scss b/packages/components/src/theme/fields.scss
index 762352f9d8..cc7b904775 100644
--- a/packages/components/src/theme/fields.scss
+++ b/packages/components/src/theme/fields.scss
@@ -179,7 +179,7 @@ button.view-field__action .fa {
// GitHub Issue 985: Improve readability of identifying fields
.select-input__option--is-selected .identifying_field_label {
- color: #FFFFFF;
+ color: $white;
}
.folder-field_archived-tag {
@@ -190,3 +190,27 @@ button.view-field__action .fa {
padding: 2px 3px;
}
}
+
+.add-entities-footer {
+ background-color: $gray-shadow;
+ border-top: 1px solid $gray-border;
+ height: 34px;
+ display: flex;
+ align-items: center;
+ padding: 0 8px;
+ cursor: pointer;
+
+ &:hover {
+ background-color: $gray-border-light;
+ }
+
+ &.is-focused {
+ // react-select (theme primary25) -- See SelectInput.tsx
+ background-color: rgba(41, 128, 185, 0.1);
+ }
+
+ > .fa {
+ color: $brand-success;
+ padding-right: 4px;
+ }
+}