Bug 10190: (QA follow-up) UI/UX improvements and fixes - phase three

Trigger deletion and rule set reset
- warn the user that resetting or deleting a given set will break sets using it as fallback.
- indicate which sets will be affected.
- display a preview of the post reset state of each affected rule set.
- preparation: allow TriggerTable to take a title parameter.
- refactor to remove 'has_rules' from the store's rule suffixes list so those match the DB.
- DRY: refactor to implement TriggerTable rather than use custom templates.
- select context stage handles narrower viewports
- prevent crash on missing rule set

TriggersTable: refactor: the <a> elements used as buttons are now button elements
- this is so they may be marked as disabled.

Main page - trigger actions (delete button display)
- disable the trigger deletion button.
  - if the context has been narrowed down ('delete' deletes all explicit rule set per trigger per library).
  - if the trigger belongs to default library AND one or more libraries have a trigger of a number bigger than the selected one.
  This is to protect trigger sequentiality from being broken for the relevant libraries.
  We also include an on hover message to explain the disabled button.

Add/ Edit trigger form
- clarify letters 'undo' button behaviour:
- swap the native vue select X for a fa-undo icon.
- display 'undo override and reset to fallback' on hover.
- only display the add/edit button at the selectOrAdd step.
- restore selected trigger yellow highlight (regression fix).
- display any temporarily saved data from prior step in a green 'success' container.
- only messages and data pulled from the DB for informative purposes display in blue containers.

Other
- redirect to correct URL (admin home) if store load fails
- mtt fallbacks display as such consistently (bold italics) - is a bugfix.
- catch errors on store initialisation
- remove whitespace duplicate
- ensure that mtt, if set, always display

Assisted-by: Claude Opus 4.7 (Anthropic)

AI used to help trace back potential bug sources, flag potential regressions, and draft enhancements. Changes were reviewed, tweaked, and commited manually and gradually (with a pre-submission commit squash).

Sponsored-by: Glasgow Colleges Library Group <https://library.cityofglasgowcollege.ac.uk>
Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
This commit is contained in:
Chloe Zermatten 2026-05-11 11:55:35 +00:00 committed by Pedro Amorim
parent 622f0a4742
commit d6f596c514
No known key found for this signature in database
GPG Key ID: 06B75BA7F3894D55
12 changed files with 658 additions and 513 deletions

View File

@ -20,7 +20,7 @@
<slot></slot>
</div>
<div class="modal-footer">
<ButtonSubmit text="Confirm" />
<ButtonSubmit :title="buttonText" />
<router-link
:to="{
name: 'CirculationTriggersList',
@ -33,7 +33,7 @@
<script>
import ButtonSubmit from "../../ButtonSubmit.vue";
export default {
props: ["submitAction", "formTitle"],
props: ["submitAction", "formTitle", "buttonText"],
components: { ButtonSubmit },
};
</script>

View File

@ -22,7 +22,12 @@
<div class="alert alert-warning">{{ alertMessage }}</div>
</fieldset>
</div>
<div class="page-section">
<div
:class="{
'bg-success-subtle': editMode !== 'confirmContext',
'page-section': true,
}"
>
<ConfirmContext
:context="context"
:contextInitialized="contextInitialized"
@ -41,13 +46,17 @@
<p>{{ $__("Loading rule set information...") }}</p>
</div>
<div
class="page-section"
:class="{
'bg-success-subtle': editMode !== 'selectOrAdd',
'page-section': true,
}"
v-if="ruleSetInitialized && editMode !== 'confirmContext'"
>
<TriggersTable
:triggerNumber="triggerNumber"
:modal="true"
:actions="true"
:displayActions="true"
:enableActions="editMode === 'selectOrAdd'"
:ruleSets="effectiveTriggerFilteredRuleSets"
:ruleSetBeingEdited="currentRuleSet"
:triggerBeingEdited="triggerBeingEdited"
@ -402,15 +411,17 @@ export default {
this.context,
true
);
// override the context as fetched from the DBto ensure it matches the selected context
// FIXME: consider passing the context to trigger table separately
this.currentRuleSet.context = this.context;
if (this.currentRuleSet) {
this.currentRuleSet.context = this.context;
}
},
setTriggerNumber(triggerNumber, library_id) {
this.triggerNumber =
this.editMode === "edit"
? parseInt(triggerNumber)
: (this.triggerCounts[library_id] ?? 0) + 1;
this.triggerBeingEdited =
this.editMode === "edit" ? parseInt(triggerNumber) : null;
},
setFallbackRuleSet() {
this.fallbackRuleSet = {
@ -626,11 +637,11 @@ export default {
.dialog.alert
fieldset:not(.bg-danger):not(.bg-warning):not(.bg-info):not(
.bg-success
.bg-success-subtle
):not(.bg-primary):not(.action),
.dialog.error
fieldset:not(.bg-danger):not(.bg-warning):not(.bg-info):not(
.bg-success
.bg-success-subtle
):not(.bg-primary):not(.action) {
margin: 0;
background-color: rgba(255, 255, 255, 1);
@ -647,4 +658,8 @@ export default {
.modal-body {
min-height: 280px;
}
#circulation-trigger-form-add .page-section {
display: flow-root;
}
</style>

View File

@ -2,271 +2,45 @@
<CirculationTriggersForm
:submitAction="resetCircRules"
formTitle="Confirm circulation rule set reset"
buttonText="Confirm reset"
>
<fieldset class="rows">
<div class="page-section bg-info">
<p>
{{
$__(
"Resetting this rule set for the chosen context will have an impact on all the contexts that used to fall back on this rule set."
)
}}
</p>
<p>
{{
$__(
"To better understand which contexts will be affected, use 'Display: all applied rules.' on the circulations triggers page."
)
}}
</p>
</div>
<legend>{{ $__("Trigger context") }}</legend>
<ol v-if="initialized">
<li>
<p>
<strong>{{ $__("Library") }}:</strong>
</p>
<p id="library_id">
{{ handleContext(library_id, libraries, "library_id") }}
</p>
</li>
<li>
<p>
<strong>{{ $__("Patron category") }}:</strong>
</p>
<p id="patron_category_id">
{{
handleContext(
patron_category_id,
patronCategories,
"patron_category_id"
)
}}
</p>
</li>
<li>
<p>
<strong>{{ $__("Item type") }}:</strong>
</p>
<p id="item_type_id">
{{
handleContext(
item_type_id,
itemTypes,
"item_type_id",
"description"
)
}}
</p>
</li>
</ol>
<div v-else>
<p>{{ $__("Loading circulation context...") }}</p>
</div>
</fieldset>
<fieldset class="rows">
<legend>
{{ $__("Rules (overrides) which will be deleted") }}
</legend>
<table>
<thead>
<th>
{{ $__("Delay") }}
</th>
<th>
{{ $__("Notice") }}
</th>
<th>
{{ $__("Email") }}
</th>
<th>
{{ $__("Print") }}
</th>
<th>
{{ $__("SMS") }}
</th>
<th>
{{ $__("Restricts checkouts") }}
</th>
</thead>
<tbody v-if="initialized">
<tr>
<!-- Delay -->
<td>
<span
v-if="
effectiveRuleSet[
`overdue_${triggerNumber}_delay`
]
"
:class="{
fallback:
effectiveRuleSet[
`overdue_${triggerNumber}_delay`
].isFallback,
}"
>
{{
effectiveRuleSet[
`overdue_${triggerNumber}_delay`
].value
}}
</span>
</td>
<!-- Notice -->
<td>
<span
v-if="
effectiveRuleSet[
`overdue_${triggerNumber}_notice`
]
"
:class="{
fallback:
effectiveRuleSet[
`overdue_${triggerNumber}_notice`
].isFallback,
}"
>
{{
handleNotice(
effectiveRuleSet[
`overdue_${triggerNumber}_notice`
].value
)
}}
</span>
</td>
<!-- Email -->
<td>
<span
v-if="
effectiveRuleSet[
`overdue_${triggerNumber}_mtt`
]?.length
"
:class="{
fallback:
effectiveRuleSet[
`overdue_${triggerNumber}_mtt`
].isFallback,
}"
>
{{
handleTransport(
effectiveRuleSet[
`overdue_${triggerNumber}_mtt`
].value,
"email",
!effectiveRuleSet[
`overdue_${modal ? i + 1 : triggerNumber}_notice`
].value
)
}}
</span>
</td>
<!-- Print -->
<td>
<span
v-if="
effectiveRuleSet[
`overdue_${triggerNumber}_mtt`
]?.length
"
:class="{
fallback:
effectiveRuleSet[
`overdue_${triggerNumber}_mtt`
].isFallback,
}"
>
{{
handleTransport(
effectiveRuleSet[
`overdue_${triggerNumber}_mtt`
].value,
"print",
!effectiveRuleSet[
`overdue_${modal ? i + 1 : triggerNumber}_notice`
].value
)
}}
</span>
</td>
<!-- SMS -->
<td>
<span
v-if="
effectiveRuleSet[
`overdue_${triggerNumber}_mtt`
]?.length
"
:class="{
fallback:
effectiveRuleSet[
`overdue_${triggerNumber}_mtt`
].isFallback,
}"
>
{{
handleTransport(
effectiveRuleSet[
`overdue_${triggerNumber}_mtt`
].value,
!effectiveRuleSet[
`overdue_${modal ? i + 1 : triggerNumber}_notice`
].value
)
}}
</span>
</td>
<!-- Restricts Checkouts -->
<td>
<span
v-if="
effectiveRuleSet[
`overdue_${triggerNumber}_restrict`
]
"
:class="{
fallback:
effectiveRuleSet[
`overdue_${triggerNumber}_restrict`
].isFallback,
}"
>
{{
handleRestrictions(
effectiveRuleSet[
`overdue_${triggerNumber}_restrict`
].value
)
}}
</span>
</td>
</tr>
</tbody>
<tbody v-else>
<tr>
{{
$__("Loading circulation rule set...")
}}
</tr>
</tbody>
</table>
</fieldset>
<TriggersTable
v-if="initialized"
:ruleSets="[effectiveRuleSet]"
:triggerNumber="triggerNumber"
:modal="false"
:displayActions="false"
:enableActions="false"
:title="$__('Rule set selected for reset')"
/>
<fieldset class="rows" v-if="alertMessage">
<div class="alert alert-warning">{{ alertMessage }}</div>
</fieldset>
<fieldset
v-if="initialized && dependentRuleSets.length > 0"
class="rows"
>
<div class="page-section bg-warning overflow-hidden">
<TriggersTable
:ruleSets="projectedDependentEffectiveRuleSets"
:triggerNumber="triggerNumber"
:modal="false"
:displayActions="false"
:enableActions="false"
:title="
$__('Affected rule sets preview: state after reset')
"
/>
</div>
</fieldset>
</CirculationTriggersForm>
</template>
<script>
import ButtonSubmit from "../../ButtonSubmit.vue";
import CirculationTriggersForm from "./CirculationTriggersForm.vue";
import TriggersTable from "./TriggersTable.vue";
import { inject } from "vue";
import { storeToRefs } from "pinia";
@ -281,6 +55,8 @@ export default {
formatTriggerSpecificRuleSetForDisplay,
deleteRuleSet,
handleRestrictions,
computeDeletionImpact,
setAllFormattedRuleSets,
} = circRulesStore;
const {
libraries,
@ -300,6 +76,8 @@ export default {
formatTriggerSpecificRuleSetForDisplay,
deleteRuleSet,
handleRestrictions,
computeDeletionImpact,
setAllFormattedRuleSets,
};
},
data() {
@ -314,28 +92,50 @@ export default {
currentRuleSet: null,
fallbackRuleSet: null,
effectiveRuleSet: null,
dependentRuleSets: [],
projectedDependentEffectiveRuleSets: [],
};
},
beforeRouteEnter(to, from, next) {
next(async vm => {
vm.setContext(to.query);
vm.currentRuleSet = await vm.getSelectedRuleSet(
{
library_id: vm.library_id,
patron_category_id: vm.patron_category_id,
item_type_id: vm.item_type_id,
},
false
);
vm.effectiveRuleSet = vm.formatTriggerSpecificRuleSetForDisplay(
vm.currentRuleSet.context,
vm.triggerNumber,
false
);
vm.initialized = true;
await vm.loadModalData();
});
},
methods: {
async loadModalData() {
await this.setAllFormattedRuleSets();
this.currentRuleSet = await this.getSelectedRuleSet(
{
library_id: this.library_id,
patron_category_id: this.patron_category_id,
item_type_id: this.item_type_id,
},
false
);
this.effectiveRuleSet = this.formatTriggerSpecificRuleSetForDisplay(
this.currentRuleSet.context,
this.triggerNumber
);
const { dependentRuleSets, projectedDependentEffectiveRuleSets } =
await this.computeDeletionImpact(
[this.currentRuleSet],
this.triggerNumber
);
this.dependentRuleSets = dependentRuleSets;
this.projectedDependentEffectiveRuleSets =
projectedDependentEffectiveRuleSets;
if (this.dependentRuleSets.length > 0) {
this.alertMessage = this.$__(
"Some sets inherit one or more fields from this rule set. After the reset, those fields will fall through to a less specific rule set or be left empty. See the preview below."
);
}
this.initialized = true;
},
async resetCircRules(e) {
e.preventDefault();
try {
@ -345,14 +145,7 @@ export default {
);
} catch (e) {
this.alertMessage = e;
// reload the form components that have changed, remain in edit mode
this.$router.push({
path: "/cgi-bin/koha/admin/circulation_triggers/reset",
query: {
...this.currentRuleSet.context,
triggerNumber: this.triggerNumber,
},
});
await this.loadModalData();
return;
}
this.lastEditedTriggerNumber = this.triggerNumber;
@ -368,7 +161,7 @@ export default {
this.triggerNumber = parseInt(query.triggerNumber);
},
},
components: { ButtonSubmit, CirculationTriggersForm },
components: { ButtonSubmit, CirculationTriggersForm, TriggersTable },
};
</script>
@ -377,11 +170,15 @@ export default {
max-height: 90vh;
}
form li {
form ol li {
display: flex;
align-items: center;
}
.page-section ul li {
float: none;
}
.numeric-input-wrapper {
position: relative;
display: inline-block;

View File

@ -1,26 +1,21 @@
<template>
<CirculationTriggersForm
:submitAction="deleteTrigger"
formTitle="Confirm circulation trigger deletion"
:formTitle="
initialized
? `Confirm deletion of Trigger ${triggerNumber} for ${handleContext(library_id, libraries, 'library_id')}`
: 'Confirm circulation trigger deletion'
"
buttonText="Confirm deletion"
>
<fieldset class="rows">
<h2 v-if="initialized">
{{
$__(
`Delete Trigger ${triggerNumber} for: ${handleContext(library_id, libraries, "library_id")}`
)
}}
</h2>
<div v-else>
<p>{{ $__("Loading library...") }}</p>
</div>
</fieldset>
<fieldset class="rows">
<TriggersTable
:triggerNumber="triggerNumber"
:modal="false"
:actions="false"
:displayActions="false"
:enableActions="false"
:ruleSets="formattedEffectiveRuleSets"
:title="$__('Rule sets to be deleted')"
/>
</fieldset>
<fieldset class="rows">
@ -37,6 +32,23 @@
{{ alertMessage }}
</div>
</fieldset>
<fieldset
v-if="initialized && dependentRuleSets.length > 0"
class="rows"
>
<div class="page-section bg-warning overflow-hidden">
<TriggersTable
:ruleSets="projectedDependentEffectiveRuleSets"
:triggerNumber="triggerNumber"
:modal="false"
:displayActions="false"
:enableActions="false"
:title="
$__('Affected rule sets preview: state after deletion')
"
/>
</div>
</fieldset>
</CirculationTriggersForm>
</template>
<script>
@ -53,17 +65,28 @@ export default {
findEffectiveRule,
deleteRuleSet,
hasExplicitRulesForTrigger,
computeDeletionImpact,
setAllFormattedRuleSets,
} = circRulesStore;
const { libraries, allCurrentLibraryRawRuleSets, ruleSuffixes } =
storeToRefs(circRulesStore);
const {
libraries,
itemTypes,
patronCategories,
allCurrentLibraryRawRuleSets,
ruleSuffixes,
} = storeToRefs(circRulesStore);
return {
libraries,
itemTypes,
patronCategories,
handleContext,
findEffectiveRule,
allCurrentLibraryRawRuleSets,
ruleSuffixes,
deleteRuleSet,
hasExplicitRulesForTrigger,
computeDeletionImpact,
setAllFormattedRuleSets,
};
},
data() {
@ -73,32 +96,56 @@ export default {
library_id: "*",
triggerNumber: null,
formattedEffectiveRuleSets: [],
dependentRuleSets: [],
projectedDependentEffectiveRuleSets: [],
};
},
beforeRouteEnter(to, from, next) {
next(async vm => {
vm.setContext(to.query);
vm.setFormattedEffectiveRuleSets();
vm.initialized = true;
await vm.loadModalData();
});
},
methods: {
async loadModalData() {
await this.setAllFormattedRuleSets();
this.setFormattedEffectiveRuleSets();
const deletedRuleSets = this.allCurrentLibraryRawRuleSets.filter(
rs => this.hasExplicitRulesForTrigger(rs, this.triggerNumber)
);
const { dependentRuleSets, projectedDependentEffectiveRuleSets } =
await this.computeDeletionImpact(
deletedRuleSets,
this.triggerNumber
);
this.dependentRuleSets = dependentRuleSets;
this.projectedDependentEffectiveRuleSets =
projectedDependentEffectiveRuleSets;
if (this.dependentRuleSets.length > 0) {
this.alertMessage = this.$__(
"Some rule sets inherit one or more fields from the default trigger being deleted. After deletion, those fields will fall through to a less specific rule set or be left empty. See the preview below."
);
}
this.initialized = true;
},
async deleteTrigger(e) {
e.preventDefault();
const failures = [];
for (const ruleSet of this.allCurrentLibraryRawRuleSets) {
try {
await this.deleteRuleSet(ruleSet, this.triggerNumber);
} catch (e) {
this.alertMessage = e;
this.$router.push({
path: "/cgi-bin/koha/admin/circulation_triggers/reset",
query: {
...ruleSet.context,
triggerNumber: this.triggerNumber,
},
});
failures.push(e);
}
}
if (failures.length > 0) {
this.alertMessage = failures[0];
await this.loadModalData();
return;
}
await this.$router.push({
name: "CirculationTriggersList",
query: { refresh: Date.now() },
@ -128,6 +175,12 @@ export default {
this.triggerNumber
);
});
effectiveRuleSet[`overdue_${this.triggerNumber}_has_rules`] =
this.findEffectiveRule(
ruleSet.context,
"has_rules",
this.triggerNumber
);
this.formattedEffectiveRuleSets.push(effectiveRuleSet);
});
},
@ -145,11 +198,15 @@ export default {
max-height: 90vh;
}
form li {
form ol li {
display: flex;
align-items: center;
}
.page-section ul li {
float: none;
}
.numeric-input-wrapper {
position: relative;
display: inline-block;

View File

@ -296,7 +296,8 @@
<div class="page-section">
<TriggersTable
:modal="false"
:actions="true"
:displayActions="true"
:enableActions="true"
:ruleSets="ruleSets"
:triggerNumber="number"
>
@ -304,14 +305,21 @@
v-if="isLastTrigger(number)"
:to="{
name: 'CirculationTriggersFormConfirmTriggerDelete',
query: {
triggerNumber: number,
},
query: { triggerNumber: number },
}"
class="btn btn-primary"
custom
v-slot="{ navigate }"
>
<i class="fa-solid fa-xmark"></i>
{{ $__("Delete") }}
<span :title="deleteDisabledReason">
<button
:disabled="!!deleteDisabledReason"
@click="navigate"
class="btn btn-primary"
>
<i class="fa-solid fa-xmark"></i>
{{ $__("Delete") }}
</button>
</span>
</router-link>
<div
:class="{
@ -358,6 +366,8 @@ export default {
setAllExhaustiveEffectiveRuleSets,
isLastTrigger,
getLibrariesWithRules,
getLibrariesBlockingTriggerDeletion,
loadAllLibrariesRuleSets,
scrollToElementById,
} = circRulesStore;
const {
@ -395,6 +405,8 @@ export default {
librariesWithRules,
isLastTrigger,
getLibrariesWithRules,
getLibrariesBlockingTriggerDeletion,
loadAllLibrariesRuleSets,
lastEditedTriggerNumber,
storeInitialized,
metaInitialized,
@ -413,18 +425,53 @@ export default {
displayAllApplicableRules: 1,
};
},
computed: {
deleteDisabledReason() {
const lastTrigger = this.triggerCounts[this.currentLibraryId] || 0;
return lastTrigger
? this.computeDeleteDisabledReason(lastTrigger)
: "";
},
},
methods: {
computeDeleteDisabledReason(triggerNumber) {
if (this.currentPatronCategoryId || this.currentItemTypeId) {
return this.$__(
"Delete is only available when no patron category or item type filter is set, as it removes all explicit rules for this trigger across the selected library."
);
}
if (this.currentLibraryId !== "*") {
return "";
}
const blockingIds =
this.getLibrariesBlockingTriggerDeletion(triggerNumber);
if (blockingIds.length === 0) {
return "";
}
const blockingNames = this.libraries
.filter(lib => blockingIds.includes(lib.library_id))
.map(lib => lib.name)
.join(", ");
return this.$__(
"Cannot delete: the following libraries have triggers higher than %s, which would be orphaned: %s"
).format(triggerNumber, blockingNames);
},
async loadRuleSets() {
this.ruleSetInitialized = false;
await this.setAllFormattedRuleSets();
try {
await this.setAllFormattedRuleSets();
} catch (e) {
this.ruleSetInitialized = true;
return;
}
this.updateTriggerCount();
this.setAllEffectiveRuleSets();
this.setAllExhaustiveEffectiveRuleSets();
if (
this.currentLibraryId === "*" &&
this.triggerCounts["*"] === 0
) {
await this.getLibrariesWithRules();
if (this.currentLibraryId === "*") {
await this.loadAllLibrariesRuleSets();
if (this.triggerCounts["*"] === 0) {
await this.getLibrariesWithRules();
}
}
this.storeInitialized = true;
},

View File

@ -10,12 +10,7 @@
: $__("Selected trigger context")
}}
</legend>
<div
:class="{
'bg-info': !isConfirming,
'page-section': true,
}"
>
<div class="page-section">
<ol id="confirm-context-list">
<li>
<label for="library_id" :class="{ required: isConfirming }"
@ -147,6 +142,7 @@ export default {
<style scoped>
#confirm-context-list {
display: flex;
flex-wrap: wrap;
gap: 20px;
height: fit-content;
}

View File

@ -5,8 +5,7 @@
id="trigger-edit-form-general-section"
>
<legend v-if="editMode === 'add'">
{{ $__("Add new trigger") }}
{{ " " + triggerNumber }}
{{ $__("Add new trigger") + " " + triggerNumber }}
</legend>
<legend v-else>
{{ $__("Edit trigger") }} {{ " " + triggerNumber }}

View File

@ -21,6 +21,7 @@
label="name"
:reduce="type => type.code"
:options="filteredLetters"
:clearable="false"
@update:modelValue="setAllowSubmission"
>
<template #search="{ attributes, events }">
@ -50,6 +51,28 @@
/>
</template>
</v-select>
<button
v-if="
ruleSetToSubmit?.[`overdue_${triggerNumber}_notice`] !==
null &&
ruleSetToSubmit?.[`overdue_${triggerNumber}_notice`] !==
undefined &&
fallbackRuleSet?.[`overdue_${triggerNumber}_notice`] !==
null &&
fallbackRuleSet?.[`overdue_${triggerNumber}_notice`] !==
undefined
"
type="button"
class="btn btn-link"
:title="$__('Undo override and reset to fallback')"
@click="
ruleSetToSubmit[`overdue_${triggerNumber}_notice`] =
null;
setAllowSubmission();
"
>
<i class="fa fa-undo"></i>
</button>
</li>
<li
v-if="

View File

@ -29,7 +29,7 @@ import { inject } from "vue";
export default {
setup() {
const circRulesStore = inject("circRulesStore");
circRulesStore.init(default_view, logged_in_library_id);
circRulesStore.init(default_view, logged_in_library_id).catch(() => {});
// format letters for display as "name (notice code)" in drop downs.
letters

View File

@ -4,7 +4,7 @@
:id="modal ? 'trigger-table-form' : 'trigger-table-main'"
>
<legend>
{{ $__("Existing overdues triggers for this context") }}
{{ title ?? $__("Existing overdues triggers for this context") }}
</legend>
<slot></slot>
<table>
@ -39,26 +39,23 @@
<th>
{{ $__("Restricts checkouts") }}
</th>
<th v-if="actions">
<th v-if="displayActions">
{{ $__("Actions") }}
</th>
</thead>
<tbody>
<template
v-for="(ruleSet, i) in ruleSets"
:key="'ruleSet' + i"
:class="{
selected_rule_set:
modal &&
i + 1 === parseInt(activeTriggerBeingEdited),
}"
>
<template v-for="(ruleSet, i) in ruleSets" :key="'ruleSet' + i">
<tr
v-if="
ruleSet[
`overdue_${modal ? i + 1 : triggerNumber}_has_rules`
]
"
:class="{
selected_rule_set:
modal &&
i + 1 === parseInt(activeTriggerBeingEdited),
}"
>
<td v-if="!modal" class="trigger_context">
{{
@ -142,10 +139,7 @@
ruleSet[
`overdue_${modal ? i + 1 : triggerNumber}_mtt`
].value,
"email",
!ruleSet[
`overdue_${modal ? i + 1 : triggerNumber}_notice`
].value
"email"
)
}}
</span>
@ -165,10 +159,7 @@
ruleSet[
`overdue_${modal ? i + 1 : triggerNumber}_mtt`
].value,
"print",
!ruleSet[
`overdue_${modal ? i + 1 : triggerNumber}_notice`
].value
"print"
)
}}
</span>
@ -188,10 +179,7 @@
ruleSet[
`overdue_${modal ? i + 1 : triggerNumber}_mtt`
].value,
"sms",
!ruleSet[
`overdue_${modal ? i + 1 : triggerNumber}_notice`
].value
"sms"
)
}}
</span>
@ -215,77 +203,96 @@
}}
</span>
</td>
<td class="actions" v-if="actions">
<router-link
:to="{
name: 'CirculationTriggersFormEdit',
query: {
library_id: ruleSet.context.library_id,
item_type_id:
ruleSet.context.item_type_id,
patron_category_id:
ruleSet.context.patron_category_id,
triggerNumber: modal
? i + 1
: triggerNumber,
},
}"
<td class="actions" v-if="displayActions">
<button
type="button"
class="btn btn-default btn-xs"
><i class="fa-solid fa-pencil"></i>
{{ $__("Edit") }}</router-link
:disabled="!enableActions"
@click="
$router.push({
name: 'CirculationTriggersFormEdit',
query: {
library_id:
ruleSet.context.library_id,
item_type_id:
ruleSet.context.item_type_id,
patron_category_id:
ruleSet.context
.patron_category_id,
triggerNumber: modal
? i + 1
: triggerNumber,
},
})
"
>
<router-link
<i class="fa-solid fa-pencil"></i>
{{ $__("Edit") }}
</button>
<button
v-if="
ruleSet[
`overdue_${modal ? i + 1 : triggerNumber}_has_rules`
].value &&
!isOnlyRuleSetForTrigger(triggerNumber)
"
:to="{
name: 'CirculationTriggersFormConfirmReset',
query: {
library_id: ruleSet.context.library_id,
item_type_id:
ruleSet.context.item_type_id,
patron_category_id:
ruleSet.context.patron_category_id,
triggerNumber: triggerNumber,
},
}"
type="button"
class="btn btn-default btn-xs"
><i class="fa-solid fa-eraser"></i>
{{ $__("Reset") }}</router-link
:disabled="!enableActions"
@click="
$router.push({
name: 'CirculationTriggersFormConfirmReset',
query: {
library_id:
ruleSet.context.library_id,
item_type_id:
ruleSet.context.item_type_id,
patron_category_id:
ruleSet.context
.patron_category_id,
triggerNumber: triggerNumber,
},
})
"
>
<i class="fa-solid fa-eraser"></i>
{{ $__("Reset") }}
</button>
</td>
</tr>
</template>
<tr v-if="modal">
<td colspan="7"></td>
<td class="actions">
<router-link
:to="{
name: 'CirculationTriggersFormAdd',
query: {
library_id:
activeRuleSetBeingEdited.context
.library_id,
item_type_id:
activeRuleSetBeingEdited.context
.item_type_id,
patron_category_id:
activeRuleSetBeingEdited.context
.patron_category_id,
triggerNumber:
triggerCounts[
activeRuleSetBeingEdited.context
.library_id
] + 1,
},
}"
<td class="actions" v-if="displayActions">
<button
type="button"
class="btn btn-default btn-xs"
><i class="fa-solid fa-pencil"></i>
{{ $__("Add") }}</router-link
:disabled="!enableActions"
@click="
$router.push({
name: 'CirculationTriggersFormAdd',
query: {
library_id:
activeRuleSetBeingEdited.context
.library_id,
item_type_id:
activeRuleSetBeingEdited.context
.item_type_id,
patron_category_id:
activeRuleSetBeingEdited.context
.patron_category_id,
triggerNumber:
triggerCounts[
activeRuleSetBeingEdited.context
.library_id
] + 1,
},
})
"
>
<i class="fa-solid fa-pencil"></i>
{{ $__("Add") }}
</button>
</td>
</tr>
</tbody>
@ -301,10 +308,12 @@ export default {
props: [
"triggerNumber",
"modal",
"actions",
"displayActions",
"enableActions",
"ruleSetBeingEdited",
"triggerBeingEdited",
"ruleSets",
"title",
],
computed: {
activeRuleSetBeingEdited() {
@ -351,10 +360,15 @@ export default {
font-weight: bold;
}
.actions a {
.actions button {
margin-right: 5px;
}
.actions button[disabled] {
opacity: 0.65;
cursor: not-allowed;
}
td.trigger_context {
color: black;
}

View File

@ -65,7 +65,7 @@ router.beforeEach((to, from, next) => {
currentPermission: null,
});
removeMessages(); // This will actually flag the messages as displayed already
window.location.href = "/cgi-bin/koha/mainpage.pl";
window.location.href = "/cgi-bin/koha/admin/admin-home.pl";
return;
}
navigationStore.$patch({
@ -76,6 +76,6 @@ router.beforeEach((to, from, next) => {
next();
})
.catch(() => {
window.location.href = "/cgi-bin/koha/mainpage.pl";
window.location.href = "/cgi-bin/koha/admin/admin-home.pl";
});
});

View File

@ -30,7 +30,7 @@ export const useCircRulesStore = defineStore("circRules", () => {
userPermissions: null,
logged_in_library_id: null,
letters: [],
ruleSuffixes: ["delay", "notice", "mtt", "restrict", "has_rules"],
ruleSuffixes: ["delay", "notice", "mtt", "restrict"],
transportTypes: [
{ code: "email", name: "Email" },
{ code: "sms", name: "SMS" },
@ -39,6 +39,7 @@ export const useCircRulesStore = defineStore("circRules", () => {
// rule sets
allDefaultLibraryRawRuleSets: [], // source of truth for default library
allCurrentLibraryRawRuleSets: [], // source of truth for current library
allLibrariesRawRuleSets: [], // all rule sets across all libraries; loaded lazily on confirm screens for library=* rule sets. Only loaded if necessary (eg. 'Delete' modal access on default library triggers).
allEffectiveRuleSets: [], // main data set for display explicitly set rules for current library
allExhaustiveEffectiveRuleSets: [], // main data set for display all applied rules for current library
currentAndDefaultRawRuleSets: [], // data set to identify effective rules from (combines allDefaultLibraryRawRuleSets and allCurrentLibraryRawRuleSets)
@ -53,6 +54,40 @@ export const useCircRulesStore = defineStore("circRules", () => {
.CAN_user_parameters_manage_circ_rules_from_any_libraries
);
// Exposed as a computed rather than an action to prevent `$id` warnings
// when called from a Vue component during render.
const getLibrariesBlockingTriggerDeletion = computed(
() => triggerNumber => {
const hasRuleAboveCurrentTriggerNumber = ruleSet => {
return Object.keys(ruleSet).some(ruleName => {
const match = ruleName.match(/^overdue_(\d+)_/);
if (!match) {
return false;
}
return (
ruleSet[ruleName] != null &&
parseInt(match[1]) > triggerNumber
);
});
};
const blocking = new Set();
for (const ruleSet of store.allLibrariesRawRuleSets) {
const library_id = ruleSet.context?.library_id;
if (!library_id || library_id === "*") {
continue;
}
if (blocking.has(library_id)) {
continue;
}
if (hasRuleAboveCurrentTriggerNumber(ruleSet)) {
blocking.add(library_id);
}
}
return [...blocking];
}
);
const actions = {
// controllers
async init(defaultLibraryId = "*", loggedInLibraryId = null) {
@ -97,7 +132,13 @@ export const useCircRulesStore = defineStore("circRules", () => {
includeFallbacks
);
});
triggerSpecificRuleSet.context = context;
triggerSpecificRuleSet[`overdue_${triggerNumber}_has_rules`] =
this.findEffectiveRule(
context,
"has_rules",
triggerNumber,
includeFallbacks
);
return triggerSpecificRuleSet;
},
handleContext(value, data, type, displayProperty = "name") {
@ -114,17 +155,30 @@ export const useCircRulesStore = defineStore("circRules", () => {
}
return value === "1" ? $__("Yes") : $__("No");
},
handleTransport(value, type, noLetter) {
if (!value || noLetter) {
handleTransport(value, type) {
if (!value) {
return "";
}
return value.includes(type) ? $__("Yes") : $__("No");
},
hasExplicitRulesForTrigger(ruleSet, triggerNumber) {
return ["delay", "notice", "mtt", "restrict"].some(
return this.ruleSuffixes.some(
suffix => ruleSet[`overdue_${triggerNumber}_${suffix}`] != null
);
},
maxExplicitTriggerNumber(ruleSet) {
const regex = new RegExp(
`^overdue_(\\d+)_(${this.ruleSuffixes.join("|")})$`
);
let max = 0;
Object.keys(ruleSet).forEach(key => {
const match = key.match(regex);
if (match && ruleSet[key] !== null) {
max = Math.max(max, parseInt(match[1]));
}
});
return max;
},
hasConflict(oldRuleSet, newRuleSet, triggerNumber) {
if (
!oldRuleSet ||
@ -132,23 +186,18 @@ export const useCircRulesStore = defineStore("circRules", () => {
) {
return false;
}
return (
oldRuleSet.context.library_id !==
newRuleSet.context.library_id ||
oldRuleSet.context.item_type_id !==
newRuleSet.context.item_type_id ||
oldRuleSet.context.patron_category_id !==
newRuleSet.context.patron_category_id ||
oldRuleSet[`overdue_${triggerNumber}_delay`] !==
newRuleSet[`overdue_${triggerNumber}_delay`] ||
oldRuleSet[`overdue_${triggerNumber}_notice`] !==
newRuleSet[`overdue_${triggerNumber}_notice`] ||
oldRuleSet[`overdue_${triggerNumber}_restrict`] !==
newRuleSet[`overdue_${triggerNumber}_restrict`] ||
!isEqual(
oldRuleSet[`overdue_${triggerNumber}_mtt`],
newRuleSet[`overdue_${triggerNumber}_mtt`]
)
if (!this.isSameContext(oldRuleSet.context, newRuleSet.context)) {
return false;
}
return this.ruleSuffixes.some(
suffix =>
!isEqual(
oldRuleSet[`overdue_${triggerNumber}_${suffix}`] ??
null,
newRuleSet[`overdue_${triggerNumber}_${suffix}`] ?? null
)
);
},
isOnlyRuleSetForTrigger(triggerNumber) {
@ -178,6 +227,18 @@ export const useCircRulesStore = defineStore("circRules", () => {
}
element.scrollIntoView({ behavior: "smooth" });
},
isSameContext(contextA, contextB) {
return (
contextA.library_id === contextB.library_id &&
contextA.patron_category_id === contextB.patron_category_id &&
contextA.item_type_id === contextB.item_type_id
);
},
containsMatchingContext(contextList, context) {
return contextList.some(candidate =>
this.isSameContext(candidate, context)
);
},
// services
formatMttForDisplay(rawMtt) {
return rawMtt?.split(",");
@ -200,6 +261,82 @@ export const useCircRulesStore = defineStore("circRules", () => {
triggerNumber = 1;
}
},
getSpecificityScore(ruleSetContext, referenceContext) {
let score = 0;
if (
ruleSetContext.library_id !== "*" &&
ruleSetContext.library_id === referenceContext.library_id
)
score += 4;
if (
ruleSetContext.patron_category_id !== "*" &&
ruleSetContext.patron_category_id ===
referenceContext.patron_category_id
)
score += 2;
if (
ruleSetContext.item_type_id !== "*" &&
ruleSetContext.item_type_id === referenceContext.item_type_id
)
score += 1;
return score;
},
findFallbackRuleSetForField(context, triggerNumber, suffix, ruleSets) {
const candidates = ruleSets.filter(
ruleSet =>
ruleSet[`overdue_${triggerNumber}_${suffix}`] != null &&
(ruleSet.context.library_id === context.library_id ||
ruleSet.context.library_id === "*") &&
(ruleSet.context.patron_category_id ===
context.patron_category_id ||
ruleSet.context.patron_category_id === "*") &&
(ruleSet.context.item_type_id === context.item_type_id ||
ruleSet.context.item_type_id === "*")
);
if (candidates.length === 0) return null;
return candidates.reduce((best, current) =>
this.getSpecificityScore(current.context, context) >
this.getSpecificityScore(best.context, context)
? current
: best
);
},
buildProjectedRuleSet(
dependentRuleSet,
triggerNumber,
contextRuleSets
) {
const projectedRuleSet = {
context: dependentRuleSet.context,
[`overdue_${triggerNumber}_has_rules`]: {
value: true,
isFallback: false,
},
};
this.ruleSuffixes.forEach(suffix => {
const field = `overdue_${triggerNumber}_${suffix}`;
if (dependentRuleSet[field] != null) {
projectedRuleSet[field] = {
value: dependentRuleSet[field],
isFallback: false,
};
return;
}
const fallback = this.findFallbackRuleSetForField(
dependentRuleSet.context,
triggerNumber,
suffix,
contextRuleSets
);
projectedRuleSet[field] = {
value: fallback?.[field] ?? null,
isFallback: true,
};
});
return projectedRuleSet;
},
findEffectiveRule(
context,
ruleSuffix,
@ -278,35 +415,12 @@ export const useCircRulesStore = defineStore("circRules", () => {
ruleSet.context.item_type_id === "*")
);
// Function to calculate specificity score
const getSpecificityScore = ruleSetContext => {
let score = 0;
if (
ruleSetContext.library_id !== "*" &&
ruleSetContext.library_id === context.library_id
)
score += 4;
if (
ruleSetContext.patron_category_id !== "*" &&
ruleSetContext.patron_category_id ===
context.patron_category_id
)
score += 2;
if (
ruleSetContext.item_type_id !== "*" &&
ruleSetContext.item_type_id === context.item_type_id
)
score += 1;
return score;
};
// Sort the ruleSets based on specificity score, descending
const sortedRules = relevantRules.sort((a, b) => {
return (
getSpecificityScore(b.context) -
getSpecificityScore(a.context)
);
});
const sortedRules = relevantRules.sort(
(a, b) =>
this.getSpecificityScore(b.context, context) -
this.getSpecificityScore(a.context, context)
);
// If no ruleSet found, return null
if (sortedRules.length === 0) {
return { value: null, isFallback: true };
@ -344,6 +458,12 @@ export const useCircRulesStore = defineStore("circRules", () => {
i
);
});
effectiveRuleSet[`overdue_${i}_has_rules`] =
this.findEffectiveRule(
effectiveRuleSet.context,
"has_rules",
i
);
}
this.allExhaustiveEffectiveRuleSets.push(effectiveRuleSet);
});
@ -373,6 +493,8 @@ export const useCircRulesStore = defineStore("circRules", () => {
i
);
});
effectiveRuleSet[`overdue_${i}_has_rules`] =
this.findEffectiveRule(ruleSet.context, "has_rules", i);
}
this.allEffectiveRuleSets.push(effectiveRuleSet);
});
@ -403,19 +525,15 @@ export const useCircRulesStore = defineStore("circRules", () => {
// Set the triggerCount for the default library rule set
if (this.currentLibraryId === "*") {
const triggerNumRegex =
/^overdue_(\d+)_(delay|notice|mtt|restrict)$/;
const triggerNums = new Set();
this.allDefaultLibraryRawRuleSets.forEach(ruleSet => {
Object.keys(ruleSet).forEach(key => {
const match = key.match(triggerNumRegex);
if (match && ruleSet[key] !== null) {
triggerNums.add(parseInt(match[1]));
}
});
});
this.triggerCounts["*"] =
triggerNums.size > 0 ? Math.max(...triggerNums) : 0;
this.allDefaultLibraryRawRuleSets.reduce(
(max, ruleSet) =>
Math.max(
max,
this.maxExplicitTriggerNumber(ruleSet)
),
0
);
return;
}
@ -438,8 +556,7 @@ export const useCircRulesStore = defineStore("circRules", () => {
this.triggerCounts[this.currentLibraryId] = i - 1;
},
async setAllRawRuleSets() {
const client = APIClient.circRule;
await this.getAllRawRuleSets();
await this.getCurrentAndDefaultRawRuleSets();
this.currentAndDefaultRawRuleSets = [
...this.allCurrentLibraryRawRuleSets,
@ -475,6 +592,90 @@ export const useCircRulesStore = defineStore("circRules", () => {
}
return formattedSelectedRuleSet;
},
isImpactedByDeletion(
candidate,
triggerNumber,
deletedContexts,
contextRuleSets
) {
if (
this.containsMatchingContext(deletedContexts, candidate.context)
) {
return false;
}
if (!this.hasExplicitRulesForTrigger(candidate, triggerNumber)) {
return false;
}
return this.ruleSuffixes.some(suffix => {
if (candidate[`overdue_${triggerNumber}_${suffix}`] != null) {
return false;
}
const fallback = this.findFallbackRuleSetForField(
candidate.context,
triggerNumber,
suffix,
contextRuleSets
);
return (
fallback &&
this.containsMatchingContext(
deletedContexts,
fallback.context
)
);
});
},
// For a set of rule sets being deleted for a given trigger, return the
// rule sets that depend on any of them (deduplicated, excluding the
// ones being deleted themselves) and a projection of their state after
// the deletion has happened. Reset and bulk-delete modals both consume
// this — reset passes [currentRuleSet], delete passes the full set.
async computeDeletionImpact(deletedRuleSets, triggerNumber) {
let searchRuleSets, contextRuleSets;
if (this.currentLibraryId === "*") {
await this.loadAllLibrariesRuleSets();
searchRuleSets = this.allLibrariesRawRuleSets;
contextRuleSets = this.allLibrariesRawRuleSets;
} else {
searchRuleSets = this.allCurrentLibraryRawRuleSets;
contextRuleSets = this.currentAndDefaultRawRuleSets;
}
const deletedContexts = deletedRuleSets.map(
ruleSet => ruleSet.context
);
const projectedRemainingRawRuleSets = contextRuleSets.filter(
ruleSet =>
!this.containsMatchingContext(
deletedContexts,
ruleSet.context
)
);
const dependentRuleSets = searchRuleSets.filter(candidate =>
this.isImpactedByDeletion(
candidate,
triggerNumber,
deletedContexts,
contextRuleSets
)
);
const projectedDependentEffectiveRuleSets = dependentRuleSets.map(
dependentRuleSet =>
this.buildProjectedRuleSet(
dependentRuleSet,
triggerNumber,
projectedRemainingRawRuleSets
)
);
return {
dependentRuleSets,
projectedDependentEffectiveRuleSets,
};
},
// repositories
async deleteRuleSet(ruleSet, triggerNumber) {
if (!this.hasExplicitRulesForTrigger(ruleSet, triggerNumber)) {
@ -490,21 +691,14 @@ export const useCircRulesStore = defineStore("circRules", () => {
throw "The rule set for the selected trigger context could not be reset as it was updated elsewhere. Please see the updated trigger above.";
}
const rulesForDeletion = {
context: ruleSet.context,
[`overdue_${triggerNumber}_delay`]: null,
[`overdue_${triggerNumber}_notice`]: null,
[`overdue_${triggerNumber}_restrict`]: null,
[`overdue_${triggerNumber}_mtt`]: null,
};
const rulesForDeletion = { context: ruleSet.context };
this.ruleSuffixes.forEach(suffix => {
rulesForDeletion[`overdue_${triggerNumber}_${suffix}`] = null;
});
await this.updateCircRuleSets(rulesForDeletion, triggerNumber);
},
async getLibrariesWithRules() {
const client = APIClient.circRule;
const allRules = await client.circ_rules.getAll(
{},
{ effective: false }
);
const allRules = await this.fetchRawRuleSets();
const libraryIds = new Set(
allRules
.map(r => r.context?.library_id)
@ -514,21 +708,27 @@ export const useCircRulesStore = defineStore("circRules", () => {
lib => lib.library_id !== "*" && libraryIds.has(lib.library_id)
);
},
async getAllRawRuleSets() {
const client = APIClient.circRule;
this.allDefaultLibraryRawRuleSets = await client.circ_rules.getAll(
{},
{ library_id: "*", effective: false }
);
async loadAllLibrariesRuleSets() {
this.allLibrariesRawRuleSets = await this.fetchRawRuleSets();
this.formatRuleSetMttFields(this.allLibrariesRawRuleSets);
},
async getCurrentAndDefaultRawRuleSets() {
this.allDefaultLibraryRawRuleSets = await this.fetchRawRuleSets({
library_id: "*",
});
if (this.currentLibraryId === "*") {
this.allCurrentLibraryRawRuleSets =
this.allDefaultLibraryRawRuleSets;
return;
}
this.allCurrentLibraryRawRuleSets = await client.circ_rules.getAll(
this.allCurrentLibraryRawRuleSets = await this.fetchRawRuleSets({
library_id: this.currentLibraryId,
});
},
async fetchRawRuleSets(params = {}) {
return APIClient.circRule.circ_rules.getAll(
{},
{ library_id: this.currentLibraryId, effective: false }
{ effective: false, ...params }
);
},
async getConfigurationOptions() {
@ -571,16 +771,12 @@ export const useCircRulesStore = defineStore("circRules", () => {
if (context.library_id === null) {
context.library_id = "*";
}
const client = APIClient.circRule;
const result = await client.circ_rules.getAll(
{},
{
library_id: context.library_id,
patron_category_id: context.patron_category_id,
item_type_id: context.item_type_id,
effective,
}
);
const result = await this.fetchRawRuleSets({
library_id: context.library_id,
patron_category_id: context.patron_category_id,
item_type_id: context.item_type_id,
effective,
});
return result[0] ?? null;
},
async updateCircRuleSets(existingRuleSet, triggerNumber) {
@ -603,5 +799,6 @@ export const useCircRulesStore = defineStore("circRules", () => {
...toRefs(store),
...actions,
canManageAnyLibrary,
getLibrariesBlockingTriggerDeletion,
};
});