[PP-3820] Add patron blocking rules editor to the Library Settings UI…#201
[PP-3820] Add patron blocking rules editor to the Library Settings UI…#201dbernstein wants to merge 18 commits intomainfrom
Conversation
4065f00 to
d7fca0d
Compare
tdilauro
left a comment
There was a problem hiding this comment.
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.
src/components/ServiceEditForm.tsx
Outdated
|
|
||
| /** 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( |
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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.
| 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> |
There was a problem hiding this comment.
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.
src/interfaces.ts
Outdated
| short_name: string; | ||
| [key: string]: string; | ||
| patron_blocking_rules?: PatronBlockingRule[]; | ||
| [key: string]: string | PatronBlockingRule[] | undefined; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
src/utils/patronBlockingRules.ts
Outdated
| @@ -0,0 +1,5 @@ | |||
| export const SIP2_PROTOCOL = "api.sip"; | |||
|
|
|||
| export function supportsPatronBlockingRules(protocolName: string): boolean { | |||
There was a problem hiding this comment.
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"> |
There was a problem hiding this comment.
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>.
| {serverErrorMessage && rules.length > 0 && ( | ||
| <p className="patron-blocking-rule-field-error text-danger"> | ||
| {serverErrorMessage} | ||
| </p> | ||
| )} |
There was a problem hiding this comment.
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?
src/components/ServiceEditForm.tsx
Outdated
| /** 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; | ||
| } | ||
|
|
There was a problem hiding this comment.
Minor (style) - We might want to put this up with the other subclass hooks.
… for SIP2 patron auth providers
…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.
4229b46 to
b727a1a
Compare
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.
e118dc0 to
b7963a1
Compare
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.
… 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
Modified files
@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: