From 68906dc1e696684031df325d87eafb73d78c993f Mon Sep 17 00:00:00 2001 From: Jesse Weaver Date: Tue, 17 Oct 2017 16:44:07 -0600 Subject: [PATCH] Bug 15522: New interface for updated circulation rules Test plan: 1) Make sure all dependencies are applied, and that their DB upgrades have been applied. 2) Build JS (see previous patches for HOWTO). 3) Load .../cgi-bin/koha/admin/policy.pl . 4) Try changing an existing rule (one with a textbox or dropdown). Hit "Save rules", ensure that your changes are saved. 5) Until changes are saved, all cells that are changed or affected by your changes should be highlighted in yellow. 6) Same as above, but deleting a rule by hitting the "x" next to it. 7) Add rows and columns using the "Choose rule..." and "Choose category/item type..." dropdowns. 8) Add rules by clicking the pencil in a currently unset rule cell. Ensure changes are saved. 9) Hit "Cleanup rules"; all redundant rules should be removed. As this only removes rules that are the same as the defaults, this should not change any effective rules. 10) Use the filters at the top right ("Circulation", "Holds" and "Fines") to filter the kinds of rules that are shown. 11) Ensure that any desired library can be shown, including (if selected) all at the same time. JD edits: Removed the changes to filtered categories, will be done in a follow-up --- Koha/Template/Plugin/To.pm | 2 +- admin/policy.pl | 47 ++++ koha-tmpl/intranet-tmpl/prog/css/admin/policy.css | 160 +++++++++++ .../intranet-tmpl/prog/en/modules/admin/policy.tt | 71 +++++ .../intranet-tmpl/prog/js/src/admin/policy/app.js | 227 +++++++++++++++ .../prog/js/src/admin/policy/common.js | 38 +++ .../intranet-tmpl/prog/js/src/admin/policy/core.js | 274 ++++++++++++++++++ .../prog/js/src/admin/policy/entry.js | 28 ++ .../prog/js/src/admin/policy/rule-cell.js | 256 +++++++++++++++++ .../prog/js/src/admin/policy/rule-editors.js | 308 +++++++++++++++++++++ .../prog/js/src/admin/policy/rules.js | 151 ++++++++++ 11 files changed, 1561 insertions(+), 1 deletion(-) create mode 100755 admin/policy.pl create mode 100644 koha-tmpl/intranet-tmpl/prog/css/admin/policy.css create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/admin/policy.tt create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/app.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/common.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/core.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/entry.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-cell.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-editors.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rules.js diff --git a/Koha/Template/Plugin/To.pm b/Koha/Template/Plugin/To.pm index b0a03c4b8c..ffac4a8538 100644 --- a/Koha/Template/Plugin/To.pm +++ b/Koha/Template/Plugin/To.pm @@ -28,7 +28,7 @@ our $DYNAMIC = 1; sub json { my ( $self, $value ) = @_; - my $json = JSON->new->allow_nonref(1); + my $json = JSON->new->allow_nonref(1)->convert_blessed(1); $json = $json->encode($value); $json =~ s/^"|"$//g; # Remove quotes around the strings $json =~ s/\\r/\\\\r/g; # Convert newlines to escaped newline characters diff --git a/admin/policy.pl b/admin/policy.pl new file mode 100755 index 0000000000..c6c771460b --- /dev/null +++ b/admin/policy.pl @@ -0,0 +1,47 @@ +#!/usr/bin/perl + +# This file is part of Koha. +# +# Copyright (C) 2017 Jesse Weaver +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + + +use Modern::Perl; + +use C4::Auth; +use C4::Context; +use C4::Output; + +use Koha::Patrons; + +use CGI qw( -utf8 ); + +my $query = new CGI; + +my ( $template, $borrowernumber, $cookie ) = get_template_and_user( + { template_name => "admin/policy.tt", + authnotrequired => 0, + flagsrequired => { parameters => 'manage_circ_rules' }, + query => $query, + type => "intranet", + debug => 1, + } +); + +my $uid = Koha::Patrons->find( $borrowernumber )->userid; +my $restricted_to_own_library = $uid && haspermission( $uid, { parameters => 'manage_circ_rules_restricted' }, { no_inherit => 1 } ); +$template->param( restricted_to_library => $restricted_to_own_library ? C4::Context::mybranch() : "" ); + +output_html_with_http_headers $query, $cookie, $template->output; diff --git a/koha-tmpl/intranet-tmpl/prog/css/admin/policy.css b/koha-tmpl/intranet-tmpl/prog/css/admin/policy.css new file mode 100644 index 0000000000..d09a11cfac --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/css/admin/policy.css @@ -0,0 +1,160 @@ +#toolbar { + text-align: center; + vertical-align: middle; +} + +#toolbar-left, #toolbar-center, #toolbar-right { + display: inline-block; +} + +#toolbar-left { + float: left; +} + +#toolbar-left > button { + margin-right: 6px; +} + +#toolbar-center { + line-height: 1.42857143; + padding: 3px; +} + +#toolbar-right { + float: right; +} + +#toolbar label input { + margin-left: 10px; + margin-right: 5px; + vertical-align: middle; +} + +#toolbar > button { + margin-right: 15px; + vertical-align: middle; +} + +.branch-rules { + border: 1px solid #333; + margin-top: 20px; + margin-bottom: 25px; + padding: 10px; +} + +.branch-rules h2 { + margin-bottom: 20px; +} + +.branch-rules .table { + border-right-width: 0; + width: auto; +} + +.branch-rules .all-hidden { + color: #888; + font-style: italic; +} + +.branch-rules .table th { + border-right: 1px solid #BCBCBC; + max-width: 10em; + min-width: 10em; + vertical-align: middle; +} + +.branch-rules .table th.add-rule { + max-width: 16em; +} + +.branch-rules .table th[scope=row] { + min-width: 16em; +} + +.branch-rules .table td { + border-right: 1px solid #BCBCBC; + padding: 0; +} + +/* This very odd-looking rule causes dropdowns and textboxes to correctly shrink */ +.branch-rules input, .branch-rules select { + min-width: 0px; + max-width: 100%; + text-overflow: ellipsis; +} + +/* Setting these manually isn't the best practice, but for some godforsaken reason the input changes + * size after an update without these rules. */ +.branch-rules input { + border-color: #BBB; + border-radius: 4px; + border-style: solid; + border-width: 1px !important; + max-width: 6em; + min-width: 6em; + padding: 6px; +} + +.branch-rules input:focus { + border-color: #825300; + border-width: 2px !important; + padding: 5px; +} + +.branch-rules input:valid:focus { + border-color: #538200; +} + +select { + height: 2em; +} + +.inherited-rule { + font-style: italic; +} + +.policy-rule { + display: flex; + line-height: 30px; + justify-content: space-between; + padding: 8px; +} + +.policy-rule.changed-rule { + background-color: #FFFFCC; +} + +.policy-rule .rule-tools { + align-items: center; + display: flex; + opacity: .4; + transition: opacity .15s; +} + +.policy-rule:hover .rule-tools { + opacity: 1; +} + +.rule-tools a { + margin-left: 8px; +} + +.category-rules { + border: 1px solid #666; + margin-bottom: 15px; + margin-top: 15px; + padding: 10px; +} + +.table-scroll-wrapper { + width: 100%; + overflow-x: auto; +} + +.default-rules, .category-default-rules { + border-style: dashed; +} + +.default-rules h2, .category-default-rules h4, th.default { + font-style: italic; +} diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/policy.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/policy.tt new file mode 100644 index 0000000000..ef10b71d6c --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/policy.tt @@ -0,0 +1,71 @@ +[% USE raw %] +[% USE Asset %] +[% USE Branches %] +[% USE Categories %] +[% USE ItemTypes %] +[% USE Koha %] +[% USE To %] +[% SET footerjs = 1 %] + +[% INCLUDE 'doc-head-open.inc' %] +Koha › Administration › Policy +[% INCLUDE 'doc-head-close.inc' %] + + + +[% INCLUDE 'header.inc' %] +[% INCLUDE 'prefs-admin-search.inc' %] + + + +
+
+
+
+
+

Loading, please wait...

+
+
+
+
+ +
+
+ +[% MACRO jsinclude BLOCK %] + [% Asset.js("js/admin-menu.js") | $raw %] + [% Asset.js("lib/hc-sticky.js") | $raw %] + [% Asset.js("lib/jquery/plugins/humanmsg.js") | $raw %] + [% Asset.js("js/built/vendor.js") | $raw %] + [% Asset.js("js/built/admin/policy/entry.js") | $raw %] + [% INCLUDE 'calendar.inc' %] + + +[% END %] + +[% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/app.js b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/app.js new file mode 100644 index 0000000000..7b3743e523 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/app.js @@ -0,0 +1,227 @@ +"use strict"; + +/* global __, Koha, fetch, $, humanMsg */ + +import _ from "lodash"; +import React from "react"; + +import { DropDown } from "./common"; +import rules from "./rules"; +import { focusCell } from "./rule-cell"; +import { BranchRuleEditor } from "./rule-editors"; + +function RuleGroupSelector( { group: selectedGroup, onChangeGroup } ) { + let renderButton = ( group, className="btn-default", text = group ) => { + return ; + }; + + return
+ { renderButton( null, "btn-primary", __("Show all rules") ) } + { renderButton(__("Circulation")) } + { renderButton(__("Fines")) } + { renderButton(__("Holds")) } +
; +} + +class PolicyAppToolbar extends React.Component { + constructor( props ) { + super( props ); + + this.state = { + selectedBranch: props.branch, + }; + } + + render() { + let selectedBranch = this.props.branch == "*" ? this.state.selectedBranch : this.props.branch; + let hasChanges= rules.hasChangedRules(); + + return
{ this.toolbarElem = el; } }> +
+ + +
+ { window.restricted_to_library ?
:
+ + +
} + +
; + } + + componentDidMount() { + $( this.toolbarElem ).fixFloat(); + } +} + +export default class PolicyApp extends React.Component { + constructor( props ) { + super( props ); + + this.state = { + branch: window.restricted_to_library || "", + kindGroup: null, + }; + } + + onSaveClick() { + fetch( "/api/v1/circulation-rules", { + method: "POST", + credentials: "include", + body: JSON.stringify( rules.pullChangedRules() ), + } ).then( response => { + switch( response.status ) { + case 200: + rules.clearChangedRules(); + humanMsg.displayMsg( __("

Rules saved.

"), { className: "humanSuccess" } ); + this.forceUpdate(); + break; + + case 401: + humanMsg.displayAlert( __("

Rules not saved

Please reload the page to log in again

"), { className: "humanError" } ); + break; + + default: + humanMsg.displayAlert( __("

Rules not saved

Internal error

"), { className: "humanError" } ); + break; + } + } ); + } + + onCleanupClick() { + if ( !confirm( __("This will remove any redundant rules (ones that are set to the default value).\n\nThis should not change any circulation behavior, but you will be able to review these changes before saving.") ) ) { + return; + } + + for ( let rule of rules.getAllDefined( {} ) ) { + let defaultRule = {}; + + // We are trying to find any default rules, ignoring any scopes that we're already a + // default rule for. + // + // If we have found a default rule besides the built-in default, the scope will be + // something besides null. + + if ( rule.itemtype != "" && defaultRule.itemtype == null ) { + defaultRule = rules.lookup( { + branchcode: rule.branchcode, + categorycode: rule.categorycode, + itemtype: null, + rule_name: rule.rule_name, + } ); + } + + if ( rule.categorycode != "" && defaultRule.categorycode == null ) { + defaultRule = rules.lookup( { + branchcode: rule.branchcode, + categorycode: null, + itemtype: rule.itemtype, + rule_name: rule.rule_name, + } ); + } + + if ( rule.branchcode != "" && defaultRule.branchcode == null ) { + defaultRule = rules.lookup( { + branchcode: null, + categorycode: rule.categorycode, + itemtype: rule.itemtype, + rule_name: rule.rule_name, + } ); + } + + if ( rule.rule_value == defaultRule.rule_value ) { + rules.unset( { + branchcode: rule.branchcode, + categorycode: rule.categorycode, + itemtype: rule.itemtype, + rule_name: rule.rule_name, + } ); + } + } + + this.forceUpdate(); + } + + render() { + let onChangeRule = ( rule ) => { + if ( rule.rule_value === null ) { + rules.unset( rule ); + } else { + rules.set( rule ); + } + this.forceUpdate(); + }; + + let onFocusRule = ( rule, inheritedRuleID ) => { + let callback = () => { + focusCell( document.getElementById( inheritedRuleID ), true ); + }; + + if ( this.state.branch == "*" || rule.branchcode == this.state.branch ) { + callback(); + } else { + this.setState( { branch: rule.branchcode }, callback ); + } + }; + + let branchEditors; + + if ( this.state.branch == "*" ) { + branchEditors = [ ].concat( + Object.keys( Koha.BRANCHES ).map( branchcode => ) + ); + } else { + branchEditors = ; + } + + return
+

{__( "Circulation, fine and hold policy" )}

+ this.setState( { branch } ) } + onChangeGroup={ group => this.setState( { kindGroup: group } ) } + onSaveClick={ () => { this.onSaveClick(); } } + onCleanupClick={ () => { this.onCleanupClick(); } } + /> + {branchEditors} +
; + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/common.js b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/common.js new file mode 100644 index 0000000000..8ed8e5fc85 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/common.js @@ -0,0 +1,38 @@ +"use strict"; + +/* global __ */ + +import React from "react"; + +export class DropDown extends React.Component { + focus() { + this.select.focus(); + } + + render() { + const props = this.props; + + return ; + } +} + +export class GroupedDropDown extends React.Component { + focus() { + this.select.focus(); + } + + render() { + const props = this.props; + + return ; + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/core.js b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/core.js new file mode 100644 index 0000000000..1035c50a57 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/core.js @@ -0,0 +1,274 @@ +"use strict"; + +/* global __, fetch */ + +import _ from "lodash"; +import rules from "./rules"; + +const RULE_DISPLAY_INFO = { + refund: { + description: __("Refund lost item fee"), + group: __("Fines"), + choices: [ + [ 0, __("No") ], + [ 1, __("Yes") ], + ], + showByDefault: true, + }, + + patron_maxissueqty: { + description: __("Total current checkouts allowed"), + group: __("Circulation"), + type: "amount", + defaultText: __("Unlimited"), + showByDefault: true, + }, + patron_maxonsiteissueqty: { + description: __("Total current on-site checkouts allowed"), + group: __("Circulation"), + type: "amount", + defaultText: __("Unlimited"), + showByDefault: true, + }, + max_holds: { + description: __("Maximum total holds allowed"), + group: __("Holds"), + type: "amount", + defaultText: __("Unlimited"), + showByDefault: true, + }, + + holdallowed: { + description: __("Hold policy"), + group: __("Holds"), + choices: [ + [ 2, __("From any library") ], + [ 1, __("From home library") ], + [ 0, __("No holds allowed") ], + ], + showByDefault: true, + }, + hold_fulfillment_policy: { + description: __("Hold pickup library match"), + group: __("Holds"), + choices: [ + [ "any", __("any library") ], + [ "homebranch", __("item's home library") ], + [ "holdingbranch", __("item's holding library") ], + ], + showByDefault: true, + }, + returnbranch: { + description: __("Return policy"), + group: __("Circulation"), + choices: [ + [ "homebranch", __("Item returns home") ], + [ "holdingbranch", __("Item returns to issuing library") ], + [ "noreturn", __("Item floats") ], + ], + showByDefault: true, + }, + + article_requests: { + description: __("Article requests"), + group: __("Circulation"), + choices: [ + [ "no", __("No") ], + [ "yes", __("Yes") ], + [ "bib_only", __("Record only") ], + [ "item_only", __("Item only") ], + ], + }, + auto_renew: { + description: __("Automatic renewal"), + group: __("Circulation"), + choices: [ + [ "no", __("No") ], + [ "yes", __("Yes") ], + ], + }, + cap_fine_to_replacement_price: { + description: __("Cap fine at replacement price"), + group: __("Fines"), + choices: [ + [ 0, __("No") ], + [ 1, __("Yes") ], + ], + }, + chargeperiod: { + description: __("Fine charging interval"), + group: __("Fines"), + type: "amount", + }, + chargeperiod_charge_at: { + description: __("When to charge"), + group: __("Circulation"), + choices: [ + [ 0, __("End of interval") ], + [ 1, __("Start of interval") ], + ], + }, + fine: { + description: __("Fine amount"), + group: __("Fines"), + type: "currency", + showByDefault: true, + }, + finedays: { + description: __("Suspension in days"), + group: __("Fines"), + type: "amount", + }, + firstremind: { + description: __("Fine grace period"), + group: __("Fines"), + type: "amount", + showByDefault: true, + }, + hardduedate: { + description: __("Hard due date"), + group: __("Circulation"), + type: "date", + defaultText: __("None"), + }, + hardduedatecompare: { + description: __("Hard due date type"), + group: __("Circulation"), + choices: [ + [ -1, __("Before") ], + [ 0, __("Exactly on") ], + [ 1, __("After") ], + ], + }, + holds_per_record: { + description: __("Holds per record (count)"), + group: __("Holds"), + type: "amount", + }, + issuelength: { + description: __("Loan period"), + group: __("Circulation"), + type: "amount", + showByDefault: true, + }, + lengthunit: { + description: __("Loan period unit"), + group: __("Circulation"), + choices: [ + [ "days", __("Days") ], + [ "hours", __("Hours") ], + ], + showByDefault: true, + }, + maxissueqty: { + description: __("Current checkouts allowed"), + group: __("Circulation"), + type: "amount", + defaultText: __("Unlimited"), + showByDefault: true, + }, + maxonsiteissueqty: { + description: __("Current on-site checkouts allowed"), + group: __("Circulation"), + defaultText: __("Unlimited"), + type: "amount", + }, + maxsuspensiondays: { + description: __("Max. suspension duration (days)"), + group: __("Fines"), + defaultText: __("Unlimited"), + type: "amount", + }, + no_auto_renewal_after: { + description: __("No automatic renewal after"), + group: __("Circulation"), + type: "amount", + defaultText: __("Unset"), + }, + no_auto_renewal_after_hard_limit: { + description: __("No automatic renewal after (hard limit)"), + group: __("Circulation"), + type: "date", + defaultText: __("Unset"), + }, + norenewalbefore: { + description: __("No renewal before"), + group: __("Circulation"), + type: "amount", + defaultText: __("Unset"), + }, + onshelfholds: { + description: __("On shelf holds allowed"), + group: __("Holds"), + choices: [ + [ 1, __("Yes") ], + [ 0, __("If any unavailable") ], + [ 2, __("If all unavailable") ], + ], + }, + opacitemholds: { + description: __("Item-level holds"), + group: __("Holds"), + choices: [ + [ "N", __("Don't allow") ], + [ "Y", __("Allow") ], + [ "F", __("Force") ], + ], + }, + overduefinescap: { + description: __("Overdue fines cap (amount)"), + group: __("Fines"), + type: "currency", + defaultText: __("Unlimited"), + }, + renewalperiod: { + description: __("Renewal period"), + group: __("Circulation"), + type: "amount", + }, + renewalsallowed: { + description: __("Renewals allowed (count)"), + group: __("Circulation"), + type: "amount", + defaultText: __("None"), + }, + rentaldiscount: { + description: __("Rental discount (%)"), + group: __("Circulation"), + type: "percent", + defaultText: __("None"), + }, + reservesallowed: { + description: __("Holds allowed (count)"), + group: __("Holds"), + type: "amount", + defaultText: __("None"), + }, + // Not included (deprecated?): + // * accountsent + // * chargename + // * reservecharge + // * restrictedtype +}; + +export var RULE_KINDS = {}, KINDS_BY_SCOPE = {}; + +export function load() { + return Promise.all( [ + fetch( "/api/v1/circulation-rules/kinds" ).then( result => result.json() ).then( result => { + _.merge( RULE_KINDS, RULE_DISPLAY_INFO, result ); + for ( let [ name, kind ] of Object.entries( RULE_KINDS ) ) { + // Display _something_ sensible for unknown rule kinds. + if ( !kind.description ) kind.description = name; + } + + KINDS_BY_SCOPE = _( RULE_KINDS ) + .map( ( kind, name ) => Object.assign( kind, { name, scope_name: kind.scope.join( "_" ) } ) ) + .groupBy( kind => kind.scope_name ) + .value(); + } ), + fetch( "/api/v1/circulation-rules" ).then( result => result.json() ).then( result => { + rules.load( result ); + } ), + ] ); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/entry.js b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/entry.js new file mode 100644 index 0000000000..cffcaeda8e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/entry.js @@ -0,0 +1,28 @@ +"use strict"; + +/* global __ */ + +import React from "react"; +import ReactDOM from "react-dom"; + +import PromisePolyfill from "promise-polyfill"; +if ( !window.Promise ) window.Promise = PromisePolyfill; + +// This polyfill installs itself +import "whatwg-fetch"; + +import { load } from "./core"; +import PolicyApp from "./app"; +import rules from "./rules"; + +window.addEventListener( "beforeunload", ( e ) => { + // NOTE: This string is not shown in most browsers; its exact contents aren't critical. + if ( rules.hasChangedRules() ) e.returnValue = __("Unsaved changes to rules, please save before closing the page"); +} ); + +load().then( () => { + ReactDOM.render( + , + document.getElementById( "react-base" ) + ); +} ); diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-cell.js b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-cell.js new file mode 100644 index 0000000000..ecfd133d34 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-cell.js @@ -0,0 +1,256 @@ +"use strict"; + +/* global __, __x, Koha, fetch, is_valid_date, MSG_PLEASE_ENTER_A_VALID_DATE, get_dateformat_str */ + +import _ from "lodash"; +import React from "react"; +import ReactDOM from "react-dom"; + +import { DropDown } from "./common"; +import { RULE_KINDS } from "./core"; +import rules from "./rules"; + +const RULE_DEBOUNCE_MS = 200; + +export function focusCell( cell, scrollTo = false ) { + let cellInput = cell.querySelector( "input, select" ); + cellInput.focus(); + + if ( scrollTo ) { + // Get the cell in view (for horizontal scrolling), get the table itself all the way in + // view, then fudge a bit for the floating toolbar. + cell.scrollIntoView( true, { block: "start", inline: "nearest", behavior: "smooth" } ); + let tableTop = $( cell ).closest( 'table' )[0].offsetTop; + ( document.scrollingElement || document.documentElement || document.body ).scrollTop = tableTop - document.querySelector("#toolbar").clientHeight * 2; + } + + if ( cellInput.nodeName == "INPUT" && cellInput.type == "text" ) { + cellInput.setSelectionRange( 0, cellInput.value.length ); + } +} + +export default class RuleCell extends React.Component { + constructor(props) { + super(props); + + this.state = { toFocus: false }; + this.debounceTimeout = null; + } + + shouldComponentUpdate( nextProps, nextState ) { + const { ruleProperties: { branchcode, categorycode = "", itemtype = "", rule_name } } = nextProps; + if ( nextProps != this.props || nextState != this.state ) return true; + + return rules.isChangedRule( { branchcode, categorycode, itemtype, rule_name } ); + } + + render() { + const { ruleProperties: { branchcode, categorycode = "", itemtype = "", rule_name }, onChangeRule, onFocusRule } = this.props; + + let rule = rules.lookup( { + branchcode, + categorycode, + itemtype, + rule_name + } ) || {}; + let kindInfo = RULE_KINDS[rule_name]; + let kindChoices = kindInfo.choices ? kindInfo.choices : null; + let kindDescriptions = kindInfo.choices ? _.fromPairs(kindInfo.choices) : {}; + + let onInheritedClick = ( e ) => { + let defaultValue = ""; + + if ( rule.rule_value ) { + defaultValue = rule.rule_value; + } else if ( kindInfo.choices ) { + defaultValue = kindInfo.choices[0][0]; + } + + onChangeRule( { branchcode, categorycode, itemtype, rule_name, rule_value: defaultValue } ); + this.setState( { toFocus: true } ); + + e.preventDefault(); + }; + + let onInputChange = ( value ) => { + if ( value === rule.rule_value ) return; + + onChangeRule( { branchcode, categorycode, itemtype, rule_name, rule_value: value } ); + }; + + let onUnsetClick = ( e ) => { + onChangeRule( { branchcode, categorycode, itemtype, rule_name, rule_value: null } ); + + e.preventDefault(); + }; + + let checkInput = () => { + if ( kindInfo.type == "date" ) { + if ( !is_valid_date( this.input.value ) ) { + this.input.setCustomValidity( MSG_PLEASE_ENTER_A_VALID_DATE.format( get_dateformat_str( Koha.Preferences.dateformat ) ) ); + } else { + this.input.setCustomValidity( "" ); + } + } + + return !( this.input.validity.patternMismatch || this.input.validity.customError ); + }; + + let onKeyDown = ( e ) => { + if ( e.key == "Enter" ) { + if ( checkInput() ) { + onInputChange( this.input.value ); + } + e.preventDefault(); + } + }; + + let onChange = () => { + if ( this.debounceTimeout ) { + clearTimeout( this.debounceTimeout ); + } + + this.debounceTimeout = setTimeout( () => { + if ( checkInput() ) { + onInputChange( this.input.value ); + } + }, RULE_DEBOUNCE_MS ); + }; + + let isBuiltInDefaultRule = ( rule ) => ( rule.branchcode === null && rule.categorycode === null && rule.itemtype === null ); + + let renderRulePath = ( rule ) => { + let result = []; + + if ( isBuiltInDefaultRule( rule ) ) { + return __("Built-in default value"); + } + + for ( let part of RULE_KINDS[ rule.rule_name ].scope ) { + switch ( part ) { + case "branchcode": + result.push( + rule.branchcode == "" ? + __("Library defaults") : + ( "\u201c" + Koha.BRANCHES[ rule.branchcode ].branchname + "\u201d" ) + ); + break; + + case "categorycode": + result.push( + rule.categorycode == "" ? + __("Category defaults") : + ( "\u201c" + Koha.PATRON_CATEGORIES[ rule.categorycode ].description + "\u201d" ) + ); + break; + + case "itemtype": + result.push( + rule.itemtype == "" ? + __("Item type defaults") : + ( "\u201c" + Koha.ITEM_TYPES[ rule.itemtype ].translated_description + "\u201d" ) + ); + break; + } + } + + return result.join( " \u203a " ); + }; + + let contents, tools; + + if ( branchcode === rule.branchcode && categorycode === rule.categorycode && itemtype === rule.itemtype ) { + // The rule we found is actually defined for the given combination + if ( kindChoices ) { + contents = this.input = input } + choices={kindChoices} + value={rule.rule_value} + onChange={onInputChange} + />; + } else { + let pattern; + + switch ( kindInfo.type ) { + case "amount": + pattern = String.raw`\d+`; + break; + + case "currency": + pattern = String.raw`\d+|\d*\.\d+?`; + break; + } + + contents = this.input = input } + defaultValue={rule.rule_value} + pattern={pattern} + onBlur={ e => { + if ( checkInput() ) { + onInputChange( this.input.value ); + } + + e.preventDefault(); + } } + onChange={onChange} + onKeyDown={onKeyDown} + />; + } + tools =
+ +
; + } else { + let value = rule.rule_value; + + if ( rule.rule_value == null ) { + value = __("Unset"); + } else if ( kindChoices && kindDescriptions[rule.rule_value] ) { + value = kindDescriptions[rule.rule_value]; + } else if ( isBuiltInDefaultRule( rule ) && RULE_KINDS[rule_name].defaultText ) { + value = RULE_KINDS[rule_name].defaultText; + } + + let inheritedRuleID = `rule-${rule.branchcode}-${rule.categorycode}-${rule.itemtype}-${rule_name}`; + contents = {value}; + tools = ; + } + + return
+ {contents} + {tools} +
; + } + + componentDidUpdate() { + if ( this.state.toFocus && this.input ) { + focusCell( ReactDOM.findDOMNode( this ) ); + this.setState( { toFocus: false } ); + } + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-editors.js b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-editors.js new file mode 100644 index 0000000000..4ff3eb2b6d --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rule-editors.js @@ -0,0 +1,308 @@ +"use strict"; + +/* global __, Koha, fetch */ + +import _ from "lodash"; +import React from "react"; + +import { DropDown, GroupedDropDown } from "./common"; +import { RULE_KINDS, KINDS_BY_SCOPE } from "./core"; +import rules from "./rules"; +import RuleCell from "./rule-cell"; + +function AllHiddenHeader() { + return + {__("All rules hidden")} + ; +} + +class RuleEditor extends React.Component { + constructor( props ) { + super( props ); + + this.state = { + visibleChoices: _.union( [ "" ], _.sortBy( this._configuredChoices, choice => this._allChoices[choice] ) ), + visibleKinds: _.uniq( + KINDS_BY_SCOPE[this.scopeName] + .filter( kind => kind.showByDefault ) + .map( kind => kind.name ) + .concat( this._ownRules.map( rule => rule.rule_name ) ) + ), + }; + } + + get branchcode() { + return this.props.branch ? this.props.branch.branchcode : ""; + } + + get categorycode() { + // We have to handle both the case where there's never a categorycode and where the category + // is unset (default rules). + if ( this.props.category === null ) { + return ""; + } else if ( this.props.category === undefined ) { + return undefined; + } else { + return this.props.category.categorycode; + } + } + + get _ownRules() { + return rules.getAllDefined( { branchcode: this.branchcode, categorycode: this.categorycode, scope_name: this.scopeName } ); + } + + addChoice( choice ) { + this.setState( { + visibleChoices: this.state.visibleChoices.concat( [ choice ] ), + } ); + } + + addRuleName( choice ) { + this.setState( { + visibleKinds: this.state.visibleKinds.concat( [ choice ] ), + } ); + } + + render() { + const { props, state } = this; + let choices = this._allChoices; + let availableChoices = _( choices ) + .filter( ( [ choice ] ) => !state.visibleChoices.includes( choice ) ) + .sortBy( 1 ) + .value(); + let choiceDescriptions = _.fromPairs( choices ); + let visibleChoices = _.sortBy( state.visibleChoices, choice => choice ? choiceDescriptions[choice] : "" ); + + let allKinds = KINDS_BY_SCOPE[this.scopeName]; + let allKindNames = allKinds.map( rule => rule.name ); + let kindGroups = _( allKindNames ) + .difference( state.visibleKinds ) + .map( rule_name => [ rule_name, RULE_KINDS[rule_name].description ] ) + .sortBy( 1 ) + .groupBy( ( [ rule_name ] ) => RULE_KINDS[rule_name].group ) + .value(); + + let kindChoices = _( props.kindGroup ? { [props.kindGroup]: kindGroups[props.kindGroup] } : kindGroups ) + .toPairs() + .sortBy( 0 ) + .value(); + + if ( kindChoices.length && kindChoices[0][1] == undefined ) kindChoices = null; + + let visibleKinds = state.visibleKinds.filter( rule_name => !props.kindGroup || RULE_KINDS[rule_name].group == props.kindGroup ); + + return + { props.caption && } + + + + { !visibleKinds.length ? : visibleKinds.map( rule_name => + + ) } + { _.isEmpty( kindChoices ) || } + + + + { visibleChoices.map( choice => + + { visibleKinds.map( rule_name => ) } + ) } + { _.isEmpty( availableChoices ) || + + } + +
{props.caption}
 { RULE_KINDS[rule_name].description } + this.addRuleName(choice) } + /> +
{ choice ? choiceDescriptions[choice] : __("Defaults") } + +
+ this.addChoice(choice) } + /> +
; + } +} + +export class CategoryRuleEditor extends RuleEditor { + get scopeName() { return "branchcode_categorycode"; } + get ruleKey() { return "categorycode"; } + get _dropDownCaption() { return __("Choose category..."); } + + get _allChoices() { + return Object.keys( Koha.PATRON_CATEGORIES ).map( categorycode => [ categorycode, Koha.PATRON_CATEGORIES[categorycode].description ] ); + } + + get _configuredChoices() { + return _.uniq( this._ownRules.map( rule => rule.categorycode ) ); + } +} + +// This has to be slightly differently set up than the CategoryRuleEditor, because it's used for +// both branch/itemtype and branch/category/itemtype rules. +export class ItemTypeRuleEditor extends RuleEditor { + static get defaultProps() { + return { scopeName: "branchcode_itemtype" }; + } + + get scopeName() { return this.props.scopeName; } + get ruleKey() { return "itemtype"; } + get _dropDownCaption() { return __("Choose item type..."); } + + get _allChoices() { + return Object.keys( Koha.ITEM_TYPES ).map( itemtype => [ itemtype, Koha.ITEM_TYPES[itemtype].translated_description ] ); + } + + get _configuredChoices() { + return _.uniq( this._ownRules.map( rule => rule.itemtype ) ); + } +} + +export class CategoryItemTypeRuleEditor extends React.Component { + get branchname() { + return this.props.branch ? this.props.branch.branchname : null; + } + + render() { + const props = this.props; + var defaults = props.category == null; + + return
+

{ defaults ? __("Defaults for all categories") : props.category.description }

+
+ +
+
; + } +} + +function BranchGlobalRuleEditor( { branch, kindGroup, onChangeRule, onFocusRule } ) { + let allKinds = KINDS_BY_SCOPE["branchcode"]; + let allKindNames = allKinds.filter( rule => !kindGroup || RULE_KINDS[ rule.name ].group == kindGroup ).map( rule => rule.name ); + + return + + + { !allKindNames.length ? : allKindNames.map( rule_name => + + ) } + + + + + { allKindNames.map( rule_name => ) } + + +
{ RULE_KINDS[rule_name].description }
+ +
; +} + +export class BranchRuleEditor extends React.Component { + constructor( props ) { + super( props ); + this.state = { + visibleCategories: _.union( [ "" ], _.uniq( rules.getAllDefined( { branchcode: this.branchcode, scope_name: "branchcode_categorycode_itemtype" } ) ).map( rule => rule.categorycode ) ), + }; + } + + get branchcode() { + return this.props.branch ? this.props.branch.branchcode : ""; + } + + addCategory( choice ) { + this.setState( { + visibleCategories: this.state.visibleCategories.concat( [ choice ] ), + } ); + } + + render() { + const { props, state } = this; + var defaults = props.branch == null; + + let allCategories = Object.keys( Koha.PATRON_CATEGORIES ).map( categorycode => [ categorycode, Koha.PATRON_CATEGORIES[categorycode].description ] ); + let availableCategories = _( allCategories ) + .filter( ( [ categorycode ] ) => !state.visibleCategories.includes( categorycode ) ) + .sortBy( 1 ) + .value(); + let categoryDescriptions = _.fromPairs( allCategories ); + let visibleCategories = _.sortBy( state.visibleCategories, category => category ? categoryDescriptions[category] : "" ); + + return
+

{ defaults ? __( "Defaults for all libraries" ) : props.branch.branchname }

+

{__( "Rules only by library" )}

+ + + +

{__( "Rules by category and item type" )}

+ { visibleCategories.map( categorycode => + + ) } + {_.isEmpty( availableCategories ) || +

+ this.addCategory(choice) } + /> +

} +
; + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rules.js b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rules.js new file mode 100644 index 0000000000..54be602ccf --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/admin/policy/rules.js @@ -0,0 +1,151 @@ +"use strict"; + +import _ from "lodash"; + +import { RULE_KINDS } from "./core"; + +export default ( window.rules = new class { + constructor() { + this.rules = {}; + this.changedRules = {}; + } + + load( ruleData ) { + // NOTE: We use "" within the frontend, as distinguishing "null" and null gets too funky. + for ( let { branchcode, categorycode, itemtype, rule_name, rule_value } of ruleData ) { + this.set( { + branchcode: branchcode || "", + categorycode: categorycode || "", + itemtype: itemtype || "", + rule_name, + rule_value + } ); + } + + this.clearChangedRules(); + } + + // Find all the defined rules for a given set of criteria. + getAllDefined( { branchcode, categorycode, itemtype, scope_name } ) { + let result = []; + + for ( let ruleBranchCode of ( + branchcode === undefined ? + Object.keys( this.rules ) : + [ branchcode ] + ) ) { + if ( !this.rules[ruleBranchCode] ) continue; + + for ( let ruleCategoryCode of ( + categorycode === undefined ? + Object.keys( this.rules[ruleBranchCode] ) : + [ categorycode ] + ) ) { + if ( !this.rules[ruleBranchCode][ruleCategoryCode] ) continue; + + for ( let ruleItemType of ( + itemtype === undefined ? + Object.keys( this.rules[ruleBranchCode][ruleCategoryCode] ) : + [ itemtype ] + ) ) { + + if ( !this.rules[ruleBranchCode][ruleCategoryCode][ruleItemType] ) continue; + + for ( let [ rule_name, rule_value ] of Object.entries( this.rules[ruleBranchCode][ruleCategoryCode][ruleItemType] ) ) { + if ( scope_name !== undefined && scope_name != RULE_KINDS[rule_name].scope_name ) continue; + + result.push( { + rule_name, + rule_value, + + branchcode: ruleBranchCode, + categorycode: ruleCategoryCode, + itemtype: ruleItemType, + } ); + } + } + } + } + + return result; + } + + lookup( { branchcode, categorycode, itemtype, rule_name } ) { + for ( let ruleBranchCode of [ branchcode, "" ] ) { + if ( !this.rules[ruleBranchCode] ) continue; + + for ( let ruleCategoryCode of [ categorycode, "" ] ) { + if ( !this.rules[ruleBranchCode][ruleCategoryCode] ) continue; + + for ( let ruleItemType of [ itemtype, "" ] ) { + let itemTypeRules = this.rules[ruleBranchCode][ruleCategoryCode][ruleItemType]; + + if( itemTypeRules && itemTypeRules[rule_name] !== undefined ) { + return { + rule_name: rule_name, + rule_value: itemTypeRules[rule_name], + + branchcode: ruleBranchCode, + categorycode: ruleCategoryCode, + itemtype: ruleItemType, + }; + } + } + } + } + + return { + branchcode: null, + categorycode: null, + itemtype: null, + + rule_name, + rule_value: RULE_KINDS[rule_name].default_value + }; + } + + set( { branchcode, categorycode, itemtype, rule_name, rule_value } ) { + _.set( this.rules, [branchcode, categorycode, itemtype, rule_name ], rule_value ); + _.set( this.changedRules, [branchcode, categorycode, itemtype, rule_name ], rule_value ); + } + + unset( { branchcode, categorycode, itemtype, rule_name } ) { + _.unset( this.rules, [branchcode, categorycode, itemtype, rule_name ] ); + _.set( this.changedRules, [branchcode, categorycode, itemtype, rule_name ], null ); + } + + // Returns a list of changed rules (with defaults as null, per the backend). + pullChangedRules() { + let changedRules = []; + + for ( let [ ruleBranchCode, branchRules ] of Object.entries( this.changedRules ) ) { + for ( let [ ruleCategoryCode, categoryRules ] of Object.entries( branchRules ) ) { + for ( let [ ruleItemType, itemtypeRules ] of Object.entries( categoryRules ) ) { + for ( let [ rule_name, rule_value ] of Object.entries( itemtypeRules ) ) { + changedRules.push( { + branchcode: ruleBranchCode || null, + categorycode: ruleCategoryCode || null, + itemtype: ruleItemType || null, + rule_name, + rule_value + } ); + } + } + } + } + + return changedRules; + } + + hasChangedRules() { + return !_.isEmpty( this.changedRules ); + } + + isChangedRule( { branchcode, categorycode, itemtype, rule_name } ) { + return _.has( this.changedRules, [branchcode, categorycode, itemtype, rule_name ] ); + } + + clearChangedRules() { + this.changedRules = {}; + } +} ); -- 2.11.0