From 8b9446d803bcf922620bc24c95625b949746e69e Mon Sep 17 00:00:00 2001
From: Tomas Cohen Arazi <tomascohen@theke.io>
Date: Wed, 24 Jan 2024 16:18:13 -0300
Subject: [PATCH] Bug 35919: Add record sources admin page

This patch introduces a Vue.js based record sources managing page. To
test it:

1. Apply this patch
2. Build the Vue.js stuff:
   $ ktd --shell
  k$ yarn js:build
  k$ restart_all
3. On the staff interface, go to Administration > Record sources
4. Play with the interface and the offered actions
=> SUCCESS: Things go well
5. Sign off :-D

Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
Signed-off-by: Matt Blenkinsop <matt.blenkinsop@ptfs-europe.com>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
---
 admin/record_sources.pl                       |  37 +++++
 debian/templates/apache-shared-intranet.conf  |   2 +
 .../prog/en/modules/admin/admin-home.tt       |   4 +
 .../prog/en/modules/admin/record_sources.tt   |  34 +++++
 .../Admin/RecordSources/FormAdd.vue           | 121 +++++++++++++++
 .../components/Admin/RecordSources/List.vue   | 140 ++++++++++++++++++
 .../components/Admin/RecordSources/Main.vue   |  34 +++++
 .../prog/js/vue/fetch/api-client.js           |   2 +
 .../js/vue/fetch/record-sources-api-client.js |  51 +++++++
 .../js/vue/modules/admin/record_sources.ts    |  54 +++++++
 .../js/vue/routes/admin/record_sources.js     |  38 +++++
 webpack.config.js                             |   1 +
 12 files changed, 518 insertions(+)
 create mode 100755 admin/record_sources.pl
 create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/admin/record_sources.tt
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/FormAdd.vue
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/List.vue
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/Main.vue
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/fetch/record-sources-api-client.js
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/record_sources.ts
 create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/routes/admin/record_sources.js

diff --git a/admin/record_sources.pl b/admin/record_sources.pl
new file mode 100755
index 00000000000..9524f32dd65
--- /dev/null
+++ b/admin/record_sources.pl
@@ -0,0 +1,37 @@
+#!/usr/bin/perl
+
+# Copyright 2023 Theke Solutions
+#
+# This file is part of Koha.
+#
+# 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 CGI        qw ( -utf8 );
+use C4::Auth   qw( get_template_and_user );
+use C4::Output qw( output_html_with_http_headers );
+
+my $query = CGI->new;
+
+my ( $template, $loggedinuser, $cookie ) = get_template_and_user(
+    {
+        template_name => "admin/record_sources.tt",
+        query         => $query,
+        type          => "intranet",
+        flagsrequired => { parameters => 'manage_record_sources' },
+    }
+);
+
+output_html_with_http_headers $query, $cookie, $template->output;
diff --git a/debian/templates/apache-shared-intranet.conf b/debian/templates/apache-shared-intranet.conf
index b4fe0ea2263..835b0812b6b 100644
--- a/debian/templates/apache-shared-intranet.conf
+++ b/debian/templates/apache-shared-intranet.conf
@@ -13,6 +13,7 @@ ScriptAlias /search "/usr/share/koha/intranet/cgi-bin/catalogue/search.pl"
 
 # Protect dev package install
 RewriteEngine on
+
 RewriteRule ^/cgi-bin/koha/(C4|debian|etc|installer/data|install_misc|Koha|misc|selenium|t|test|tmp|xt)/|\.PL$ /notfound [PT]
 
 RewriteRule ^/bib/([^\/]*)/?$ /cgi-bin/koha/catalogue/detail.pl?biblionumber=$1 [PT]
@@ -23,6 +24,7 @@ RewriteRule ^(.*)_[0-9]{2}\.[0-9]{7}\.(js|css)$ $1.$2 [L]
 RewriteRule ^/cgi-bin/koha/erm/.*$ /cgi-bin/koha/erm/erm.pl [PT]
 RewriteCond %{REQUEST_URI} !^/cgi-bin/koha/preservation/.*.pl$
 RewriteRule ^/cgi-bin/koha/preservation/.*$ /cgi-bin/koha/preservation/home.pl [PT]
+RewriteRule ^/cgi-bin/koha/admin/record_sources(.*)?$ /cgi-bin/koha/admin/record_sources.pl$1 [PT]
 
 Alias "/api" "/usr/share/koha/api"
 <Directory "/usr/share/koha/api">
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
index 1320214b653..8f920678ba6 100644
--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/admin-home.tt
@@ -210,6 +210,10 @@
                         <dt><a href="/cgi-bin/koha/admin/searchengine/elasticsearch/mappings.pl">Search engine configuration (Elasticsearch)</a></dt>
                         <dd>Manage indexes, facets, and their mappings to MARC fields and subfields</dd>
                     [% END %]
+                    [% IF ( CAN_user_parameters_manage_record_sources ) %]
+                        <dt><a href="/cgi-bin/koha/admin/record_sources">Record sources</a></dt>
+                        <dd>Define record sources to import from</dd>
+                    [% END %]
                 </dl>
             [% END %]
 
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/record_sources.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/record_sources.tt
new file mode 100644
index 00000000000..533a171ec2c
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/record_sources.tt
@@ -0,0 +1,34 @@
+[% USE raw %]
+[% USE To %]
+[% USE Asset %]
+[% USE KohaDates %]
+[% USE TablesSettings %]
+[% USE AuthorisedValues %]
+[% SET footerjs = 1 %]
+[% PROCESS 'i18n.inc' %]
+[% INCLUDE 'doc-head-open.inc' %]
+<title>
+    Record sources &rsaquo; Koha
+</title>
+[% INCLUDE 'doc-head-close.inc' %]
+</head>
+
+<body id="record_sources" class="record_sources">
+[% WRAPPER 'header.inc' %]
+    [% INCLUDE 'prefs-admin-search.inc' %]
+[% END %]
+
+<div id="record-source"> <!-- this is closed in intranet-bottom.inc -->
+
+[% MACRO jsinclude BLOCK %]
+    [% INCLUDE 'calendar.inc' %] <!-- FIXME: this shouldn't be needed -->
+    [% INCLUDE 'datatables.inc' %]
+    [% INCLUDE 'columns_settings.inc' %]
+    [% INCLUDE 'js-patron-format.inc' %]
+    [% INCLUDE 'js-date-format.inc' %]
+
+    [% Asset.js("js/vue/dist/admin/record_sources.js") | $raw %]
+
+[% END %]
+
+[% INCLUDE 'intranet-bottom.inc' %]
diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/FormAdd.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/FormAdd.vue
new file mode 100644
index 00000000000..2b7994224cb
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/FormAdd.vue
@@ -0,0 +1,121 @@
+<template>
+    <div v-if="!initialized">{{ $__("Loading") }}</div>
+    <div v-else id="record_source_edit">
+        <h1 v-if="record_source.record_source_id">
+            {{ $__("Edit '%s'").format(record_source.name) }}
+        </h1>
+        <h1 v-else>{{ $__("Add record source") }}</h1>
+        <form @submit="onSubmit($event)">
+            <fieldset class="rows">
+                <ol>
+                    <li>
+                        <label class="required" for="name">
+                            {{ $__("Name") }}:
+                        </label>
+                        <input
+                            id="name"
+                            v-model="record_source.name"
+                            required
+                        />
+                        <span class="required">{{ $__("Required") }}</span>
+                    </li>
+                    <li>
+                        <label for="can_be_edited">
+                            {{ $__("Can be edited") }}:
+                        </label>
+                        <input
+                            id="can_be_edited"
+                            type="checkbox"
+                            v-model="record_source.can_be_edited"
+                        />
+                    </li>
+                </ol>
+            </fieldset>
+            <fieldset class="action">
+                <input type="submit" :value="$__('Submit')" />
+                <router-link
+                    :to="{ name: 'RecordSourcesList' }"
+                    role="button"
+                    class="cancel"
+                    >{{ $__("Cancel") }}</router-link
+                >
+            </fieldset>
+        </form>
+    </div>
+</template>
+
+<script>
+import { inject } from "vue"
+import { setMessage, setError, setWarning } from "../../../messages"
+import { APIClient } from "../../../fetch/api-client.js"
+
+export default {
+    setup() {
+        const { setMessage } = inject("mainStore")
+        return {
+            setMessage,
+        }
+    },
+    data() {
+        return {
+            record_source: {
+                record_source_id: null,
+                name: "",
+                can_be_edited: false,
+            },
+            initialized: false,
+        }
+    },
+    beforeRouteEnter(to, from, next) {
+        next(vm => {
+            if (to.params.record_source_id) {
+                vm.getRecordSource(to.params.record_source_id)
+            } else {
+                vm.initialized = true
+            }
+        })
+    },
+    methods: {
+        async getRecordSource(record_source_id) {
+            const client = APIClient.record_sources
+            client.record_sources.get(record_source_id).then(
+                record_source => {
+                    this.record_source = record_source
+                    this.record_source_id = record_source_id
+                    this.initialized = true
+                },
+                error => {}
+            )
+        },
+        onSubmit(e) {
+            e.preventDefault()
+            const client = APIClient.record_sources
+            let response
+            // RO attribute
+            delete this.record_source.record_source_id
+            if (this.record_source_id) {
+                // update
+                response = client.record_sources
+                    .update(this.record_source, this.record_source_id)
+                    .then(
+                        success => {
+                            setMessage(this.$__("Record source updated!"))
+                            this.$router.push({ name: "RecordSourcesList" })
+                        },
+                        error => {}
+                    )
+            } else {
+                response = client.record_sources
+                    .create(this.record_source)
+                    .then(
+                        success => {
+                            setMessage(this.$__("Record source created!"))
+                            this.$router.push({ name: "RecordSourcesList" })
+                        },
+                        error => {}
+                    )
+            }
+        },
+    },
+}
+</script>
diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/List.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/List.vue
new file mode 100644
index 00000000000..f8776f9b35d
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/List.vue
@@ -0,0 +1,140 @@
+<template>
+    <div v-if="!initialized">{{ $__("Loading") }}</div>
+    <div v-else id="record_sources_list">
+        <Toolbar>
+            <ToolbarButton
+                :to="{ name: 'RecordSourcesFormAdd' }"
+                icon="plus"
+                :title="$__('New record source')"
+            />
+        </Toolbar>
+        <h1>{{ title }}</h1>
+        <div v-if="record_sources_count > 0" class="page-section">
+            <KohaTable
+                ref="table"
+                v-bind="tableOptions"
+                @edit="doEdit"
+                @delete="doDelete"
+            ></KohaTable>
+        </div>
+        <div v-else class="dialog message">
+            {{ $__("There are no record sources defined") }}
+        </div>
+    </div>
+</template>
+
+<script>
+import Toolbar from "../../Toolbar.vue"
+import ToolbarButton from "../../ToolbarButton.vue"
+import { inject } from "vue"
+import { APIClient } from "../../../fetch/api-client.js"
+import KohaTable from "../../KohaTable.vue"
+
+export default {
+    data() {
+        return {
+            title: this.$__("Record sources"),
+            tableOptions: {
+                columns: [
+                    {
+                        title: this.$__("ID"),
+                        data: "record_source_id",
+                        searchable: true,
+                    },
+                    {
+                        title: this.$__("Name"),
+                        data: "name",
+                        searchable: true,
+                    },
+                    {
+                        title: __("Can be edited"),
+                        data: "can_be_edited",
+                        searchable: true,
+                        orderable: true,
+                        render: function (data, type, row, meta) {
+                            return escape_str(
+                                row.can_be_edited ? __("Yes") : __("No")
+                            )
+                        },
+                    },
+                ],
+                actions: {
+                    "-1": ["edit", "delete"],
+                },
+                url: "/api/v1/record_sources",
+            },
+            initialized: false,
+            record_sources_count: 0,
+        }
+    },
+    setup() {
+        const { setWarning, setMessage, setError, setConfirmationDialog } =
+            inject("mainStore")
+        return {
+            setWarning,
+            setMessage,
+            setError,
+            setConfirmationDialog,
+        }
+    },
+    beforeRouteEnter(to, from, next) {
+        next(vm => {
+            vm.getRecordSourcesCount().then(() => (vm.initialized = true))
+        })
+    },
+    methods: {
+        async getRecordSourcesCount() {
+            const client = APIClient.record_sources
+            await client.record_sources.count().then(
+                count => {
+                    this.record_sources_count = count
+                },
+                error => {}
+            )
+        },
+        newRecordSource() {
+            this.$router.push({ name: "RecordSourcesFormAdd" })
+        },
+        doEdit: function ({ record_source_id }, dt, event) {
+            this.$router.push({
+                name: "RecordSourcesFormAddEdit",
+                params: { record_source_id },
+            })
+        },
+        doDelete: function (record_source, dt, event) {
+            this.setConfirmationDialog(
+                {
+                    title: this.$__(
+                        "Are you sure you want to remove this record source?"
+                    ),
+                    message: record_source.name,
+                    accept_label: this.$__("Yes, delete"),
+                    cancel_label: this.$__("No, do not delete"),
+                },
+                () => {
+                    const client = APIClient.record_sources
+                    client.record_sources
+                        .delete(record_source.record_source_id)
+                        .then(
+                            success => {
+                                this.setMessage(
+                                    this.$__("Record source %s deleted").format(
+                                        record_source.name
+                                    ),
+                                    true
+                                )
+                                dt.draw()
+                            },
+                            error => {}
+                        )
+                }
+            )
+        },
+    },
+    components: {
+        KohaTable,
+        Toolbar,
+        ToolbarButton,
+    },
+}
+</script>
diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/Main.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/Main.vue
new file mode 100644
index 00000000000..057c78a4ae1
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Admin/RecordSources/Main.vue
@@ -0,0 +1,34 @@
+<template>
+    <div>
+        <div id="sub-header">
+            <Breadcrumbs></Breadcrumbs>
+            <Help />
+        </div>
+        <div class="main container-fluid">
+            <div class="row">
+                <div class="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
+                    <main>
+                        <Dialog></Dialog>
+                        <router-view />
+                    </main>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+import Breadcrumbs from "../../Breadcrumbs.vue"
+import Help from "../../Help.vue"
+import Dialog from "../../Dialog.vue"
+
+export default {
+    components: {
+        Breadcrumbs,
+        Dialog,
+        Help,
+    },
+}
+</script>
+
+<style></style>
diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js
index 294693c238e..4bfd3c8dd0c 100644
--- a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js
+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/api-client.js
@@ -3,6 +3,7 @@ import PatronAPIClient from "./patron-api-client";
 import AcquisitionAPIClient from "./acquisition-api-client";
 import AVAPIClient from "./authorised-values-api-client";
 import ItemAPIClient from "./item-api-client";
+import RecordSourcesAPIClient from "./record-sources-api-client";
 import SysprefAPIClient from "./system-preferences-api-client";
 import PreservationAPIClient from "./preservation-api-client";
 
@@ -14,4 +15,5 @@ export const APIClient = {
     item: new ItemAPIClient(),
     sysprefs: new SysprefAPIClient(),
     preservation: new PreservationAPIClient(),
+    record_sources: new RecordSourcesAPIClient(),
 };
diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/record-sources-api-client.js b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/record-sources-api-client.js
new file mode 100644
index 00000000000..b12882d1dca
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/fetch/record-sources-api-client.js
@@ -0,0 +1,51 @@
+import HttpClient from "./http-client";
+
+export class RecordSourcesAPIClient extends HttpClient {
+    constructor() {
+        super({
+            baseURL: "/api/v1/record_sources",
+        });
+    }
+
+    get record_sources() {
+        return {
+            create: record_source =>
+                this.post({
+                    endpoint: "",
+                    body: record_source,
+                }),
+            delete: id =>
+                this.delete({
+                    endpoint: "/" + id,
+                }),
+            update: (record_source, id) =>
+                this.put({
+                    endpoint: "/" + id,
+                    body: record_source,
+                }),
+            get: id =>
+                this.get({
+                    endpoint: "/" + id,
+                }),
+            getAll: (query, params) =>
+                this.getAll({
+                    endpoint: "/",
+                    query,
+                    params,
+                    headers: {},
+                }),
+            count: (query = {}) =>
+                this.count({
+                    endpoint:
+                        "?" +
+                        new URLSearchParams({
+                            _page: 1,
+                            _per_page: 1,
+                            ...(query && { q: JSON.stringify(query) }),
+                        }),
+                }),
+        };
+    }
+}
+
+export default RecordSourcesAPIClient;
diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/record_sources.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/record_sources.ts
new file mode 100644
index 00000000000..80f43f4ea2f
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/record_sources.ts
@@ -0,0 +1,54 @@
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+import { createWebHistory, createRouter } from "vue-router";
+
+import { library } from "@fortawesome/fontawesome-svg-core";
+import {
+    faPlus,
+    faMinus,
+    faPencil,
+    faTrash,
+    faSpinner,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
+import vSelect from "vue-select";
+import { useNavigationStore } from "../../stores/navigation";
+import { useMainStore } from "../../stores/main";
+import routesDef from "../../routes/admin/record_sources";
+
+library.add(faPlus, faMinus, faPencil, faTrash, faSpinner);
+
+const pinia = createPinia();
+const navigationStore = useNavigationStore(pinia);
+const mainStore = useMainStore(pinia);
+const { removeMessages } = mainStore;
+const { setRoutes } = navigationStore;
+const routes = setRoutes(routesDef);
+
+const router = createRouter({
+    history: createWebHistory(),
+    linkExactActiveClass: "current",
+    routes,
+});
+
+import App from "../../components/Admin/RecordSources/Main.vue";
+import i18n from "../../i18n";
+
+const app = createApp(App);
+
+const rootComponent = app
+    .use(i18n)
+    .use(pinia)
+    .use(router)
+    .component("font-awesome-icon", FontAwesomeIcon)
+    .component("v-select", vSelect);
+
+app.config.unwrapInjectedRef = true;
+app.provide("mainStore", mainStore);
+app.provide("navigationStore", navigationStore);
+app.mount("#record-source");
+
+router.beforeEach(to => {
+    navigationStore.$patch({ current: to.matched, params: to.params || {} });
+    removeMessages(); // This will actually flag the messages as displayed already
+});
diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/routes/admin/record_sources.js b/koha-tmpl/intranet-tmpl/prog/js/vue/routes/admin/record_sources.js
new file mode 100644
index 00000000000..a85fd4b5f41
--- /dev/null
+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/routes/admin/record_sources.js
@@ -0,0 +1,38 @@
+import { markRaw } from "vue";
+import RecordSourcesFormAdd from "../../components/Admin/RecordSources/FormAdd.vue";
+import RecordSourcesList from "../../components/Admin/RecordSources/List.vue";
+import { $__ } from "../../i18n";
+
+export default {
+    title: $__("Administration"),
+    path: "",
+    href: "/cgi-bin/koha/admin/admin-home.pl",
+    is_base: true,
+    is_default: true,
+    children: [
+        {
+            title: $__("Record sources"),
+            path: "/cgi-bin/koha/admin/record_sources",
+            is_end_node: true,
+            children: [
+                {
+                    path: "",
+                    name: "RecordSourcesList",
+                    component: markRaw(RecordSourcesList),
+                },
+                {
+                    component: markRaw(RecordSourcesFormAdd),
+                    name: "RecordSourcesFormAdd",
+                    path: "add",
+                    title: $__("Add record source"),
+                },
+                {
+                    component: markRaw(RecordSourcesFormAdd),
+                    name: "RecordSourcesFormAddEdit",
+                    path: "edit/:record_source_id",
+                    title: $__("Edit record source"),
+                },
+            ],
+        },
+    ],
+};
diff --git a/webpack.config.js b/webpack.config.js
index c22256785a4..ae87653c244 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -7,6 +7,7 @@ module.exports = {
   entry: {
     erm: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/erm.ts",
     preservation: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/preservation.ts",
+    "admin/record_sources": "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/admin/record_sources.ts",
   },
   output: {
     filename: "[name].js",
-- 
2.34.1