[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 25 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?
There was a problem hiding this comment.
Ah, that makes sense. Learn something new everyday.
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.
… rules validation errors.
…ot yet saved, the "Add Library" button is not active if there is an invalid patron block rule. Also, inline errors for duplicate names across patron block rules within a library.
|
@tdilauro : I think this PR should be good now. In addition to addressing the issues you raised, I made some other changes to ensure that the user can't "Add Library" or click "Save" if there are outstanding validation issues. I also added support for inline validation checks (both changed here and new functionality added on the backend) to ensure that the user gets instant feedback about the validity of a rule (uniqueness of name as well as proper syntax and templated values based on live feedback from the SIP2 api). I tested it extensively locally before committing the changes. |
tdilauro
left a comment
There was a problem hiding this comment.
I like the async validation you've added here. There's still an important issue to resolve with removeRule. Additionally, there are a bunch of mostly "non happy path" cases that are not covered by tests, mostly in src/components/PatronBlockingRulesEditor.tsx and src/components/PatronAuthServiceEditForm.tsx.
|
|
||
| const removeRule = (index: number) => { | ||
| const removedId = rules[index]._id; | ||
| setRules((prev) => prev.filter((_, i) => i !== index)); |
There was a problem hiding this comment.
Change requested - It looks like there is still the same problem with removeRule. I think it would be helpful to write a test for this case where you start with two rules, the first of which validates, the second of which does not. Then delete the first rule. Verify that what was the second rule is now the first (and only) rule and that the error message is associated with that rule is still at the right place on the page.
I manually ran this locally just to verify that it is behaving as I described above.
| const errorMessage = await validatePatronBlockingRuleExpression( | ||
| serviceId, | ||
| rule, | ||
| csrfToken | ||
| ); | ||
| setServerErrors((prev) => ({ ...prev, [index]: errorMessage })); |
There was a problem hiding this comment.
There's a possible race condition here because we're rendering multiple rules down below, each with their own onRuleBlur. That means that there might be multiple validatePatronBlockingRuleExpression fetches in flight at the same time and the last one to resolve will win, which might not end up being what you want.
It would be more likely to happen with slower services and an ILS that has a lot of variability. I don't think this will be a big problem, but since it is a possibility, it's probably worth adding a comment about it, in case we actually run into it.
| export interface ServiceEditFormProps<T> { | ||
| data: T; | ||
| item?: ServiceData; | ||
| additionalData?: any; |
There was a problem hiding this comment.
Minor - It would be good to narrow this from any to per-service effective types. We'd need to use generics for that.
| <Button | ||
| type="button" | ||
| className="add-patron-blocking-rule" | ||
| disabled={disabled || hasIncompleteRule} | ||
| callback={addRule} | ||
| content="Add Rule" | ||
| /> |
There was a problem hiding this comment.
Minor, especially since this is MVP, but might need future design - This button is a bit confusing, possibly due to the proximity of the library's Save button.
There was a problem hiding this comment.
Yes - I agree. I'll make a note for future review.
| /> | ||
| ))} | ||
| </ul> | ||
| </div> |
There was a problem hiding this comment.
Minor - It would be useful to fetch the supported ILS attributes /functions to display at the bottom of the list of rules to provide some initial guidance to the user.
There was a problem hiding this comment.
I agree - I'll create a new ticket for this.
Co-authored-by: Tim DiLauro <tdilauro@users.noreply.github.com>
…ditor, adding coverage for expected error conditions as well as testing for expected button enabled/disabled states.
… was deleted and add a test to confirm the fix.
|
@tdilauro : thanks for the second round of comments. I added a bunch of new tests, fixed the remove bug (and added the test to prove it as well as manually confirmed it is fixed), and added a note regarding the potential race condition. I have added a new ticket for providing guidance. https://ebce-lyrasis.atlassian.net/browse/PP-3866 |
… 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: