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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useForm } from 'react-final-form';
import { Link } from 'react-router-dom';
Expand Down Expand Up @@ -46,7 +46,9 @@ const propTypes = {
onAgreementSelected: PropTypes.func.isRequired,
parentAgreementId: PropTypes.string,
parentAgreementName: PropTypes.string,
triggerButtonId: PropTypes.string,
};

const RelatedAgreementField = ({
agreement = {},
id,
Expand All @@ -55,12 +57,20 @@ const RelatedAgreementField = ({
onAgreementSelected,
parentAgreementId,
parentAgreementName,
triggerButtonId
}) => {
const { change } = useForm();
let triggerButton = useRef(null);

const { selfLinkedWarning, setSelfLinkedWarning } = useLinkedWarning(change, input, parentAgreementId, triggerButton);

useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems ok to me on first pass, but I do wonder if we're manually doing this on mount whether we're missing something.

useEffect on mount here I think means that if we decided to open the form up with an empty field instead of none (like periods in agreements) then this would focus.

Whereas what we're actually after is the focusing as a byproduct of being added...

I think alltold this probably should be dealt with more holistically.

Something like

const { focusRef, addCallback } useAddElementFocus

which we can implement within form components, and on creation of a new object handles the focusing of the ref

OR some kind of handling in form arrays specifically, where it internally tracks state, and on addition it can move focus to the new element...

Either way I think controlling from the overview of all elements makes more sense than each element controlling whether it gets focus or not.

if (!input.value?.id && triggerButton.current) {
triggerButton.current.focus();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

/* istanbul ignore next */
const renderLinkAgreementButton = value => (
<Pluggable
Expand All @@ -74,7 +84,7 @@ const RelatedAgreementField = ({
'aria-haspopup': 'true',
'buttonRef': triggerButton,
'buttonStyle': value ? 'default' : 'primary',
'id': `${id}-find-agreement-btn`,
'id': triggerButtonId || `${id}-find-agreement-btn`,
Copy link
Contributor

Choose a reason for hiding this comment

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

If we already have a ref, I'm not sure why we need to append a custom id and then focus by that id

'marginBottom0': true,
'name': input.name,
'onClick': pluggableRenderProps.onClick
Expand Down Expand Up @@ -198,4 +208,3 @@ const RelatedAgreementField = ({
RelatedAgreementField.propTypes = propTypes;

export default RelatedAgreementField;

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { useKiwtFieldArray } from '@k-int/stripes-kint-components';

import RelatedAgreementField from './RelatedAgreementField';
import { agreementRelationshipTypes } from '../../constants';
import { focusByIdWhenReady } from '../utilities';


const RelatedAgreementsFieldArray = ({
currentAgreementId,
Expand Down Expand Up @@ -41,8 +43,9 @@ const RelatedAgreementsFieldArray = ({
}
});

const handleAgreementSelected = (index, agreement) => {
const handleAgreementSelected = (index, agreement, triggerButtonId) => {
onUpdateField(index, { agreement });
focusByIdWhenReady(triggerButtonId);
};

const renderEmpty = () => (
Expand All @@ -69,51 +72,75 @@ const RelatedAgreementsFieldArray = ({
};

const renderRelatedAgreements = () => {
return items.map((relatedAgreement, index) => (
<EditCard
key={index}
data-testid={`relatedAgreementsFieldArray[${index}]`}
deleteBtnProps={{
'id': `ra-delete-${index}`,
'data-test-delete-field-button': true
}}
deleteButtonTooltipText={<FormattedMessage id="ui-agreements.relatedAgreements.remove" values={{ index: index + 1 }} />}
header={<FormattedMessage id="ui-agreements.relatedAgreements.relatedAgreementIndex" values={{ index: index + 1 }} />}
id={`edit-ra-card-${index}`}
onDelete={() => onDeleteField(index, relatedAgreement)}
>
<Field
agreement={relatedAgreement.agreement}
component={RelatedAgreementField}
id={`ra-agreement-${index}`}
index={index}
name={`${name}[${index}].agreement`}
onAgreementSelected={selectedAgreement => handleAgreementSelected(index, selectedAgreement)}
parentAgreementId={currentAgreementId}
parentAgreementName={currentAgreementName}
validate={requiredValidator}
/>
<Field
component={Select}
dataOptions={relationshipTypes}
disabled={!get(relatedAgreement, 'agreement.id')}
id={`ra-type-${index}`}
label={<FormattedMessage id="ui-agreements.relatedAgreements.relationshipToThisAgreement" />}
name={`${name}[${index}].type`}
required
validate={requiredValidator}
/>
{renderRelationshipSummary(relatedAgreement)}
<Field
component={TextArea}
id={`ra-note-${index}`}
label={<FormattedMessage id="ui-agreements.note" />}
name={`${name}[${index}].note`}
parse={v => v}
/>
</EditCard>
));
return items.map((relatedAgreement, index) => {
const fieldId = `ra-agreement-${index}`;
const triggerButtonId = `${fieldId}-find-agreement-btn`;

return (
<EditCard
key={index}
data-testid={`relatedAgreementsFieldArray[${index}]`}
deleteBtnProps={{
id: `ra-delete-${index}`,
'data-test-delete-field-button': true
}}
deleteButtonTooltipText={
<FormattedMessage
id="ui-agreements.relatedAgreements.remove"
values={{ index: index + 1 }}
/>
}
header={
<FormattedMessage
id="ui-agreements.relatedAgreements.relatedAgreementIndex"
values={{ index: index + 1 }}
/>
}
id={`edit-ra-card-${index}`}
onDelete={() => onDeleteField(index, relatedAgreement)}
>
<Field
agreement={relatedAgreement.agreement}
component={RelatedAgreementField}
id={fieldId}
index={index}
name={`${name}[${index}].agreement`}
onAgreementSelected={selectedAgreement => handleAgreementSelected(index, selectedAgreement, triggerButtonId)
}
parentAgreementId={currentAgreementId}
parentAgreementName={currentAgreementName}
triggerButtonId={triggerButtonId}
validate={requiredValidator}
/>

<Field
component={Select}
dataOptions={relationshipTypes}
disabled={!get(relatedAgreement, 'agreement.id')}
id={`ra-type-${index}`}
label={
<FormattedMessage id="ui-agreements.relatedAgreements.relationshipToThisAgreement" />
}
name={`${name}[${index}].type`}
required
validate={requiredValidator}
/>

{renderRelationshipSummary(relatedAgreement)}

<Field
component={TextArea}
id={`ra-note-${index}`}
label={<FormattedMessage id="ui-agreements.note" />}
name={`${name}[${index}].note`}
parse={v => v}
/>
</EditCard>
);
});
};


return (
<div data-test-ra-fa>
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,28 @@ import { Button, renderWithIntl, TestForm } from '@folio/stripes-erm-testing';

import { FieldArray } from 'react-final-form-arrays';
import RelatedAgreementsFieldArray from '../RelatedAgreementsFieldArray';

import { focusByIdWhenReady } from '../../utilities';
import { relatedAgreements } from '../../../../test/jest';
import translationsProperties from '../../../../test/helpers';

const onSubmit = jest.fn();

jest.mock('../RelatedAgreementField', () => () => <div>RelatedAgreementField</div>);

const relatedAgreements = [
{
id: '6bcca40d-138c-4c51-b7f5-235fb02552a6',
note: 'test note 1',
agreement: {
id: 'f591806e-fe8c-4b16-a3cb-8cccc180d82b',
name: 'AM ag 1',
agreementStatus: {
id: '2c91809c7ba954b5017ba95c586a0035',
value: 'active',
label: 'Active'
},
startDate: '2021-09-02',
endDate: null
},
type: 'supersedes'
},
{
id: 'ac2fde6e-a6a2-4df5-b244-8d803382d2d5',
note: 'test note 2',
agreement: {
id: 'b958c1be-54f5-4f3d-9131-4255cd21b109',
name: 'AM ag 3',
agreementStatus: {
id: '2c91809c7ba954b5017ba95c58630034',
value: 'in_negotiation',
label: 'In negotiation'
},
startDate: '2021-09-18',
endDate: null
},
type: 'provides_post-cancellation_access_for'
}
];
jest.mock('../../utilities', () => ({
focusByIdWhenReady: jest.fn(),
}));

jest.mock('../RelatedAgreementField', () => (props) => (
<div>
<div>RelatedAgreementField</div>
<button
onClick={() => props.onAgreementSelected({ id: 'selected-agreement-id', name: 'Selected' })}
type="button"
>
Select agreement
</button>
</div>
));


describe('RelatedAgreementsFieldArray', () => {
describe('render with empty initial values', () => {
Expand All @@ -63,6 +44,20 @@ describe('RelatedAgreementsFieldArray', () => {
);
});

test('calling onAgreementSelected focuses the trigger button id for that card', async () => {
const { getByRole } = renderComponent;

await waitFor(async () => {
await Button('Add related agreement').click();
});

await waitFor(async () => {
await userEvent.click(getByRole('button', { name: 'Select agreement' }));
});

expect(focusByIdWhenReady).toHaveBeenCalledWith('ra-agreement-0-find-agreement-btn');
});

it('renders empty field', () => {
const { getByText } = renderComponent;
expect(getByText('No related agreements for this agreement')).toBeInTheDocument();
Expand Down Expand Up @@ -132,8 +127,8 @@ describe('RelatedAgreementsFieldArray', () => {

it('renders the expected relationship summary in each field', () => {
const { getByTestId } = renderComponent;
expect(within(getByTestId('relatedAgreementsFieldArray[0]')).getByText(/"AM ag 1" supersedes the agreement being created/)).toBeInTheDocument();
expect(within(getByTestId('relatedAgreementsFieldArray[1]')).getByText(/"AM ag 3" provides post-cancellation access for the agreement being created/)).toBeInTheDocument();
expect(within(getByTestId('relatedAgreementsFieldArray[0]')).getByText(/"Related agreement 1" supersedes the agreement being created/)).toBeInTheDocument();
expect(within(getByTestId('relatedAgreementsFieldArray[1]')).getByText(/"Related agreement 2" provides post-cancellation access for the agreement being created/)).toBeInTheDocument();
});

it('renders the expected note in each field', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const useLinkedWarning = (change, input, parentAgreementId, triggerButton) => {
if (!input.value?.id && triggerButton.current) {
triggerButton.current.focus();
}
}, [input, triggerButton]);
}, [input.value?.id, triggerButton]);


useEffect(() => {
if (parentAgreementId && parentAgreementId === input.value?.id && !selfLinkedWarning) {
Expand Down
9 changes: 9 additions & 0 deletions src/components/utilities/focusByIdWhenReady.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const focusByIdWhenReady = (id) => {
requestAnimationFrame(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we should be using animationFrames like this.

If we need to wait for a ref to be filled, we can use a callback ref and check whether the current is null or not

requestAnimationFrame(() => {
document.getElementById(id)?.focus();
});
});
};

export default focusByIdWhenReady;
42 changes: 42 additions & 0 deletions src/components/utilities/focusByIdWhenReady.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import focusByIdWhenReady from './focusByIdWhenReady';

describe('focusByIdWhenReady', () => {
let pendingCallbacks;

beforeEach(() => {
pendingCallbacks = [];

global.requestAnimationFrame = jest.fn(cb => {
pendingCallbacks.push(cb);
});
});

afterEach(() => {
jest.restoreAllMocks();
});

test('focuses the element with the given id when ready', () => {
const focus = jest.fn();

jest.spyOn(document, 'getElementById').mockReturnValue({ focus });

focusByIdWhenReady('test-id');

pendingCallbacks.shift()();
pendingCallbacks.shift()();

expect(document.getElementById).toHaveBeenCalledWith('test-id');
expect(focus).toHaveBeenCalledTimes(1);
});

test('does nothing if the element does not exist', () => {
jest.spyOn(document, 'getElementById').mockReturnValue(null);

focusByIdWhenReady('missing-id');

pendingCallbacks.shift()();
pendingCallbacks.shift()();

expect(document.getElementById).toHaveBeenCalledWith('missing-id');
});
});
1 change: 1 addition & 0 deletions src/components/utilities/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export { default as getTranslatedAcqMethod } from './getTranslatedAcqMethod';
export { getRefdataValuesByDesc } from '@folio/stripes-erm-components';
export { filterObjectKeys, filterIgnoreObjectKeys } from './filterObjectKeys';
export * from './entitlementOptions';
export { default as focusByIdWhenReady } from './focusByIdWhenReady';
1 change: 1 addition & 0 deletions test/jest/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { agreements } from './agreements';
export { pcis, pkgs, platforms, ptis, tis, works } from './eresources';
export { externalEntitlements, entitlements } from './entitlements';
export { default as refdata } from './refdata';
export { relatedAgreements } from './relatedAgreements';
28 changes: 28 additions & 0 deletions test/jest/relatedAgreements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import refdata from './refdata';

export const relatedAgreements = [
Copy link
Contributor

Choose a reason for hiding this comment

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

If I'm reading this right this is just a list of agreements. We should either use the agreements already in the centralised agreements resource, or add these to that list and extract by name/id in the test

{
id: '6bcca40d-138c-4c51-b7f5-235fb02552a6',
note: 'test note 1',
agreement: {
id: 'f591806e-fe8c-4b16-a3cb-8cccc180d82b',
name: 'Related agreement 1',
agreementStatus: refdata.find(rdc => rdc.desc === 'SubscriptionAgreement.AgreementStatus').values.find(rdv => rdv.value === 'active'),
startDate: '2021-09-02',
endDate: null
},
type: refdata.find(rdc => rdc.desc === 'AgreementRelationship.Type').values.find(rdv => rdv.value === 'supersedes').value
},
{
id: 'ac2fde6e-a6a2-4df5-b244-8d803382d2d5',
note: 'test note 2',
agreement: {
id: 'b958c1be-54f5-4f3d-9131-4255cd21b109',
name: 'Related agreement 2',
agreementStatus: refdata.find(rdc => rdc.desc === 'SubscriptionAgreement.AgreementStatus').values.find(rdv => rdv.value === 'in_negotiation'),
startDate: '2021-09-18',
endDate: null
},
type: refdata.find(rdc => rdc.desc === 'AgreementRelationship.Type').values.find(rdv => rdv.value === 'provides_post-cancellation_access_for').value
}
];