From 279629adead16454e56d2c040888647dfbd9b05f Mon Sep 17 00:00:00 2001 From: Daniel Bernstein Date: Fri, 27 Feb 2026 13:26:51 -0800 Subject: [PATCH 01/26] [PP-3820] Add patron blocking rules editor to the Library Settings UI for SIP2 patron auth providers --- src/components/PatronAuthServiceEditForm.tsx | 122 ++++++++++ src/components/PatronAuthServices.tsx | 4 +- src/components/PatronBlockingRulesEditor.tsx | 171 ++++++++++++++ src/components/ServiceEditForm.tsx | 38 ++- src/interfaces.ts | 9 +- src/utils/patronBlockingRules.ts | 5 + .../PatronAuthServiceEditForm.test.tsx | 223 ++++++++++++++++++ .../PatronBlockingRulesEditor.test.tsx | 111 +++++++++ .../patronBlockingRulesCapability.test.ts | 23 ++ 9 files changed, 699 insertions(+), 7 deletions(-) create mode 100644 src/components/PatronAuthServiceEditForm.tsx create mode 100644 src/components/PatronBlockingRulesEditor.tsx create mode 100644 src/utils/patronBlockingRules.ts create mode 100644 tests/jest/components/PatronAuthServiceEditForm.test.tsx create mode 100644 tests/jest/components/PatronBlockingRulesEditor.test.tsx create mode 100644 tests/jest/utils/patronBlockingRulesCapability.test.ts diff --git a/src/components/PatronAuthServiceEditForm.tsx b/src/components/PatronAuthServiceEditForm.tsx new file mode 100644 index 0000000000..437b610891 --- /dev/null +++ b/src/components/PatronAuthServiceEditForm.tsx @@ -0,0 +1,122 @@ +import * as React from "react"; +import { PatronAuthServicesData } from "../interfaces"; +import { + LibraryWithSettingsData, + PatronBlockingRule, + ProtocolData, +} from "../interfaces"; +import ServiceEditForm from "./ServiceEditForm"; +import PatronBlockingRulesEditor from "./PatronBlockingRulesEditor"; +import { supportsPatronBlockingRules } from "../utils/patronBlockingRules"; + +const NEW_LIBRARY_RULES_REF = "new_library_patron_blocking_rules"; + +/** Extends ServiceEditForm with patron-blocking-rules support for protocols that + * support it (v1: SIP2 only). The editor is injected via the hook methods added + * to ServiceEditForm; editLibrary and addLibrary are overridden to collect the + * rules from the editor ref and persist them in library state. */ +export default class PatronAuthServiceEditForm extends ServiceEditForm< + PatronAuthServicesData +> { + protocolHasLibrarySettings(protocol: ProtocolData): boolean { + return ( + super.protocolHasLibrarySettings(protocol) || + supportsPatronBlockingRules(protocol && protocol.name) + ); + } + + renderExtraExpandedLibrarySettings( + library: LibraryWithSettingsData, + protocol: ProtocolData, + disabled: boolean + ): React.ReactNode { + if (!supportsPatronBlockingRules(protocol && protocol.name)) { + return null; + } + return ( + + ); + } + + renderExtraNewLibrarySettings( + protocol: ProtocolData, + disabled: boolean + ): React.ReactNode { + if (!supportsPatronBlockingRules(protocol && protocol.name)) { + return null; + } + return ( + + ); + } + + editLibrary(library: LibraryWithSettingsData, protocol: ProtocolData) { + const libraries = this.state.libraries.filter( + (stateLibrary) => stateLibrary.short_name !== library.short_name + ); + const expandedLibraries = this.state.expandedLibraries.filter( + (shortName) => shortName !== library.short_name + ); + const newLibrary: LibraryWithSettingsData = { + short_name: library.short_name, + }; + for (const setting of this.protocolLibrarySettings(protocol)) { + const value = (this.refs[ + library.short_name + "_" + setting.key + ] as any).getValue(); + if (value) { + newLibrary[setting.key] = value; + } + } + if (supportsPatronBlockingRules(protocol && protocol.name)) { + const editorRef = this.refs[ + `${library.short_name}_patron_blocking_rules` + ] as PatronBlockingRulesEditor | undefined; + if (editorRef) { + newLibrary.patron_blocking_rules = editorRef.getValue(); + } + } + libraries.push(newLibrary); + const newState = Object.assign({}, this.state, { + libraries, + expandedLibraries, + }); + this.setState(newState); + } + + addLibrary(protocol: ProtocolData) { + const name = this.state.selectedLibrary; + const newLibrary: LibraryWithSettingsData = { short_name: name }; + for (const setting of this.protocolLibrarySettings(protocol)) { + const value = (this.refs[setting.key] as any).getValue(); + if (value) { + newLibrary[setting.key] = value; + } + (this.refs[setting.key] as any).clear(); + } + if (supportsPatronBlockingRules(protocol && protocol.name)) { + const editorRef = this.refs[NEW_LIBRARY_RULES_REF] as + | PatronBlockingRulesEditor + | undefined; + if (editorRef) { + newLibrary.patron_blocking_rules = editorRef.getValue(); + } + } + const libraries = this.state.libraries.concat(newLibrary); + const newState = Object.assign({}, this.state, { + libraries, + selectedLibrary: null, + }); + this.setState(newState); + } +} diff --git a/src/components/PatronAuthServices.tsx b/src/components/PatronAuthServices.tsx index 7f084cd5e1..e3fa9c9736 100644 --- a/src/components/PatronAuthServices.tsx +++ b/src/components/PatronAuthServices.tsx @@ -7,7 +7,7 @@ import EditableConfigList, { import { connect } from "react-redux"; import ActionCreator from "../actions"; import { PatronAuthServicesData, PatronAuthServiceData } from "../interfaces"; -import ServiceEditForm from "./ServiceEditForm"; +import PatronAuthServiceEditForm from "./PatronAuthServiceEditForm"; import NeighborhoodAnalyticsForm from "./NeighborhoodAnalyticsForm"; /** Right panel for patron authentication services on the system @@ -18,7 +18,7 @@ export class PatronAuthServices extends EditableConfigList< PatronAuthServicesData, PatronAuthServiceData > { - EditForm = ServiceEditForm; + EditForm = PatronAuthServiceEditForm; ExtraFormSection = NeighborhoodAnalyticsForm; extraFormKey = "neighborhood_mode"; listDataKey = "patron_auth_services"; diff --git a/src/components/PatronBlockingRulesEditor.tsx b/src/components/PatronBlockingRulesEditor.tsx new file mode 100644 index 0000000000..9120d43c16 --- /dev/null +++ b/src/components/PatronBlockingRulesEditor.tsx @@ -0,0 +1,171 @@ +import * as React from "react"; +import { Button } from "library-simplified-reusable-components"; +import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; +import { PatronBlockingRule } from "../interfaces"; +import EditableInput from "./EditableInput"; +import WithRemoveButton from "./WithRemoveButton"; + +export interface PatronBlockingRulesEditorProps { + value?: PatronBlockingRule[]; + disabled?: boolean; + error?: FetchErrorData; +} + +interface PatronBlockingRulesEditorState { + rules: PatronBlockingRule[]; + clientErrors: { [index: number]: { name?: boolean; rule?: boolean } }; +} + +/** Protocol-agnostic editor for a list of patron blocking rules stored in library settings. */ +export default class PatronBlockingRulesEditor extends React.Component< + PatronBlockingRulesEditorProps, + PatronBlockingRulesEditorState +> { + static defaultProps: Partial = { + value: [], + disabled: false, + }; + + constructor(props: PatronBlockingRulesEditorProps) { + super(props); + this.state = { + rules: (props.value || []).map((r) => ({ ...r })), + clientErrors: {}, + }; + this.addRule = this.addRule.bind(this); + } + + /** Returns the current list of rules; called via ref by parent form logic. */ + getValue(): PatronBlockingRule[] { + return this.state.rules; + } + + addRule() { + const rules = [...this.state.rules, { name: "", rule: "", message: "" }]; + this.setState({ rules }); + } + + removeRule(index: number) { + const rules = this.state.rules.filter((_, i) => i !== index); + const clientErrors = { ...this.state.clientErrors }; + delete clientErrors[index]; + this.setState({ rules, clientErrors }); + } + + updateRule(index: number, field: keyof PatronBlockingRule, value: string) { + const rules = this.state.rules.map((r, i) => + i === index ? { ...r, [field]: value } : r + ); + const clientErrors = { ...this.state.clientErrors }; + if (field === "name" || field === "rule") { + if (clientErrors[index]) { + clientErrors[index] = { ...clientErrors[index], [field]: !value }; + } + } + this.setState({ rules, clientErrors }); + } + + validateAndGetValue(): PatronBlockingRule[] | null { + const clientErrors: { + [index: number]: { name?: boolean; rule?: boolean }; + } = {}; + let valid = true; + this.state.rules.forEach((r, i) => { + const rowErrors: { name?: boolean; rule?: boolean } = {}; + if (!r.name) { + rowErrors.name = true; + valid = false; + } + if (!r.rule) { + rowErrors.rule = true; + valid = false; + } + if (Object.keys(rowErrors).length > 0) { + clientErrors[i] = rowErrors; + } + }); + this.setState({ clientErrors }); + return valid ? this.state.rules : null; + } + + render(): JSX.Element { + const { disabled, error } = this.props; + const { rules, clientErrors } = this.state; + + return ( +
+ + {rules.length === 0 && ( +

No patron blocking rules defined.

+ )} +
    + {rules.map((rule, index) => { + const rowErrors = clientErrors[index] || {}; + return ( +
  • + this.removeRule(index)} + > +
    + + this.updateRule(index, "name", value) + } + /> + + this.updateRule(index, "rule", value) + } + /> + + this.updateRule(index, "message", value) + } + /> +
    +
    +
  • + ); + })} +
+
+ ); + } +} diff --git a/src/components/ServiceEditForm.tsx b/src/components/ServiceEditForm.tsx index c49203051b..f75f960e7b 100644 --- a/src/components/ServiceEditForm.tsx +++ b/src/components/ServiceEditForm.tsx @@ -227,6 +227,25 @@ export default class ServiceEditForm< return []; } + /** 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( + _library: LibraryWithSettingsData, + _protocol: ProtocolData, + _disabled: boolean + ): React.ReactNode { + return null; + } + + /** Hook for subclasses to inject extra fields into the add-new-library panel. + * Rendered after protocol library_settings fields and before the Add Library button. */ + renderExtraNewLibrarySettings( + _protocol: ProtocolData, + _disabled: boolean + ): React.ReactNode { + return null; + } + renderRequiredFields( requiredFields, protocol: ProtocolData, @@ -357,8 +376,7 @@ export default class ServiceEditForm< > {this.props.data && this.props.data.protocols && - this.protocolLibrarySettings(protocol) && - this.protocolLibrarySettings(protocol).length > 0 && ( + this.protocolHasLibrarySettings(protocol) && ( this.expandLibrary(library)} @@ -370,8 +388,7 @@ export default class ServiceEditForm< {!( this.props.data && this.props.data.protocols && - this.protocolLibrarySettings(protocol) && - this.protocolLibrarySettings(protocol).length > 0 + this.protocolHasLibrarySettings(protocol) ) && this.getLibrary(library.short_name) && this.getLibrary(library.short_name).name} @@ -390,6 +407,11 @@ export default class ServiceEditForm< ref={library.short_name + "_" + setting.key} /> ))} + {this.renderExtraExpandedLibrarySettings( + library, + protocol, + disabled + )}