Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a1b40a2
Enhance input visibility logic and add new helper functions
projkov Jan 31, 2026
c3bb5f9
Refactor input handling and enhance test coverage
projkov Jan 31, 2026
0b17e40
Enhance input serializer and entity to support conditional display
projkov Jan 31, 2026
8d004a6
Add conditional input group to demo suite
projkov Jan 31, 2026
098bda9
Initialize Bundler in inferno script
projkov Jan 31, 2026
87e3a29
Remove yarn.lock file from the project
projkov Jan 31, 2026
983a165
Fix lint errors in the client
projkov Feb 1, 2026
01b1a04
Refactor input visibility logic to use `enable_when` instead of `show…
projkov Feb 2, 2026
bbf9490
Change conditional input group in demo suite
projkov Feb 2, 2026
e1913e4
Refactor input handling and enhance conditional input tests
projkov Feb 6, 2026
1de06b6
Remove Bundler setup requirement from the inferno script
projkov Feb 6, 2026
77fff3b
Update normalizeValue function and add comprehensive tests
projkov Feb 6, 2026
ebf97db
Merge branch 'main' into conditional-rendering-of-modal-inputs
projkov May 13, 2026
4cf6082
Remove unused sortAndNormalizeArray function from InputHelpers.ts
projkov May 13, 2026
f81cbaf
Add test to cover dependent visibility for checkbox
projkov May 13, 2026
a3cd415
Update conditionalShowInput documentation for clarity and accuracy
projkov May 13, 2026
7810955
Remove code duplication in Inputs.test.tsx
projkov May 13, 2026
33b30e3
Enhance InputHelpers with array sorting in normalization and add cons…
projkov May 13, 2026
a27cff2
Lint auto fixes
projkov May 20, 2026
aaaf1e2
Lint fixes
projkov May 20, 2026
323298d
Remove console warnings for missing inputs in conditionalShowInput an…
projkov May 20, 2026
73af7d3
Update conditionalShowInput to account for checkbox type and remove u…
projkov May 20, 2026
e7fba3c
Remove console warning check for missing inputs in conditionalShowInp…
projkov May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ PLATFORMS
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
arm64-darwin-25
x86_64-darwin-20
x86_64-darwin-22
x86_64-darwin-23
Expand Down
1 change: 1 addition & 0 deletions bin/inferno
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env ruby

require 'bundler/setup'
Comment thread
karlnaden marked this conversation as resolved.
Outdated
require 'pry'
require_relative '../lib/inferno/config/application'
require_relative '../lib/inferno/apps/cli'
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/InputsModal/InputFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import InputOAuthCredentials from '~/components/InputsModal/InputOAuthCredential
import InputRadioGroup from '~/components/InputsModal/InputRadioGroup';
import InputSingleCheckbox from '~/components/InputsModal/InputSingleCheckbox';
import InputTextField from '~/components/InputsModal/InputTextField';
import { showInput } from './InputHelpers';

export interface InputFieldsProps {
inputs: TestInput[];
Expand All @@ -19,7 +20,7 @@ const InputFields: FC<InputFieldsProps> = ({ inputs, inputsMap, setInputsMap })
return (
<List>
{inputs.map((input: TestInput, index: number) => {
if (!input.hidden) {
if (showInput(input, inputsMap)) {
switch (input.type) {
case 'auth_info':
if (input.options?.mode === 'auth') {
Expand Down
85 changes: 85 additions & 0 deletions client/src/components/InputsModal/InputHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,88 @@ export const isJsonString = (str: unknown) => {
}
return !!value && typeof value === 'object';
};

/**
* Converts a value to its string representation.
* - If value is null or undefined, returns an empty string.
* - Otherwise, returns the string representation of the value.
*
* @param value - The value to normalize.
* @returns The normalized string value.
*/
const normalizeValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
switch (typeof value) {
case 'string':
return value;
case 'number':
case 'boolean':
case 'bigint':
case 'symbol':
return String(value);
default:
return '';
}
};

/**
* Sorts and normalizes an array of values.
* - Sorts the array and returns a normalized string representation of each element.
*
* @param value - The value to sort and normalize.
* @returns The sorted and normalized array of strings.
*/
const sortAndNormalizeArray = (value: unknown[]): string[] => {
return value.sort().map((item) => normalizeValue(item));
};
Comment thread
projkov marked this conversation as resolved.
Outdated

/**
* Compares two values for equality.
* - If both values are arrays, sorts and normalizes them and then compares each element.
* - Otherwise, normalizes them and compares the string representations.
*
* @param value1 - The first value to compare.
* @param value2 - The second value to compare.
* @returns True if the values are equal, false otherwise.
*/
const isEqual = (value1: unknown, value2: unknown) => {
if (Array.isArray(value1) && Array.isArray(value2)) {
return sortAndNormalizeArray(value1).every(
(item, index) => item === sortAndNormalizeArray(value2)[index],
);
}

return normalizeValue(value1) === normalizeValue(value2);
};

/**
* Returns true if the input field should be displayed based on its `enable_when`
* condition and the values in inputsMap.
* - No `enable_when` → always show.
* - Referenced input missing or no `input_name` → hide.
* - When `enable_when.value` is a string: show when referenced value equals it.
* - When `enable_when.value` is string[]: show when referenced value equals any element.
*/
export const conditionalShowInput = (
input: TestInput,
inputsMap: Map<string, unknown>,
): boolean => {
const enableWhen = input.enable_when;
if (!enableWhen?.input_name) {
return true;
}
const inputValue = inputsMap.get(enableWhen.input_name);
if (inputValue === undefined) {
return false;
}
return isEqual(inputValue, enableWhen.value);
};

export const showInput = (input: TestInput, inputsMap: Map<string, unknown>): boolean => {
return !input.hidden && conditionalShowInput(input, inputsMap);
};
138 changes: 138 additions & 0 deletions client/src/components/InputsModal/__tests__/Inputs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import InputRadioGroup from '~/components/InputsModal/InputRadioGroup';
import InputTextField from '~/components/InputsModal/InputTextField';
import { isJsonString } from '~/components/InputsModal/InputHelpers';
import ThemeProvider from '~/components/ThemeProvider';
import InputFields from '../InputFields';

describe('Input Components', () => {
it('renders InputCheckboxGroup', () => {
Expand Down Expand Up @@ -180,4 +181,141 @@ describe('Input Components', () => {
expect(isJsonString(string)).toEqual(true);
});
});

describe('renders conditional fields correctly', () => {
const constructInput = (props: Partial<TestInput>): TestInput => {
return {
name: props?.name || '',
type: (props?.type || 'text') as TestInput['type'],
title: props?.title,
optional: props?.optional,
enable_when: props?.enable_when,
hidden: props?.hidden,
value: props?.value,
};
};

const constructInputsMap = (inputs: TestInput[]): Map<string, string> => {
return inputs.reduce((acc, input) => {
acc.set(input.name, input.value as string);
return acc;
}, new Map<string, string>());
};

const renderInputFields = (inputs: TestInput[], inputsMap: Map<string, string>) => {
render(
<ThemeProvider>
<SnackbarProvider>
<InputFields inputs={inputs} inputsMap={inputsMap} setInputsMap={() => {}} />
</SnackbarProvider>
</ThemeProvider>,
);
};

const assertInputVisibility = (inputs: Partial<TestInput>[], expectedVisible: boolean[]) => {
const constructedInputs: TestInput[] = inputs.map((input) => constructInput(input));
const inputsMap = constructInputsMap(constructedInputs);
renderInputFields(constructedInputs, inputsMap);

constructedInputs.forEach((input, index) => {
const label = input.title as string;
const shouldBeVisible = expectedVisible[index];
if (shouldBeVisible) {
expect(screen.getByLabelText(label, { exact: false })).toBeVisible();
} else {
expect(screen.queryByLabelText(label, { exact: false })).not.toBeInTheDocument();
}
});
};

it('renders field when it has no enable_when (always visible)', () => {
assertInputVisibility([{ name: 'standalone', title: 'Standalone field' }], [true]);
});

it('skips rendering dependent field when controlling value is undefined', () => {
assertInputVisibility(
[
{ name: 'trigger', title: 'Trigger' },
{
name: 'dependent',
title: 'Dependent',
enable_when: { input_name: 'trigger', value: 'yes' },
},
],
[true, false],
);
});

it('skips rendering when controlling value does not match enable_when', () => {
assertInputVisibility(
[
{ name: 'trigger', title: 'Trigger', value: 'no' },
{
name: 'dependent',
title: 'Dependent',
enable_when: { input_name: 'trigger', value: 'yes' },
},
],
[true, false],
);
});

it('renders dependent field when controlling value matches enable_when', () => {
assertInputVisibility(
[
{ name: 'trigger', title: 'Trigger', value: 'yes' },
{
name: 'dependent',
title: 'Dependent',
enable_when: { input_name: 'trigger', value: 'yes' },
},
],
[true, true],
);
});

it('hides field when hidden is true even if enable_when would pass', () => {
assertInputVisibility(
[
{ name: 'trigger', title: 'Trigger', value: 'yes' },
{
name: 'dependent',
title: 'Dependent',
enable_when: { input_name: 'trigger', value: 'yes' },
hidden: true,
},
],
[true, false],
);
});

it('renders dependent field when controlling value (array) equals enable_when array value', () => {
const refValue = ['a', 'b'];
const inputs: TestInput[] = [
constructInput({
name: 'trigger',
title: 'Trigger',
type: 'checkbox',
value: refValue,
}),
constructInput({
name: 'dependent',
title: 'Dependent',
enable_when: { input_name: 'trigger', value: ['a', 'b'] },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the value be a string rep of the Array? Or is use with checkboxes disallowed?

}),
];
const inputsMap = new Map<string, unknown>();
inputsMap.set('trigger', refValue);
render(
<ThemeProvider>
<SnackbarProvider>
<InputFields inputs={inputs} inputsMap={inputsMap} setInputsMap={() => {}} />
</SnackbarProvider>
</ThemeProvider>,
);

expect(screen.getByRole('checkbox', { name: /Trigger/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Dependent/i })).toBeVisible();
});
});
});
4 changes: 4 additions & 0 deletions client/src/models/testSuiteModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ export interface TestInput {
list_options?: InputOption[];
mode?: string;
};
enable_when?: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please propose updated Inferno documentation in this section (rendered here) for this input feature.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

input_name: string;
value: string | string[];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a decent amount of added complexity to support string[] here for unclear benefit. Technically it allows you to use a multi-select checkbox field as a target for the enable-when, but in practice you would be enabling for a specific set of boxes checked and not able to enable based on a single checkbox. You don't have any examples or unit tests for this case, so I think my request is to take it out and explicitly document that enable_when is meant to target inputs with the radio type. That is the primary use case and I think better to keep this simple (as you've already done) than try to make it fully general.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed this attribute to a string type and also added an example with the select input.

};
}

export interface TestOutput {
Expand Down
26 changes: 26 additions & 0 deletions dev_suites/dev_demo_ig_stu1/demo_suite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -417,5 +417,31 @@ class DemoSuite < Inferno::TestSuite
end
end
end

group do
id 'conditional_group'
title 'Conditional Inputs Group'
optional

test 'Conditional, optional, empty input test' do
input :get_type, title: 'How to get Bundle', type: 'radio', options: {
list_options: [
{ value: 'copy_paste', label: 'Paste JSON' },
{ value: 'url', label: 'URL to FHIR Bundle' },
{ value: 'summary_op', label: '$summary Operation' }
]
}
input :bundle_copy_paste, title: 'Paste JSON', type: 'textarea', optional: true,
enable_when: { input_name: 'get_type', value: 'copy_paste' }
input :bundle_url, title: 'URL to FHIR Bundle', type: 'text', optional: true,
enable_when: { input_name: 'get_type', value: 'url' }
input :fhir_server_url, title: 'FHIR Server URL', type: 'text', optional: true,
enable_when: { input_name: 'get_type', value: 'summary_op' }
input :patient_identifier, title: 'Patient ID', type: 'text', optional: true,
enable_when: { input_name: 'get_type', value: 'summary_op' }

run { pass }
end
end
end
end
1 change: 1 addition & 0 deletions lib/inferno/apps/web/serializers/input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Input < Serializer
field :locked, if: :field_present?
field :hidden, if: :field_present?
field :value, if: :field_present?
field :enable_when, if: :field_present?
end
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/inferno/dsl/input_output_handling.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module InputOutputHandling
# @option input_params [Hash] :options Possible input option formats based on input type
# @option options [Array] :list_options Array of options for input formats
# that require a list of possible values (radio and checkbox)
# @option input_params [Hash] :enable_when Conditions for showing the input
# @return [void]
# @example
# input :patient_id, title: 'Patient ID', description: 'The ID of the patient being searched for',
Expand Down
15 changes: 9 additions & 6 deletions lib/inferno/entities/input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,25 @@ class Input
:options,
:locked,
:hidden,
:value
:value,
:enable_when
].freeze
include Entities::Attributes

# These attributes require special handling when merging input
# definitions.
UNINHERITABLE_ATTRIBUTES = [
# Locking or hiding an input only has meaning at the level it is applied.
# Consider:
# Locking, hiding, or conditional display only have meaning at the level
# they are applied. Consider:
# - ParentGroup
# - Group 1, input :a
# - Group 2, input :a, locked: true, hidden: true, optional: true
# The input 'a' should only be locked or hidden when running Group 2 in isolation.
# It should not be locked or hidden when running Group 1 or the ParentGroup.
# - Group 2, input :a, locked: true, hidden: true, enable_when: {...}, optional: true
# The input 'a' should only be locked, hidden, or conditionally shown when
# running Group 2 in isolation. It should not inherit those when running
# Group 1 or the ParentGroup.
:locked,
:hidden,
:enable_when,
# Input type is sometimes only a UI concern (e.g. text vs. textarea), so
# it is common to not redeclare the type everywhere it's used and needs
# special handling to avoid clobbering the type with the default (text)
Expand Down
16 changes: 15 additions & 1 deletion spec/inferno/utils/preset_template_generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,21 @@
{ name: 'cancel_pause_time', _type: 'text', value: '30' },
{ name: 'url1', _type: 'text', value: nil },
{ name: 'custom_bearer_token', _type: 'text',
_description: 'This bearer token will be used to identify the incoming request', value: nil }
_description: 'This bearer token will be used to identify the incoming request', value: nil },
{ name: 'get_type', _type: 'radio', _title: 'How to get Bundle',
_options: { list_options: [
{ value: 'copy_paste', label: 'Paste JSON' },
{ value: 'url', label: 'URL to FHIR Bundle' },
{ value: 'summary_op', label: '$summary Operation' }
] }, value: nil },
{ name: 'bundle_copy_paste', _type: 'textarea', _title: 'Paste JSON',
_optional: true, value: nil, _enable_when: { input_name: 'get_type', value: 'copy_paste' } },
{ name: 'bundle_url', _type: 'text', _title: 'URL to FHIR Bundle',
_optional: true, value: nil, _enable_when: { input_name: 'get_type', value: 'url' } },
{ name: 'fhir_server_url', _type: 'text', _title: 'FHIR Server URL',
_optional: true, value: nil, _enable_when: { input_name: 'get_type', value: 'summary_op' } },
{ name: 'patient_identifier', _type: 'text', _title: 'Patient ID',
_optional: true, value: nil, _enable_when: { input_name: 'get_type', value: 'summary_op' } }
] }
end

Expand Down