Skip to content

[PP-3820] Add patron blocking rules editor to the Library Settings UI…#201

Open
dbernstein wants to merge 18 commits intomainfrom
feature/PP-3820-add-patron-blocks
Open

[PP-3820] Add patron blocking rules editor to the Library Settings UI…#201
dbernstein wants to merge 18 commits intomainfrom
feature/PP-3820-add-patron-blocks

Conversation

@dbernstein
Copy link
Contributor

@dbernstein dbernstein commented Feb 27, 2026

… for SIP2 patron auth providers

Description

Implements an editable list of "Patron Blocking Rules" in the Patron Auth Services configuration, scoped per-library. Rules (name, rule, message) are added/removed in the UI and sent as part of the existing libraries payload on save. The feature is gated to SIP2 in v1; the architecture is designed so that additional provider types (SirsiDynix, Millennium, etc.) can be enabled later with no structural changes.

Changes

New files

  • src/utils/patronBlockingRules.ts — supportsPatronBlockingRules(protocolName) capability gate; SIP2_PROTOCOL constant
  • src/components/PatronBlockingRulesEditor.tsx — Protocol-agnostic, reusable rule list editor with getValue() ref method, *Add Rule / Delete controls, client-side required-field validation, and error/disabled prop support
  • src/components/PatronAuthServiceEditForm.tsx — Extends ServiceEditForm; overrides editLibrary, addLibrary, and three hook methods to inject the editor for SIP2
  • tests/jest/utils/patronBlockingRulesCapability.test.ts
  • tests/jest/components/PatronBlockingRulesEditor.test.tsx
  • tests/jest/components/PatronAuthServiceEditForm.test.tsx

Modified files

  • src/interfaces.ts — New PatronBlockingRule interface; LibraryWithSettingsData widened to allow PatronBlockingRule[] values
  • src/components/ServiceEditForm.tsx — Three protected hook methods added (protocolHasLibrarySettings, renderExtraExpandedLibrarySettings, renderExtraNewLibrarySettings); no behaviour change to existing callers
  • src/components/PatronAuthServices.tsx — EditForm swapped from ServiceEditForm to PatronAuthServiceEditForm

@tdilauro : In the last commit I asked Claude to examine the updates I made and to the extent that it made sense, use the most modern React programming patterns that exist within this code base. I'm especially curious to know what you think of it's approach.

Motivation and Context

https://ebce-lyrasis.atlassian.net/browse/PP-3820

How Has This Been Tested?

New unit test coverage added.
This PR was tested extensively on my local instance against it's circulation repository counterpart: ThePalaceProject/circulation#3090

Checklist:

  • I have updated the documentation accordingly.
  • All new and existing tests passed.

@dbernstein dbernstein force-pushed the feature/PP-3820-add-patron-blocks branch from 4065f00 to d7fca0d Compare March 3, 2026 20:24
@dbernstein dbernstein marked this pull request as ready for review March 3, 2026 21:16
@dbernstein dbernstein requested a review from a team March 3, 2026 21:16
Copy link
Contributor

@tdilauro tdilauro left a comment

Choose a reason for hiding this comment

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

I've put a first round of comments on this one. This React and Typescript stuff can be a bit tricky. Let me know if you get stuck on any of the feedback.

Also, a lot of the changes are missing test coverage (e.g., there are no test changes for src/components/ServiceEditForm.tsx), so I have not yet gone through the tests carefully.


/** Hook for subclasses to inject extra fields into the expanded per-library settings panel.
* Rendered after protocol library_settings fields and before the Save button. */
renderExtraExpandedLibrarySettings(
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion - Rename this function to renderExtraAssociatedLibrarySettings or renderExtraActiveLibrarySettings to parallel with the "New" in renderExtraNewLibrarySettings below. The current name was confusing me when I looked at other parts of this PR.

The problem here may just be me misunderstanding the intent here. I don't think of these as expanded libraries, but rather as associated (with the integration) libraries. It's true that they are expanded in the interface when we are editing them, but that is more a property of the UI design than something intrinsic to the libraries themselves.

<Button
type="button"
className="add-patron-blocking-rule"
disabled={disabled}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion - This button should also be disabled whenever there is an incomplete rule (missing any required field) in the form. Otherwise, a user can just create a bunch of incomplete rules.

Currently, this is disabled with just the prop that is passed when the this component is mounted.

Comment on lines +127 to +183
return (
<li key={index} className="patron-blocking-rule-row">
<WithRemoveButton
disabled={disabled}
onRemove={() => removeRule(index)}
>
<div className="patron-blocking-rule-fields">
{nameClientError && (
<p className="patron-blocking-rule-field-error text-danger">
Rule Name is required.
</p>
)}
<EditableInput
elementType="input"
type="text"
label="Rule Name"
name={`patron_blocking_rule_name_${index}`}
value={rule.name}
required={true}
disabled={disabled}
readOnly={disabled}
optionalText={false}
clientError={rowErrors.name}
error={error}
onChange={(value) => updateRule(index, "name", value)}
/>
{ruleClientError && (
<p className="patron-blocking-rule-field-error text-danger">
Rule Expression is required.
</p>
)}
<EditableInput
elementType="textarea"
label="Rule Expression"
name={`patron_blocking_rule_rule_${index}`}
value={rule.rule}
required={true}
disabled={disabled}
readOnly={disabled}
optionalText={false}
clientError={rowErrors.rule}
error={error}
onChange={(value) => updateRule(index, "rule", value)}
/>
<EditableInput
elementType="textarea"
label="Message (optional)"
name={`patron_blocking_rule_message_${index}`}
value={rule.message || ""}
disabled={disabled}
readOnly={disabled}
optionalText={false}
onChange={(value) => updateRule(index, "message", value)}
/>
</div>
</WithRemoveButton>
</li>
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion - Factor this out into its own little RuleFormListItem (or similarly named) component. This is really a lot to pack into the middle of some JSX and makes it difficult to reason over the function of this code.

short_name: string;
[key: string]: string;
patron_blocking_rules?: PatronBlockingRule[];
[key: string]: string | PatronBlockingRule[] | undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we actually need to change this line?

  • Can keys other than patron_blocking_rules, which is already listed as optional above, contain a list of PatronBlockingRule?
  • Are we planning to return undefined settings?

Copy link
Contributor Author

@dbernstein dbernstein Mar 6, 2026

Choose a reason for hiding this comment

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

I'm not sure about this: But here is what my robot overlord said about it:

"The widening of [key: string]: string | PatronBlockingRule[] | undefined is required by TypeScript. The rule is: every named property in an interface must be assignable to the index signature type. Since patron_blocking_rules?: PatronBlockingRule[] has type PatronBlockingRule[] | undefined, the index signature must include both. There's no way to keep the original [key: string]: string while also having a typed patron_blocking_rules property — TypeScript will reject it."

I tried to have it remove undefined, but Claude said typescript would not accept it. Does this make sense to you?

@@ -0,0 +1,5 @@
export const SIP2_PROTOCOL = "api.sip";

export function supportsPatronBlockingRules(protocolName: string): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should change the type for protocolName here to 'string | null | undefined'.

In PatronAuthServiceEditForm it is called with protocol && protocol.name, which can evaluate to null, undefined, or string. The tests call it with null and undefined, as well.

const ruleClientError = !!rowErrors.rule;

return (
<li key={index} className="patron-blocking-rule-row">
Copy link
Contributor

Choose a reason for hiding this comment

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

Using index for the key here is potentially problematic because it is not unique for a given rule. So, it is possible that the wrong data could be display because the DOM is confused. For example, if we have more than one rule and we delete the first one, then the second one will end up with the index that used to belong to the first one. When that happens, the DOM may not understand that it needs to update that <li>.

Comment on lines +113 to +117
{serverErrorMessage && rules.length > 0 && (
<p className="patron-blocking-rule-field-error text-danger">
{serverErrorMessage}
</p>
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to suppress a server error message just because there are no rules? Would that message be displayed somewhere else or just hidden entirely?

Comment on lines +583 to +589
/** Returns true when this protocol has any editable per-library settings,
* either from the protocol definition or injected by a subclass.
* Subclasses should override this when they add extra library-level fields. */
protocolHasLibrarySettings(protocol: ProtocolData): boolean {
return this.protocolLibrarySettings(protocol).length > 0;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Minor (style) - We might want to put this up with the other subclass hooks.

dbernstein and others added 13 commits March 6, 2026 10:21
…o change everything at once, the style of the

new components to the newer React development style.

Here are the changes:

PatronBlockingRulesEditor.tsx — full function component conversion
*  React.forwardRef + useImperativeHandle: replaces the class instance methods (getValue, validateAndGetValue) with an explicit typed PatronBlockingRulesEditorHandle interface. Callers now get a clean contract rather than reaching into a class instance.
useState for rules and clientErrors: replaces this.state and this.setState.
* Functional updater pattern (e.g. setRules(prev => ...)) in removeRule and updateRule to avoid stale closure issues.
* useImperativeHandle deps array [rules]: ensures the exposed getValue/validateAndGetValue always close over the latest rules without needing a separate ref.
* displayName: set explicitly since forwardRef components lose the inferred name in React DevTools.

PatronAuthServiceEditForm.tsx — modernized refs, kept as class
* Cannot be a function component — it extends ServiceEditForm, a class. Function components can't extend anything; that constraint belongs to the inheritance structure, which a full refactor of ServiceEditForm would be needed to change.
* React.createRef<PatronBlockingRulesEditorHandle>() for the new-library editor, replacing the NEW_LIBRARY_RULES_REF string constant.
* Map<string, React.RefObject<PatronBlockingRulesEditorHandle>> (libraryRulesRefs) for per-library editors, with a getOrCreateLibraryRef() helper — replaces the deprecated string refs (this.refs["library_patron_blocking_rules"]).
* Optional chaining (editorRef?.current) throughout for cleaner null safety.
* PatronBlockingRulesEditor.test.tsx — type fix only
* Updated two React.createRef<PatronBlockingRulesEditor>() calls to React.createRef<PatronBlockingRulesEditorHandle>(), since the component is no longer a class whose instance type can be used as a ref type.
Co-authored-by: Tim DiLauro <tdilauro@users.noreply.github.com>
Co-authored-by: Tim DiLauro <tdilauro@users.noreply.github.com>
… from string to string | null | undefined so it safely handles all values callers may pass.
…rarySettings in ServiceEditForm and PatronAuthServiceEditForm
…t, renderExtraAssociatedLibrarySettings, and renderExtraNewLibrarySettings
The fix requires giving each rule a stable internal ID that doesn't change when other rules are deleted.
Introduce a local RuleEntry type (internal to the component only) that augments PatronBlockingRule with a _id: number field
Use a useRef counter to generate a monotonically increasing ID each time a rule is added (or on initial load)
Use entry._id as the key prop instead of index
Strip the _id when returning values from getValue() / validateAndGetValue()
The PatronBlockingRule interface in interfaces.ts stays untouched — the ID is purely a UI concern internal to the component.

Add a test to improve coverage of this functionalily.
…ield.

Also adds tests to cover change:
The first test checks the button is disabled immediately after adding an empty rule,
and the second verifies it re-enables once both required fields (name and rule) are filled.
…ge check alone is sufficient — it is only non-null when there is an actual error to display. Also add test coverage.
@dbernstein dbernstein force-pushed the feature/PP-3820-add-patron-blocks branch from 4229b46 to b727a1a Compare March 6, 2026 21:04
dbernstein and others added 3 commits March 6, 2026 13:05
Co-authored-by: Tim DiLauro <tdilauro@users.noreply.github.com>
Co-authored-by: Tim DiLauro <tdilauro@users.noreply.github.com>
TypeScript required widening the index signature to
string | PatronBlockingRule[] | undefined when patron_blocking_rules
was added as a named property. Instead, remove the index signature
entirely and use localized unknown-casts at the three call sites that
do dynamic string-key access, keeping the interface semantically
precise.
@dbernstein dbernstein force-pushed the feature/PP-3820-add-patron-blocks branch from e118dc0 to b7963a1 Compare March 6, 2026 21:30
editLibrary — the closing } for the if (supportsPatronBlockingRules(...)) block was missing, which caused a duplicate editorRef declaration and pulled libraries.push / setState inside the malformed block.
addLibrary — the supportsPatronBlockingRules protocol guard was dropped, meaning patron blocking rules would have been collected regardless of the protocol type.
src/api/patronBlockingRules.ts (new) — validatePatronBlockingRuleExpression(serviceId, rule, csrfToken) — sends FormData POST, returns null on 200 or the error detail string on failure.
src/components/PatronAuthServices.tsx — Added additionalData: { csrfToken: ownProps.csrfToken } to mapStateToProps to thread the CSRF token through to the edit form.
src/components/PatronAuthServiceEditForm.tsx — Passes csrfToken and serviceId (this.props.item?.id) to both PatronBlockingRulesEditor instances.
src/components/PatronBlockingRulesEditor.tsx — Added csrfToken/serviceId props, serverErrors state, handleRuleBlur async handler, clear-on-edit logic in updateRule, and <div onBlur> wrapper + serverRuleError display in RuleFormListItem.
tests/jest/api/patronBlockingRules.test.ts (new) — 5 tests covering the API function (null on 200, detail string on 400, fallback string, correct headers, undefined serviceId).
tests/jest/components/PatronBlockingRulesEditor.test.tsx — Added 6 blur/server-error tests and added beforeEach/afterEach fetch mock setup to both describe blocks to prevent incidental blur events from failing unrelated tests.

Other changes:

.eslintrc — Added a @typescript-eslint/no-unused-vars rule override with three options:
argsIgnorePattern: "^_" — suppresses the _library, _protocol, _disabled parameter warnings in ServiceEditForm.tsx (and any similar _-prefixed intentionally-unused parameters elsewhere)
ignoreRestSiblings: true — suppresses the _id warnings in PatronBlockingRulesEditor.tsx, which is the standard ESLint way to allow the { _id, ...rest } "strip a key" pattern
varsIgnorePattern: "^_" — consistent _-prefix convention for variables too
ServiceEditForm.tsx — Renamed library → _library in isLibraryRemovalPermitted (the only parameter that didn't already have the _ prefix the others used)
PatronAuthServiceEditForm.tsx — Removed the unused PatronBlockingRule import
PatronBlockingRulesEditor.tsx — Added // eslint-disable-next-line jsx-a11y/no-static-element-interactions with an explanatory comment. The wrapper <div> is a focus-event delegation container (React's synthetic blur bubbles from the textarea), not an interactive element — an eslint-disable with a clear rationale is the honest and correct fix here rather than misusing a role to silence the rule.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants