Bugzilla – Attachment 101936 Details for
Bug 15522
New interface for revamped circulation rules
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 15522: New interface for updated circulation rules
Bug-15522-New-interface-for-updated-circulation-ru.patch (text/plain), 58.15 KB, created by
Lari Taskula
on 2020-03-27 03:10:30 UTC
(
hide
)
Description:
Bug 15522: New interface for updated circulation rules
Filename:
MIME Type:
Creator:
Lari Taskula
Created:
2020-03-27 03:10:30 UTC
Size:
58.15 KB
patch
obsolete
>From 0b65cfbff602ab679032bb41757410cb53b0bd3e Mon Sep 17 00:00:00 2001 >From: Jesse Weaver <pianohacker@gmail.com> >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 > >Signed-off-by: Lisette Scheer <lisettes@latahlibrary.org> >--- > Koha/Template/Plugin/To.pm | 2 +- > admin/policy.pl | 47 +++ > .../intranet-tmpl/prog/css/admin/policy.css | 160 +++++++++ > .../prog/en/modules/admin/policy.tt | 71 ++++ > .../prog/js/src/admin/policy/app.js | 227 +++++++++++++ > .../prog/js/src/admin/policy/common.js | 38 +++ > .../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 67902e6ac0..d3e0f32bc1 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 <http://www.gnu.org/licenses>. >+ >+ >+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' %] >+<title>Koha › Administration › Policy</title> >+[% INCLUDE 'doc-head-close.inc' %] >+<link rel="stylesheet" href="[% interface %]/[% theme %]/css/admin/policy.css" /> >+</head> >+<body id="admin_policy" class="admin"> >+[% INCLUDE 'header.inc' %] >+[% INCLUDE 'prefs-admin-search.inc' %] >+ >+<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> › <a href="/cgi-bin/koha/admin/admin-home.pl">Administration</a> › Circulation, fines and holds rules</div> >+ >+<div class="main container-fluid"> >+ <div class="row"> >+ <div class="col-sm-10 col-sm-push-2"> >+ <main> >+ <div id="react-base"> >+ <h1>Loading, please wait...</h1> >+ </div> >+ </main> >+ </div> >+ <div class="col-sm-2 col-sm-pull-10"> >+ <aside> >+ [% INCLUDE 'admin-menu.inc' %] >+ </aside> >+ </div> <!-- /.col-sm-2.col-sm-pull-10 --> >+ </div> <!-- /.row --> >+ >+[% 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' %] >+ >+ <script> >+ var restricted_to_library = "[% restricted_to_library %]"; >+ >+ Koha.Preferences = { >+ "dateformat": "[% Koha.Preference( 'dateformat' ) %]", >+ }; >+ >+ Koha.BRANCHES = { >+ [% FOREACH branch IN Branches.all( unfiltered => 1 ) %] >+ "[% branch.branchcode %]": [% To.json( branch ) %], >+ [% END %] >+ }; >+ // TODO: Account for branch category limits >+ Koha.PATRON_CATEGORIES = { >+ [% FOREACH category IN Categories.all() %] >+ "[% category.categorycode %]": [% To.json( category ) %], >+ [% END %] >+ }; >+ Koha.ITEM_TYPES = { >+ [% FOREACH itemtype IN ItemTypes.Get() %] >+ "[% itemtype.itemtype %]": [% To.json( itemtype ) %], >+ [% END %] >+ }; >+ </script> >+[% END %] >+<link rel="stylesheet" type="text/css" href="[% interface %]/[% theme %]/css/humanmsg.css" /> >+[% 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 <button >+ type="button" >+ className={ "btn " + ( selectedGroup == group ? "active " : "" ) + className } >+ onClick={ () => { onChangeGroup( group ); } }> >+ <i className={ "fa " + ( selectedGroup == group ? "fa-check-circle-o" : "fa-circle-o" ) } /> >+ {" " + text} >+ </button>; >+ }; >+ >+ return <div id="toolbar-right" className="btn-group"> >+ { renderButton( null, "btn-primary", __("Show all rules") ) } >+ { renderButton(__("Circulation")) } >+ { renderButton(__("Fines")) } >+ { renderButton(__("Holds")) } >+ </div>; >+} >+ >+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 <div id="toolbar" className="btn-toolbar" ref={ el => { this.toolbarElem = el; } }> >+ <div id="toolbar-left"> >+ <button className="btn btn-success save-all" type="submit" disabled={ !hasChanges } onClick={this.props.onSaveClick}><i className="fa fa-save" /> {__("Save rules")}</button> >+ <button className="btn btn-default" type="button" onClick={this.props.onCleanupClick}><i className="fa fa-clone" /> {__("Cleanup rules")}</button> >+ </div> >+ { window.restricted_to_library ? <div id="toolbar-center"></div> : <div id="toolbar-center"> >+ <label> >+ {__("Select a library: ")} >+ <DropDown >+ value={selectedBranch} >+ choices={ >+ [ [ "", "Defaults for all libraries" ] ].concat( >+ _( Object.keys( Koha.BRANCHES ) ) >+ .map( branchcode => [ branchcode, Koha.BRANCHES[ branchcode ].branchname ] ) >+ .sortBy( 1 ) >+ .value() >+ ) >+ } >+ onChange={ branch => { >+ this.setState( { selectedBranch: branch } ); >+ this.props.onChange( branch ); >+ } } >+ /> >+ </label> >+ <label className="show-all"> >+ <input type="checkbox" checked={ this.props.branch == "*" } onChange={ e => this.props.onChange( e.target.checked ? "*" : this.state.selectedBranch ) } /> >+ {__("Show all")} >+ </label> >+ </div> } >+ <RuleGroupSelector group={this.props.group} onChangeGroup={this.props.onChangeGroup} /> >+ </div>; >+ } >+ >+ 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( __("<h3>Rules saved.</h3>"), { className: "humanSuccess" } ); >+ this.forceUpdate(); >+ break; >+ >+ case 401: >+ humanMsg.displayAlert( __("<h3>Rules not saved</h3><p>Please reload the page to log in again</p>"), { className: "humanError" } ); >+ break; >+ >+ default: >+ humanMsg.displayAlert( __("<h3>Rules not saved</h3><p>Internal error</p>"), { 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 = [ <BranchRuleEditor branch={null} key="" onChangeRule={onChangeRule} /> ].concat( >+ Object.keys( Koha.BRANCHES ).map( branchcode => <BranchRuleEditor >+ key={branchcode} >+ branch={Koha.BRANCHES[ branchcode ]} >+ onChangeRule={onChangeRule} >+ onFocusRule={onFocusRule} >+ /> ) >+ ); >+ } else { >+ branchEditors = <BranchRuleEditor >+ branch={this.state.branch ? Koha.BRANCHES[ this.state.branch ] : null} >+ kindGroup={this.state.kindGroup} >+ key={this.state.branch} >+ onChangeRule={onChangeRule} >+ onFocusRule={onFocusRule} >+ />; >+ } >+ >+ return <section> >+ <h1>{__( "Circulation, fine and hold policy" )}</h1> >+ <PolicyAppToolbar >+ branch={this.state.branch} >+ group={this.state.kindGroup} >+ onChange={ branch => this.setState( { branch } ) } >+ onChangeGroup={ group => this.setState( { kindGroup: group } ) } >+ onSaveClick={ () => { this.onSaveClick(); } } >+ onCleanupClick={ () => { this.onCleanupClick(); } } >+ /> >+ {branchEditors} >+ </section>; >+ } >+} >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 <select ref={ c => this.select = c } value={props.value} onChange={ e => props.onChange( e.target.value ) }> >+ { props.caption && <option disabled value="">{props.caption}</option> } >+ { props.allowAll && <option value="">{__("Defaults")}</option> } >+ { props.choices.map( ( [ key, description ] ) => <option key={key} value={key}>{description}</option> ) } >+ </select>; >+ } >+} >+ >+export class GroupedDropDown extends React.Component { >+ focus() { >+ this.select.focus(); >+ } >+ >+ render() { >+ const props = this.props; >+ >+ return <select ref={ c => this.select = c } value={props.value} onChange={ e => props.onChange( e.target.value ) }> >+ { props.caption && <option disabled value="">{props.caption}</option> } >+ { props.choices.map( ( [ groupName, choices ] ) => <optgroup label={groupName} key={groupName}> >+ { choices.map( ( [ key, description ] ) => <option key={key} value={key}>{description}</option> ) } >+ </optgroup> ) } >+ </select>; >+ } >+} >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( >+ <PolicyApp />, >+ 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 = <DropDown >+ ref={ input => 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 = <input >+ type={ kindInfo.type == "amount" ? "number" : "text" } >+ min={ kindInfo.type == "amount" ? 0 : null } >+ ref={ input => this.input = input } >+ defaultValue={rule.rule_value} >+ pattern={pattern} >+ onBlur={ e => { >+ if ( checkInput() ) { >+ onInputChange( this.input.value ); >+ } >+ >+ e.preventDefault(); >+ } } >+ onChange={onChange} >+ onKeyDown={onKeyDown} >+ />; >+ } >+ tools = <div className="rule-tools"> >+ <a href="#" title={__("Unset this rule")} onClick={onUnsetClick}><i className="fa fa-remove"></i></a> >+ </div>; >+ } 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 = <span className="inherited-rule">{value}</span>; >+ tools = <div className="rule-tools"> >+ <a href="#" title={__("Define this rule")} onClick={onInheritedClick}><i className="fa fa-pencil"></i></a> >+ {rule.rule_value != null && <a >+ onClick={ ( e ) => { >+ if ( !isBuiltInDefaultRule( rule ) ) { >+ onFocusRule( rule, inheritedRuleID ); >+ } >+ >+ e.preventDefault(); >+ } } >+ href={ "#" + inheritedRuleID } >+ title={ __x( "Set by: {path}", { >+ path: renderRulePath( rule ) >+ } ) }> >+ <i className="fa fa-question"></i> >+ </a>} >+ </div>; >+ } >+ >+ return <div >+ className={"policy-rule" + ( >+ ( >+ rules.isChangedRule( { branchcode, categorycode, itemtype, rule_name } ) || >+ rules.isChangedRule( rule ) >+ ) ? " changed-rule" : "" ) >+ } >+ id={`rule-${branchcode}-${categorycode}-${itemtype}-${rule_name}`} >+ > >+ {contents} >+ {tools} >+ </div>; >+ } >+ >+ 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 <th className="all-hidden" title={__("All rules are currently hidden; to see them, change your filter in the toolbar.")}> >+ {__("All rules hidden")} >+ </th>; >+} >+ >+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 <table className="table"> >+ { props.caption && <caption>{props.caption}</caption> } >+ <thead> >+ <tr> >+ <th> </th> >+ { !visibleKinds.length ? <AllHiddenHeader /> : visibleKinds.map( rule_name => >+ <th key={rule_name}>{ RULE_KINDS[rule_name].description }</th> >+ ) } >+ { _.isEmpty( kindChoices ) || <th className="add-rule"> >+ <GroupedDropDown >+ caption={__("Choose rule...")} >+ value="" >+ choices={ kindChoices } >+ onChange={ choice => this.addRuleName(choice) } >+ /> >+ </th> } >+ </tr> >+ </thead> >+ <tbody> >+ { visibleChoices.map( choice => <tr key={choice}> >+ <th scope="row" className={ choice == "" ? "default" : "" }>{ choice ? choiceDescriptions[choice] : __("Defaults") }</th> >+ { visibleKinds.map( rule_name => <td key={rule_name}> >+ <RuleCell >+ key={rule_name} >+ ruleProperties={{ >+ branchcode: this.branchcode, >+ categorycode: props.category ? props.category.categorycode : undefined, >+ [this.ruleKey]: choice, >+ rule_name >+ }} >+ onChangeRule={props.onChangeRule} >+ onFocusRule={props.onFocusRule} >+ /> >+ </td> ) } >+ </tr> ) } >+ { _.isEmpty( availableChoices ) || <tr> >+ <th scope="row"> >+ <DropDown >+ caption={this._dropDownCaption} >+ value="" >+ choices={availableChoices} >+ onChange={ choice => this.addChoice(choice) } >+ /> >+ </th> >+ </tr> } >+ </tbody> >+ </table>; >+ } >+} >+ >+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 <section className={ "category-rules" + ( defaults ? " category-default-rules" : "" ) }> >+ <h4>{ defaults ? __("Defaults for all categories") : props.category.description }</h4> >+ <div className="table-scroll-wrapper"> >+ <ItemTypeRuleEditor >+ branch={props.branch} >+ kindGroup={props.kindGroup} >+ category={props.category} >+ scopeName="branchcode_categorycode_itemtype" >+ onChangeRule={props.onChangeRule} >+ onFocusRule={props.onFocusRule} >+ /> >+ </div> >+ </section>; >+ } >+} >+ >+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 <table className="table"> >+ <thead> >+ <tr> >+ { !allKindNames.length ? <AllHiddenHeader /> : allKindNames.map( rule_name => >+ <th key={rule_name}>{ RULE_KINDS[rule_name].description }</th> >+ ) } >+ </tr> >+ </thead> >+ <tbody> >+ <tr> >+ { allKindNames.map( rule_name => <td key={rule_name}> >+ <RuleCell >+ key={rule_name} >+ ruleProperties={{ >+ branchcode: branch ? branch.branchcode : "", >+ rule_name >+ }} >+ onChangeRule={onChangeRule} >+ onFocusRule={onFocusRule} >+ /> >+ </td> ) } >+ </tr> >+ </tbody> >+ </table>; >+} >+ >+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 <section className={ "branch-rules" + ( defaults ? " default-rules" : "" ) }> >+ <h2>{ defaults ? __( "Defaults for all libraries" ) : props.branch.branchname }</h2> >+ <h3>{__( "Rules only by library" )}</h3> >+ <BranchGlobalRuleEditor >+ branch={props.branch} >+ kindGroup={props.kindGroup} >+ onChangeRule={props.onChangeRule} >+ onFocusRule={props.onFocusRule} >+ /> >+ <CategoryRuleEditor >+ caption={__( "Rules by category" )} >+ branch={props.branch} >+ kindGroup={props.kindGroup} >+ onChangeRule={props.onChangeRule} >+ onFocusRule={props.onFocusRule} >+ /> >+ <ItemTypeRuleEditor >+ caption={__( "Rules by item type" )} >+ branch={props.branch} >+ kindGroup={props.kindGroup} >+ onChangeRule={props.onChangeRule} >+ onFocusRule={props.onFocusRule} >+ /> >+ <h3>{__( "Rules by category and item type" )}</h3> >+ { visibleCategories.map( categorycode => >+ <CategoryItemTypeRuleEditor >+ key={categorycode} >+ branch={props.branch} >+ kindGroup={props.kindGroup} >+ category={categorycode ? Koha.PATRON_CATEGORIES[categorycode] : null} >+ onChangeRule={props.onChangeRule} >+ onFocusRule={props.onFocusRule} >+ /> >+ ) } >+ {_.isEmpty( availableCategories ) || >+ <h4> >+ <DropDown >+ caption={__("Define item-type rules for category...")} >+ value="" >+ choices={ availableCategories } >+ onChange={ choice => this.addCategory(choice) } >+ /> >+ </h4> } >+ </section>; >+ } >+} >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.17.1
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 15522
:
68231
|
68232
|
68233
|
68234
|
68235
|
71048
|
71049
|
71050
|
71051
|
71052
|
72239
|
72242
|
91977
|
91978
|
91979
|
91980
|
91981
|
91982
|
91983
|
91984
|
94201
|
94202
|
94203
|
94204
|
94205
|
94206
|
94207
|
94208
|
99148
|
99149
|
99150
|
99151
|
99152
|
99153
|
99154
|
99155
|
99156
|
99157
|
99199
|
99200
|
99201
|
99202
|
99203
|
99204
|
99205
|
99206
|
99207
|
99208
|
99209
|
99210
|
99211
|
99213
|
99214
|
99215
|
101918
|
101919
|
101920
|
101921
|
101922
|
101923
|
101924
|
101925
|
101926
|
101927
|
101934
|
101935
|
101936
|
101937
|
101938
|
101939
|
101940
|
101941
|
101942
|
101943
|
101944
|
101945
|
101946
|
101947
|
106913
|
106914
|
106915
|
106916
|
106917
|
106918
|
106919
|
106920
|
106921
|
106922
|
106923
|
106924
|
106925
|
106926
|
112056
|
112057
|
112058
|
112059
|
112060
|
112061