Bug 26355: Add a Vue island for the self-renewal modal

Signed-off-by: Christopher Brannon <cbrannon@cdalibrary.org>
Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com>
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
This commit is contained in:
Matt Blenkinsop 2026-02-19 13:30:24 +00:00 committed by Lucas Gass
parent 4979f0aead
commit 133946e50c
No known key found for this signature in database
GPG Key ID: 60B447398B0D36CA
5 changed files with 346 additions and 81 deletions

1
.gitignore vendored
View File

@ -13,6 +13,7 @@ koha-tmpl/opac-tmpl/bootstrap/css/print.css
koha-tmpl/opac-tmpl/bootstrap/css/print-rtl.css
koha-tmpl/opac-tmpl/bootstrap/css/sco.css
koha-tmpl/opac-tmpl/bootstrap/css/sco-rtl.css
koha-tmpl/opac-tmpl/bootstrap/js/vue/dist/
koha-tmpl/intranet-tmpl/prog/css/maps/
koha-tmpl/intranet-tmpl/prog/css/bookings.css

View File

@ -0,0 +1,167 @@
<template>
<div
id="patronSelfRenewal"
class="modal modal-full"
tabindex="-1"
role="dialog"
aria-labelledby="patronSelfRenewal"
aria-hidden="true"
>
<div class="modal-dialog modal-xl">
<div v-if="initialized" class="modal-content">
<div class="modal-header">
<h1 class="modal-title">
{{ $__("Patron self-renewal") }}
</h1>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<VerificationChecks
v-if="activeStep === 'verification'"
:renewalSettings="renewalSettings"
@verification-successful="onVerificationSuccess"
/>
<VerificationChecks
v-if="activeStep === 'confirmation'"
:informationMessage="
$__('Are you sure you want to renew your account?')
"
:renewalSettings="renewalSettings"
@verification-successful="submitRenewal"
/>
<div v-if="activeStep === 'detailsCheck'">
<legend>
{{ $__("Confirm your account details") }}
</legend>
<div class="detail_confirmation">
<span>{{
$__(
"You need to confirm your personal details to proceed with your account renewal."
)
}}</span>
<button
class="btn btn-default"
@click="proceedToDetailsVerification()"
>
{{ $__("Continue") }}
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-default cancel"
data-bs-dismiss="modal"
>
{{ $__("Close") }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { onBeforeMount, ref } from "vue";
import { APIClient } from "../../../fetch/api-client.js";
import VerificationChecks from "./VerificationChecks.vue";
import { $__ } from "@koha-vue/i18n";
export default {
components: { VerificationChecks },
props: {
patron: String,
},
setup(props) {
const initialized = ref(false);
const activeStep = ref(null);
const renewalSettings = ref({
defaultErrorMessage: $__(
"You are not able to self-renew with the provided information. Please visit your library to proceed with your renewal."
),
});
onBeforeMount(() => {
const client = APIClient.patron;
client.self_renewal.start(props.patron).then(
response => {
renewalSettings.value = {
...renewalSettings.value,
...response.self_renewal_settings,
};
activeStep.value = response.self_renewal_settings
.self_renewal_information_message
? "verification"
: response.self_renewal_settings.opac_patron_details ===
"1"
? "detailsCheck"
: "confirmation";
initialized.value = true;
},
error => {}
);
});
const proceedToDetailsVerification = () => {
window.location.href =
"/cgi-bin/koha/opac-memberentry.pl?self_renewal=1";
};
const onVerificationSuccess = () => {
if (renewalSettings.value.opac_patron_details === "1") {
activeStep.value = "detailsCheck";
} else {
activeStep.value = "confirmation";
}
};
const submitRenewal = () => {
const client = APIClient.patron;
client.self_renewal.submit(props.patron, {}).then(
response => {
let newLocation =
"/cgi-bin/koha/opac-user.pl?self_renewal_success=" +
response.expiry_date;
if (response.confirmation_sent) {
newLocation += "&confirmation_sent=1";
}
document.location = newLocation;
},
error => {
document.location =
"/cgi-bin/koha/opac-user.pl?self_renewal_success=0";
}
);
};
return {
initialized,
activeStep,
renewalSettings,
onVerificationSuccess,
proceedToDetailsVerification,
submitRenewal,
};
},
};
</script>
<style scoped>
.detail_confirmation {
display: flex;
flex-direction: column;
gap: 1em;
}
.detail_confirmation button {
display: flex;
flex-direction: column;
gap: 1em;
width: 7em;
}
</style>

View File

@ -0,0 +1,73 @@
<template>
<fieldset class="rows" v-if="!verificationFailure">
<span class="verification_question">{{ verificationCheck }}</span>
<div class="verification_actions">
<button class="btn btn-success" @click="verificationPassed()">
{{ $__("Yes") }}
</button>
<button class="btn btn-default" @click="verificationFailed()">
{{ $__("No") }}
</button>
</div>
</fieldset>
<span class="error" v-else>{{ errorMessage }}</span>
</template>
<script>
import { computed, ref } from "vue";
import { $__ } from "@koha-vue/i18n";
export default {
props: {
renewalSettings: Object,
informationMessage: String,
},
emits: ["verification-successful"],
setup(props, { emit }) {
const verificationFailure = ref(false);
const verificationCheck = ref(
props.informationMessage ||
props.renewalSettings.self_renewal_information_message
);
const verificationPassed = () => {
emit("verification-successful");
};
const verificationFailed = () => {
verificationFailure.value = true;
};
const errorMessage = computed(() => {
const { self_renewal_failure_message, defaultErrorMessage } =
props.renewalSettings;
return self_renewal_failure_message || defaultErrorMessage;
});
return {
verificationCheck,
verificationPassed,
verificationFailed,
verificationFailure,
errorMessage,
};
},
};
</script>
<style scoped>
.rows {
display: flex;
flex-direction: column;
}
.verification_actions {
display: flex;
gap: 1em;
}
.verification_actions button {
width: 5em;
}
.verification_question {
margin-bottom: 2em;
font-size: 100%;
}
</style>

View File

@ -87,6 +87,19 @@ export const componentRegistry: Map<string, WebComponentDynamicImport> =
},
},
],
[
"patron-self-renewal",
{
importFn: async () => {
const module = await import(
/* webpackChunkName: "patron-self-renewal" */
"../components/Islands/PatronSelfRenewal/PatronSelfRenewal.vue"
);
return module.default;
},
config: {},
},
],
]);
/**

View File

@ -3,6 +3,96 @@ const { VueLoaderPlugin } = require("vue-loader");
const path = require("path");
const rspack = require("@rspack/core");
const islandsExport = application => {
const vueDir =
application === "intranet"
? "intranet-tmpl/prog"
: "opac-tmpl/bootstrap";
return {
resolve: {
alias: {
"@fetch": path.resolve(
__dirname,
"koha-tmpl/intranet-tmpl/prog/js/fetch"
),
"@koha-vue": path.resolve(
__dirname,
"koha-tmpl/intranet-tmpl/prog/js/vue"
),
},
},
experiments: {
outputModule: true,
},
entry: {
islands: [
"./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js",
"./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts",
],
},
output: {
filename: "[name].esm.js",
path: path.resolve(__dirname, `koha-tmpl/${vueDir}/js/vue/dist/`),
chunkFilename: "[name].[contenthash].esm.js",
globalObject: "window",
library: {
type: "module",
},
},
module: {
rules: [
{
test: /\.vue$/,
loader: "vue-loader",
options: {
experimentalInlineMatchResource: true,
},
exclude: [path.resolve(__dirname, "t/cypress/")],
},
{
test: /\.ts$/,
loader: "builtin:swc-loader",
options: {
jsc: {
parser: {
syntax: "typescript",
},
},
appendTsSuffixTo: [/\.vue$/],
},
exclude: [
/node_modules/,
path.resolve(__dirname, "t/cypress/"),
],
type: "javascript/auto",
},
{
test: /\.css$/i,
type: "javascript/auto",
use: ["style-loader", "css-loader"],
},
],
},
plugins: [
new VueLoaderPlugin(),
new rspack.DefinePlugin({
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
}),
],
externals: {
jquery: "jQuery",
"datatables.net": "DataTable",
"datatables.net-buttons": "DataTable",
"datatables.net-buttons/js/buttons.html5": "DataTable",
"datatables.net-buttons/js/buttons.print": "DataTable",
"datatables.net-buttons/js/buttons.colVis": "DataTable",
},
};
};
module.exports = [
{
resolve: {
@ -104,87 +194,8 @@ module.exports = [
"datatables.net-buttons/js/buttons.colVis": "DataTable",
},
},
{
resolve: {
alias: {
"@fetch": path.resolve(
__dirname,
"koha-tmpl/intranet-tmpl/prog/js/fetch"
),
},
},
experiments: {
outputModule: true,
},
entry: {
islands: [
"./koha-tmpl/intranet-tmpl/prog/js/vue/csp-nonce.js",
"./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts",
],
},
output: {
filename: "[name].esm.js",
path: path.resolve(
__dirname,
"koha-tmpl/intranet-tmpl/prog/js/vue/dist/"
),
chunkFilename: "[name].[contenthash].esm.js",
globalObject: "window",
library: {
type: "module",
},
},
module: {
rules: [
{
test: /\.vue$/,
loader: "vue-loader",
options: {
experimentalInlineMatchResource: true,
},
exclude: [path.resolve(__dirname, "t/cypress/")],
},
{
test: /\.ts$/,
loader: "builtin:swc-loader",
options: {
jsc: {
parser: {
syntax: "typescript",
},
},
appendTsSuffixTo: [/\.vue$/],
},
exclude: [
/node_modules/,
path.resolve(__dirname, "t/cypress/"),
],
type: "javascript/auto",
},
{
test: /\.css$/i,
type: "javascript/auto",
use: ["style-loader", "css-loader"],
},
],
},
plugins: [
new VueLoaderPlugin(),
new rspack.DefinePlugin({
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
}),
],
externals: {
jquery: "jQuery",
"datatables.net": "DataTable",
"datatables.net-buttons": "DataTable",
"datatables.net-buttons/js/buttons.html5": "DataTable",
"datatables.net-buttons/js/buttons.print": "DataTable",
"datatables.net-buttons/js/buttons.colVis": "DataTable",
},
},
islandsExport("intranet"),
islandsExport("opac"),
{
entry: {
"api-client.cjs": [