Bugzilla – Attachment 146412 Details for
Bug 32607
Add record sources CRUD
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 32607: Add CRUD for import sources
Bug-32607-Add-CRUD-for-import-sources.patch (text/plain), 99.43 KB, created by
Agustín Moyano
on 2023-02-08 22:21:38 UTC
(
hide
)
Description:
Bug 32607: Add CRUD for import sources
Filename:
MIME Type:
Creator:
Agustín Moyano
Created:
2023-02-08 22:21:38 UTC
Size:
99.43 KB
patch
obsolete
>From c3b5ad3a65dc4a77b76270c5037606c674bf8f53 Mon Sep 17 00:00:00 2001 >From: Agustin Moyano <agustinmoyano@theke.io> >Date: Thu, 2 Feb 2023 21:07:55 -0300 >Subject: [PATCH] Bug 32607: Add CRUD for import sources > >This patch adds vue components to create, read, update and delete import sources > >To test: >1. apply patches >2. updatedatabase >3. yarn build_js >4. go to Administration -> Import sources >5. play around with the ui and create, update, remove and search import sources >6. Sign off ;) >--- > Koha/ImportSource.pm | 56 +++ > Koha/ImportSources.pm | 51 +++ > Koha/REST/V1/ImportSources.pm | 141 ++++++ > Koha/Schema/Result/ImportSource.pm | 8 + > admin/import-sources.pl | 38 ++ > api/v1/swagger/definitions/import_source.yaml | 13 + > api/v1/swagger/paths/import_sources.yaml | 237 ++++++++++ > api/v1/swagger/swagger.yaml | 15 + > .../prog/en/modules/admin/admin-home.tt | 2 + > .../prog/en/modules/admin/import-sources.tt | 37 ++ > .../prog/js/vue/components/Dialog.vue | 20 +- > .../vue/components/import-sources/ISEdit.vue | 99 ++++ > .../vue/components/import-sources/ISList.vue | 82 ++++ > .../vue/components/import-sources/ISMain.vue | 28 ++ > .../js/vue/components/shared/Breadcrumbs.vue | 41 ++ > .../prog/js/vue/components/shared/Dialog.vue | 53 +++ > .../prog/js/vue/components/shared/Form.vue | 140 ++++++ > .../js/vue/components/shared/KohaTable.vue | 433 ++++++++++++++++++ > .../prog/js/vue/components/shared/Menu.vue | 57 +++ > .../js/vue/components/shared/MenuItem.vue | 41 ++ > .../prog/js/vue/components/shared/Page.vue | 48 ++ > .../intranet-tmpl/prog/js/vue/i18n/index.js | 9 + > .../prog/js/vue/import-source/main.ts | 77 ++++ > .../prog/js/vue/import-source/model.js | 83 ++++ > .../prog/js/vue/import-source/router.js | 12 + > .../prog/js/vue/import-source/tree.js | 31 ++ > .../prog/js/vue/inputs/autocomplete.js | 199 ++++++++ > .../intranet-tmpl/prog/js/vue/inputs/index.js | 5 + > .../intranet-tmpl/prog/js/vue/stores/main.js | 60 ++- > .../intranet-tmpl/prog/js/vue/stores/menu.js | 100 ++++ > .../intranet-tmpl/prog/js/vue/stores/model.js | 51 +++ > .../intranet-tmpl/prog/js/vue/stores/table.js | 96 ++++ > package.json | 14 +- > t/db_dependent/Koha/ImportSources.t | 44 ++ > t/db_dependent/api/v1/import_sources.t | 318 +++++++++++++ > webpack.config.js | 1 + > 36 files changed, 2710 insertions(+), 30 deletions(-) > create mode 100644 Koha/ImportSource.pm > create mode 100644 Koha/ImportSources.pm > create mode 100644 Koha/REST/V1/ImportSources.pm > create mode 100644 admin/import-sources.pl > create mode 100644 api/v1/swagger/definitions/import_source.yaml > create mode 100644 api/v1/swagger/paths/import_sources.yaml > create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/admin/import-sources.tt > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISEdit.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISList.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISMain.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Breadcrumbs.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Dialog.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Form.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/KohaTable.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Menu.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/MenuItem.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Page.vue > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/i18n/index.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/import-source/main.ts > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/import-source/model.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/import-source/router.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/import-source/tree.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/inputs/autocomplete.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/inputs/index.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/stores/menu.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/stores/model.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/stores/table.js > create mode 100644 t/db_dependent/Koha/ImportSources.t > create mode 100644 t/db_dependent/api/v1/import_sources.t > >diff --git a/Koha/ImportSource.pm b/Koha/ImportSource.pm >new file mode 100644 >index 0000000000..b23fc95422 >--- /dev/null >+++ b/Koha/ImportSource.pm >@@ -0,0 +1,56 @@ >+package Koha::ImportSource; >+ >+# This file is part of Koha. >+# >+# Copyright 2020 Koha Development Team >+# >+# 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 base qw(Koha::Object); >+ >+=head1 NAME >+ >+Koha::ImportSource - Koha ImportSource Object class >+ >+=head1 API >+ >+=head2 Class Methods >+ >+=head3 patron >+ >+my $patron = $import_source->patron >+ >+Return the patron for this import source >+ >+=cut >+ >+sub patron { >+ my ($self) = @_; >+ >+ my $patron_rs = $self->_result->patron; >+ return Koha::Patron->_new_from_dbic($patron_rs); >+} >+ >+=head3 _type >+ >+=cut >+ >+sub _type { >+ return 'ImportSource'; >+} >+ >+1; >\ No newline at end of file >diff --git a/Koha/ImportSources.pm b/Koha/ImportSources.pm >new file mode 100644 >index 0000000000..1cdca37b1b >--- /dev/null >+++ b/Koha/ImportSources.pm >@@ -0,0 +1,51 @@ >+package Koha::ImportSources; >+ >+# This file is part of Koha. >+# >+# Copyright 2020 Koha Development Team >+# >+# 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 base qw(Koha::Objects); >+ >+use Koha::ImportSource; >+ >+=head1 NAME >+ >+Koha::ImportSources - Koha ImportSources Object class >+ >+=head1 API >+ >+=head2 Class Methods >+ >+=head3 _type >+ >+=cut >+ >+sub _type { >+ return 'ImportSource'; >+} >+ >+=head3 object_class >+ >+=cut >+ >+sub object_class { >+ return 'Koha::ImportSource'; >+} >+ >+1; >\ No newline at end of file >diff --git a/Koha/REST/V1/ImportSources.pm b/Koha/REST/V1/ImportSources.pm >new file mode 100644 >index 0000000000..b77e2cadca >--- /dev/null >+++ b/Koha/REST/V1/ImportSources.pm >@@ -0,0 +1,141 @@ >+package Koha::REST::V1::ImportSources; >+ >+# 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 Mojo::Base 'Mojolicious::Controller'; >+ >+use Koha::ImportSources; >+ >+use Try::Tiny qw( catch try ); >+ >+=head1 API >+ >+=head2 Methods >+ >+=head3 list >+ >+=cut >+ >+sub list { >+ my $c = shift->openapi->valid_input or return; >+ >+ return try { >+ my $import_sources_set = Koha::ImportSources->new; >+ my $import_sources = $c->objects->search( $import_sources_set ); >+ return $c->render( status => 200, openapi => $import_sources ); >+ } >+ catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+=head3 get >+ >+=cut >+ >+sub get { >+ my $c = shift->openapi->valid_input or return; >+ >+ return try { >+ my $import_sources_set = Koha::ImportSources->new; >+ my $import_source = $c->objects->find( $import_sources_set, $c->validation->param('import_source_id') ); >+ unless ($import_source) { >+ return $c->render( status => 404, >+ openapi => { error => "Import source not found" } ); >+ } >+ >+ return $c->render( status => 200, openapi => $import_source ); >+ } >+ catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+=head3 add >+ >+=cut >+ >+sub add { >+ my $c = shift->openapi->valid_input or return; >+ >+ return try { >+ my $import_source = Koha::ImportSource->new_from_api( $c->validation->param('body') ); >+ $import_source->store; >+ $c->res->headers->location( $c->req->url->to_string . '/' . $import_source->import_source_id ); >+ return $c->render( >+ status => 201, >+ openapi => $import_source->to_api >+ ); >+ } >+ catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+=head3 update >+ >+=cut >+ >+sub update { >+ my $c = shift->openapi->valid_input or return; >+ >+ my $import_source = Koha::ImportSources->find( $c->validation->param('import_source_id') ); >+ >+ if ( not defined $import_source ) { >+ return $c->render( status => 404, >+ openapi => { error => "Object not found" } ); >+ } >+ >+ return try { >+ $import_source->set_from_api( $c->validation->param('body') ); >+ $import_source->store(); >+ return $c->render( status => 200, openapi => $import_source->to_api ); >+ } >+ catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+=head3 delete >+ >+=cut >+ >+sub delete { >+ my $c = shift->openapi->valid_input or return; >+ >+ my $import_source = Koha::ImportSources->find( $c->validation->param('import_source_id') ); >+ if ( not defined $import_source ) { >+ return $c->render( status => 404, >+ openapi => { error => "Object not found" } ); >+ } >+ >+ return try { >+ $import_source->delete; >+ return $c->render( >+ status => 204, >+ openapi => q{} >+ ); >+ } >+ catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+1; >diff --git a/Koha/Schema/Result/ImportSource.pm b/Koha/Schema/Result/ImportSource.pm >index a902cb0892..83be473817 100644 >--- a/Koha/Schema/Result/ImportSource.pm >+++ b/Koha/Schema/Result/ImportSource.pm >@@ -86,4 +86,12 @@ __PACKAGE__->belongs_to( > > > # You can replace this text with custom code or comments, and it will be preserved on regeneration >+ >+sub koha_object_class { >+ 'Koha::ImportSource'; >+} >+sub koha_objects_class { >+ 'Koha::ImportSources'; >+} >+ > 1; >diff --git a/admin/import-sources.pl b/admin/import-sources.pl >new file mode 100644 >index 0000000000..c23c53270a >--- /dev/null >+++ b/admin/import-sources.pl >@@ -0,0 +1,38 @@ >+#!/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 ); >+use Koha::Plugins; >+ >+my $query = CGI->new; >+ >+my ( $template, $loggedinuser, $cookie ) = get_template_and_user( >+ { >+ template_name => "admin/import-sources.tt", >+ query => $query, >+ type => "intranet", >+ flagsrequired => { parameters => 'manage_import_sources' }, >+ } >+); >+ >+output_html_with_http_headers $query, $cookie, $template->output; >diff --git a/api/v1/swagger/definitions/import_source.yaml b/api/v1/swagger/definitions/import_source.yaml >new file mode 100644 >index 0000000000..b7d3555d1d >--- /dev/null >+++ b/api/v1/swagger/definitions/import_source.yaml >@@ -0,0 +1,13 @@ >+--- >+type: object >+properties: >+ import_source_id: >+ type: integer >+ description: internally assigned import source identifier >+ readOnly: true >+ name: >+ description: import source name >+ type: string >+ patron_id: >+ description: linked patron identifier >+ type: integer >diff --git a/api/v1/swagger/paths/import_sources.yaml b/api/v1/swagger/paths/import_sources.yaml >new file mode 100644 >index 0000000000..fc18fd4db3 >--- /dev/null >+++ b/api/v1/swagger/paths/import_sources.yaml >@@ -0,0 +1,237 @@ >+/import_sources: >+ get: >+ x-mojo-to: ImportSources#list >+ operationId: listImportSources >+ summary: List import sources >+ tags: >+ - import_sources >+ parameters: >+ - $ref: "../swagger.yaml#/parameters/match" >+ - $ref: "../swagger.yaml#/parameters/order_by" >+ - $ref: "../swagger.yaml#/parameters/page" >+ - $ref: "../swagger.yaml#/parameters/per_page" >+ - $ref: "../swagger.yaml#/parameters/q_param" >+ - $ref: "../swagger.yaml#/parameters/q_body" >+ - $ref: "../swagger.yaml#/parameters/q_header" >+ - $ref: "../swagger.yaml#/parameters/request_id_header" >+ consumes: >+ - application/json >+ produces: >+ - application/json >+ responses: >+ "200": >+ description: A list of import sources >+ "400": >+ description: Missing or wrong parameters >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Authentication required >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Not allowed >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: | >+ Internal server error. Possible `error_code` attribute values: >+ >+ * `internal_server_error` >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ parameters: manage_import_sources >+ post: >+ x-mojo-to: ImportSources#add >+ operationId: addImportSources >+ summary: Add an import source >+ tags: >+ - import_sources >+ parameters: >+ - name: body >+ in: body >+ description: A JSON object containing informations about the new import source >+ required: true >+ schema: >+ $ref: "../swagger.yaml#/definitions/import_source" >+ consumes: >+ - application/json >+ produces: >+ - application/json >+ responses: >+ "201": >+ description: An import source >+ "400": >+ description: Missing or wrong parameters >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Authentication required >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Not allowed >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: | >+ Internal server error. Possible `error_code` attribute values: >+ >+ * `internal_server_error` >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ parameters: manage_import_sources >+"/import_sources/{import_source_id}": >+ get: >+ x-mojo-to: ImportSources#get >+ operationId: getImportSources >+ summary: Get an import source >+ tags: >+ - import_sources >+ parameters: >+ - $ref: "../swagger.yaml#/parameters/import_source_id_pp" >+ consumes: >+ - application/json >+ produces: >+ - application/json >+ responses: >+ "200": >+ description: An import source >+ "400": >+ description: Missing or wrong parameters >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Authentication required >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Not allowed >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "404": >+ description: Not found >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: | >+ Internal server error. Possible `error_code` attribute values: >+ >+ * `internal_server_error` >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ parameters: manage_import_sources >+ put: >+ x-mojo-to: ImportSources#update >+ operationId: updateImportSources >+ summary: Update an import source >+ tags: >+ - import_sources >+ parameters: >+ - $ref: "../swagger.yaml#/parameters/import_source_id_pp" >+ - name: body >+ in: body >+ description: A JSON object containing informations about the new import source >+ required: true >+ schema: >+ $ref: "../swagger.yaml#/definitions/import_source" >+ consumes: >+ - application/json >+ produces: >+ - application/json >+ responses: >+ "200": >+ description: An import source >+ "400": >+ description: Missing or wrong parameters >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Authentication required >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Not allowed >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "404": >+ description: Not found >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: | >+ Internal server error. Possible `error_code` attribute values: >+ >+ * `internal_server_error` >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ parameters: manage_import_sources >+ delete: >+ x-mojo-to: ImportSources#delete >+ operationId: deleteImportSources >+ summary: Delete an import source >+ tags: >+ - import_sources >+ parameters: >+ - $ref: "../swagger.yaml#/parameters/import_source_id_pp" >+ consumes: >+ - application/json >+ produces: >+ - application/json >+ responses: >+ "204": >+ description: Deleted >+ "400": >+ description: Missing or wrong parameters >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "401": >+ description: Authentication required >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "403": >+ description: Not allowed >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "404": >+ description: Not found >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "500": >+ description: | >+ Internal server error. Possible `error_code` attribute values: >+ >+ * `internal_server_error` >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ "503": >+ description: Under maintenance >+ schema: >+ $ref: "../swagger.yaml#/definitions/error" >+ x-koha-authorization: >+ permissions: >+ parameters: manage_import_sources >\ No newline at end of file >diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml >index 479c4d710b..327e244f52 100644 >--- a/api/v1/swagger/swagger.yaml >+++ b/api/v1/swagger/swagger.yaml >@@ -54,6 +54,8 @@ definitions: > $ref: ./definitions/import_batch_profiles.yaml > import_record_match: > $ref: ./definitions/import_record_match.yaml >+ import_source: >+ $ref: ./definitions/import_source.yaml > invoice: > $ref: ./definitions/invoice.yaml > item: >@@ -235,6 +237,10 @@ paths: > $ref: ./paths/import_batch_profiles.yaml#/~1import_batch_profiles > "/import_batch_profiles/{import_batch_profile_id}": > $ref: "./paths/import_batch_profiles.yaml#/~1import_batch_profiles~1{import_batch_profile_id}" >+ /import_sources: >+ $ref: ./paths/import_sources.yaml#/~1import_sources >+ "/import_sources/{import_source_id}": >+ $ref: ./paths/import_sources.yaml#/~1import_sources~1{import_source_id} > /items: > $ref: ./paths/items.yaml#/~1items > "/items/{item_id}": >@@ -434,6 +440,12 @@ parameters: > name: import_record_id > required: true > type: integer >+ import_source_id_pp: >+ description: Internal import source identifier >+ in: path >+ name: import_source_id >+ required: true >+ type: integer > item_id_pp: > description: Internal item identifier > in: path >@@ -724,6 +736,9 @@ tags: > - description: "Manage item groups\n" > name: item_groups > x-displayName: Item groups >+ - description: "Manage import sources\n" >+ name: import_sources >+ x-displayName: Import source > - description: "Manage items\n" > name: items > x-displayName: Items >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 0e83b36721..71c9f89f23 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 >@@ -211,6 +211,8 @@ > <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 %] >+ <dt><a href="/cgi-bin/koha/admin/import-sources.pl">Import sources</a></dt> >+ <dd>Define sources to import from</dd> > </dl> > [% END %] > >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/import-sources.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/import-sources.tt >new file mode 100644 >index 0000000000..63fd41f541 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/import-sources.tt >@@ -0,0 +1,37 @@ >+[% USE raw %] >+[% USE To %] >+[% USE Asset %] >+[% USE KohaDates %] >+[% USE TablesSettings %] >+[% USE AuthorisedValues %] >+[% SET footerjs = 1 %] >+[% PROCESS 'i18n.inc' %] >+[% INCLUDE 'doc-head-open.inc' %] >+<title> >+ Import sources › Koha >+</title> >+[% INCLUDE 'doc-head-close.inc' %] >+</head> >+ >+<body id="erm_agreements" class="erm"> >+[% WRAPPER 'header.inc' %] >+ [% INCLUDE 'erm-search.inc' %] >+[% END %] >+ >+<div id="import-source"> <!-- this is closed in intranet-bottom.inc --> >+ >+[% MACRO jsinclude BLOCK %] >+ [% INCLUDE 'calendar.inc' %] >+ [% INCLUDE 'datatables.inc' %] >+ [% INCLUDE 'columns_settings.inc' %] >+ [% INCLUDE 'js-patron-format.inc' %] >+ [% INCLUDE 'js-date-format.inc' %] >+ >+ <script> >+ const RESTOAuth2ClientCredentials = [% IF Koha.Preference('RESTOAuth2ClientCredentials') %]true[% ELSE %]false[% END %]; >+ </script> >+ >+ [% Asset.js("js/vue/dist/import-source.js") | $raw %] >+ >+[% END %] >+[% INCLUDE 'intranet-bottom.inc' %] >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Dialog.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Dialog.vue >index ae336ce1dd..1561779789 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Dialog.vue >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Dialog.vue >@@ -1,8 +1,16 @@ > <template> >- <div class="dialog message" v-if="message">{{ message }}</div> >- <div class="dialog alert" v-if="error">{{ error }}</div> >+ <div class="dialog message" v-if="message" v-html="message"></div> >+ <div class="dialog alert" v-if="error" v-html="error"></div> > <div class="dialog alert modal" v-if="warning"> >- {{ warning }} >+ <span v-html="warning"></span> >+ <a >+ v-if="accept" >+ id="close_modal" >+ class="btn btn-primary btn-xs" >+ role="button" >+ @click="accept" >+ >{{ $__("Accept") }}</a >+ > > <a > id="close_modal" > class="btn btn-default btn-xs" >@@ -20,10 +28,10 @@ import { storeToRefs } from "pinia" > export default { > setup() { > const mainStore = inject("mainStore") >- const { message, error, warning } = storeToRefs(mainStore) >+ const { message, error, warning, accept } = storeToRefs(mainStore) > const { removeMessages } = mainStore >- return { message, error, warning, removeMessages } >- }, >+ return { message, error, warning, accept, removeMessages } >+ } > } > </script> > >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISEdit.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISEdit.vue >new file mode 100644 >index 0000000000..7aeb46e49a >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISEdit.vue >@@ -0,0 +1,99 @@ >+<template> >+ <h1>{{ title }}</h1> >+ <Form >+ :schema="schema" >+ :data="data" >+ @submit="processSubmit" >+ @cancel="doCancel" >+ :loading="loading" >+ ></Form> >+</template> >+ >+<script> >+import Form from "../shared/Form.vue" >+import { inject } from "vue" >+import { useRouter } from "vue-router" >+ >+export default { >+ name: "ISEdit", >+ setup() { >+ const { getFormSchema } = inject("modelStore") >+ const { add, update, get } = inject("tableStore") >+ const { setError, setMessage } = inject("mainStore") >+ const schema = getFormSchema() >+ const router = useRouter() >+ return { >+ schema, >+ add, >+ update, >+ setError, >+ setMessage, >+ router, >+ get, >+ } >+ }, >+ data() { >+ return { >+ data: null, >+ loading: true, >+ } >+ }, >+ methods: { >+ async processSubmit(data) { >+ let response >+ let responseMessage >+ if (data.import_source_id) { >+ const { import_source_id: id, ...row } = data >+ response = await this.update({ id, row }) >+ responseMessage = this.$__("Import source updated!") >+ } else { >+ response = await this.add({ row: data }) >+ responseMessage = this.$__("Import source created!") >+ } >+ if (response.ok) { >+ this.setMessage(responseMessage) >+ return this.router.push({ path: "../import-sources.pl" }) >+ } else { >+ this.setError( >+ this.$__( >+ "Could not create the import source. Error: %s" >+ ).format(response.statusText) >+ ) >+ } >+ }, >+ doCancel() { >+ this.router.push({ path: "../import-sources.pl" }) >+ }, >+ }, >+ async created() { >+ const { id } = this.$route.params >+ if (id !== undefined) { >+ const response = await this.get({ >+ id, >+ }) >+ if (response.ok) { >+ this.data = response.data >+ } else { >+ this.setError( >+ this.$__( >+ "Could not fetch import source data. Error %s" >+ ).format(response.statusText) >+ ) >+ } >+ } >+ this.loading = false >+ }, >+ computed: { >+ title() { >+ if (!this.data) return this.$__("Add import source") >+ return this.$__("Edit %s").format(this.data.name) >+ }, >+ }, >+ beforeMount() { >+ this.$root.setTitle(this.title) >+ }, >+ components: { >+ Form, >+ }, >+} >+</script> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISList.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISList.vue >new file mode 100644 >index 0000000000..e36d4f104a >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISList.vue >@@ -0,0 +1,82 @@ >+<template> >+ <div id="toolbar" class="btn-toolbar"> >+ <div class="btn-group"> >+ <button class="btn btn-default" @click="newImportSource"> >+ <i class="fa fa-plus"></i> New import source >+ </button> >+ </div> >+ </div> >+ <h1>{{ title }}</h1> >+ <KohaTable >+ v-bind="tableOptions" >+ @edit="doEdit" >+ @remove="doRemove" >+ ></KohaTable> >+</template> >+ >+<script> >+import { useRouter } from "vue-router" >+import { inject } from "vue" >+import KohaTable from "../shared/KohaTable.vue" >+export default { >+ name: "ISList", >+ data() { >+ return { >+ title: this.$__("Import Sources"), >+ tableOptions: { >+ columns: this.columns, >+ options: { >+ embed: "patron", >+ }, >+ }, >+ } >+ }, >+ setup() { >+ const router = useRouter() >+ const { getTableColumns } = inject("modelStore") >+ const { setWarning, setMessage, setError } = inject("mainStore") >+ const { remove } = inject("tableStore") >+ const columns = getTableColumns() >+ return { >+ router, >+ columns, >+ setWarning, >+ setMessage, >+ setError, >+ remove, >+ } >+ }, >+ beforeMount() { >+ this.$root.setTitle(this.title) >+ }, >+ methods: { >+ newImportSource() { >+ this.router.push({ path: "import-sources.pl/add" }) >+ }, >+ doEdit(data) { >+ this.router.push({ >+ path: `import-sources.pl/${data.import_source_id}`, >+ props: { >+ data, >+ }, >+ }) >+ }, >+ async doRemove(data, done) { >+ const response = await this.remove({ id: data.import_source_id }) >+ if (response.ok) { >+ this.setMessage(this.$__("Import source removed"), true) >+ done() >+ } else { >+ this.setError( >+ this.$__( >+ "Could not remove import source. Error: %s" >+ ).format(response.statusText) >+ ) >+ } >+ }, >+ }, >+ components: { >+ KohaTable, >+ }, >+} >+</script> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISMain.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISMain.vue >new file mode 100644 >index 0000000000..f31607fe62 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/import-sources/ISMain.vue >@@ -0,0 +1,28 @@ >+<template> >+ <Page :side-menu="false" :title="title"> >+ <router-view></router-view> >+ </Page> >+</template> >+ >+<script> >+import Page from "../shared/Page.vue" >+import { inject } from "vue" >+export default { >+ name: "ISMain", >+ data() { >+ return { >+ title: "", >+ } >+ }, >+ methods: { >+ setTitle(title) { >+ this.title = title >+ }, >+ }, >+ components: { >+ Page, >+ }, >+} >+</script> >+ >+<style></style> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Breadcrumbs.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Breadcrumbs.vue >new file mode 100644 >index 0000000000..1e79885132 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Breadcrumbs.vue >@@ -0,0 +1,41 @@ >+<template> >+ <nav id="breadcrumbs" aria-label="Breadcrumb" class="breadcrumb"> >+ <ol> >+ <template v-for="(item, idx) in breadcrumbs" v-bind:key="idx"> >+ <MenuItem >+ v-if="idx < breadcrumbs.length - 1" >+ :item="item" >+ ></MenuItem> >+ <MenuItem >+ v-else >+ :item="{ ...item, path: undefined, href: undefined }" >+ ></MenuItem> >+ </template> >+ </ol> >+ </nav> >+</template> >+ >+<script> >+import { inject } from "vue" >+import { storeToRefs } from "pinia" >+import MenuItem from "./MenuItem.vue" >+export default { >+ name: "Breadcrumbs", >+ setup: () => { >+ const menuStore = inject("menuStore") >+ const { breadcrumbs } = storeToRefs(menuStore) >+ return { >+ breadcrumbs, >+ } >+ }, >+ data: () => ({ >+ mode: "menu", >+ }), >+ props: { >+ title: String, >+ }, >+ components: { >+ MenuItem, >+ }, >+} >+</script> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Dialog.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Dialog.vue >new file mode 100644 >index 0000000000..1561779789 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Dialog.vue >@@ -0,0 +1,53 @@ >+<template> >+ <div class="dialog message" v-if="message" v-html="message"></div> >+ <div class="dialog alert" v-if="error" v-html="error"></div> >+ <div class="dialog alert modal" v-if="warning"> >+ <span v-html="warning"></span> >+ <a >+ v-if="accept" >+ id="close_modal" >+ class="btn btn-primary btn-xs" >+ role="button" >+ @click="accept" >+ >{{ $__("Accept") }}</a >+ > >+ <a >+ id="close_modal" >+ class="btn btn-default btn-xs" >+ role="button" >+ @click="removeMessages" >+ >{{ $__("Close") }}</a >+ > >+ </div> >+ <!-- Must be styled differently --> >+</template> >+ >+<script> >+import { inject } from "vue" >+import { storeToRefs } from "pinia" >+export default { >+ setup() { >+ const mainStore = inject("mainStore") >+ const { message, error, warning, accept } = storeToRefs(mainStore) >+ const { removeMessages } = mainStore >+ return { message, error, warning, accept, removeMessages } >+ } >+} >+</script> >+ >+<style scoped> >+.modal { >+ position: fixed; >+ z-index: 9998; >+ display: table; >+ transition: opacity 0.3s ease; >+ margin: auto; >+ padding: 20px 30px; >+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33); >+ transition: all 0.3s ease; >+} >+#close_modal { >+ float: right; >+ cursor: pointer; >+} >+</style> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Form.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Form.vue >new file mode 100644 >index 0000000000..b6a6c56af3 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Form.vue >@@ -0,0 +1,140 @@ >+<template> >+ <FormKit >+ v-if="!loading" >+ ref="form" >+ @submit="doSubmit" >+ type="form" >+ :actions="false" >+ :value="data" >+ > >+ <FormKitSchema :schema="formSchema" :data="schemaData"></FormKitSchema> >+ </FormKit> >+ <div v-else>Loading...</div> >+</template> >+<script> >+import { FormKitSchema } from "@formkit/vue" >+import { __ } from "../../i18n" >+ >+export default { >+ name: "Form", >+ data() { >+ return { >+ schemaData: { >+ doCancel: () => { >+ this.$emit("cancel") >+ }, >+ }, >+ formSchema: [ >+ { >+ $el: "fieldset", >+ attrs: { >+ class: "rows", >+ }, >+ children: this.schema, >+ }, >+ { >+ $el: "fieldset", >+ attrs: { >+ class: "action", >+ }, >+ children: [ >+ { >+ $el: "input", >+ attrs: { >+ type: "submit", >+ class: "btn btn-primary", >+ }, >+ ignore: false, >+ children: this.submitMessage, >+ }, >+ { >+ $el: "a", >+ attrs: { >+ class: "cancel", >+ onClick: "$doCancel", >+ }, >+ children: this.cancelMessage, >+ }, >+ ], >+ }, >+ ], >+ } >+ }, >+ methods: { >+ async doSubmit(event) { >+ this.$emit("submit", event) >+ }, >+ }, >+ components: { >+ FormKitSchema, >+ }, >+ emits: ["submit", "cancel"], >+ props: { >+ schema: Object, >+ loading: { >+ type: Boolean, >+ default: false, >+ }, >+ data: { >+ type: Object, >+ default: {}, >+ }, >+ submitMessage: { >+ type: String, >+ default: __("Submit"), >+ }, >+ cancelMessage: { >+ type: String, >+ default: __("Cancel"), >+ }, >+ }, >+} >+</script> >+ >+<style> >+.formkit-value { >+ align-items: center; >+ background-color: #f5f5f5; >+ border-radius: 0.25em; >+ box-sizing: border-box; >+ color: black; >+ display: flex; >+ justify-content: space-between; >+ padding: 0.55em 0.5em; >+ text-decoration: none; >+ width: 100%; >+} >+ >+a.formkit-value:hover { >+ text-decoration: none; >+} >+ >+.formkit-value::after { >+ content: "\00D7"; >+ margin-left: 0.5em; >+ font-size: 1.1em; >+} >+ >+.formkit-dropdown { >+ position: absolute; >+ top: 100%; >+ left: 0; >+ min-width: 15em; >+ background-color: white; >+ box-shadow: 0 0 0.5em rgb(0 0 0 / 10%); >+ margin: 0; >+ padding: 0; >+ list-style-type: none; >+ overflow: hidden; >+ border-radius: 0.25em; >+} >+ >+.formkit-dropdown-item[data-selected="true"] { >+ background-color: #cfe8fc; >+} >+ >+.formkit-dropdown-item { >+ padding: 0.5em; >+ border-bottom: 1px solid #e4e4e4; >+} >+</style> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/KohaTable.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/KohaTable.vue >new file mode 100644 >index 0000000000..c33d9efe62 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/KohaTable.vue >@@ -0,0 +1,433 @@ >+<template> >+ <DataTable >+ :columns="tableColumns" >+ :options="{ ...dataTablesDefaults, ...options, ...mandatoryOptions }" >+ :data="data" >+ ref="table" >+ > >+ <slot></slot> >+ </DataTable> >+</template> >+ >+<script> >+import DataTable from "datatables.net-vue3" >+import DataTablesLib from "datatables.net" >+import { inject, nextTick } from "vue" >+ >+DataTable.use(DataTablesLib) >+ >+function toggledClearFilter(searchText, tableId) { >+ if (searchText == "") { >+ $("#" + tableId + "_wrapper") >+ .find(".dt_button_clear_filter") >+ .addClass("disabled") >+ } else { >+ $("#" + tableId + "_wrapper") >+ .find(".dt_button_clear_filter") >+ .removeClass("disabled") >+ } >+} >+ >+export default { >+ name: "KohaTable", >+ data() { >+ return { >+ errorMessage: "", >+ tableColumns: this.columns, >+ mandatoryOptions: { >+ deferRender: true, >+ paging: true, >+ serverSide: true, >+ searching: true, >+ pagingType: "full_numbers", >+ processing: true, >+ ajax: async (data, callback, settings) => { >+ const options = { >+ ...this.dataTablesDefaults, >+ ...this.options, >+ ...this.mandatoryOptions, >+ } >+ try { >+ const headers = {} >+ if (options.embed) { >+ headers["x-koha-embed"] = Array.isArray( >+ options.embed >+ ) >+ ? options.embed.join(",") >+ : options.embed >+ } >+ >+ const length = data.length >+ const start = data.start >+ >+ const dataSet = { >+ _page: Math.floor(start / length) + 1, >+ _per_page: length, >+ } >+ >+ const build_query = (col, value) => { >+ const parts = [] >+ const attributes = col.data.split(":") >+ return attributes.map(attr => { >+ let criteria = options.criteria >+ if (value.match(/^\^(.*)\$$/)) { >+ value = value >+ .replace(/^\^/, "") >+ .replace(/\$$/, "") >+ criteria = "exact" >+ } else { >+ // escape SQL LIKE special characters % and _ >+ value = value.replace(/(\%|\\)/g, "\\$1") >+ } >+ return { >+ [!attr.includes(".") ? "me." + attr : attr]: >+ criteria === "exact" >+ ? value >+ : { >+ like: >+ ([ >+ "contains", >+ "ends_with", >+ ].indexOf(criteria) !== -1 >+ ? "%" >+ : "") + >+ value + >+ ([ >+ "contains", >+ "starts_with", >+ ].indexOf(criteria) !== -1 >+ ? "%" >+ : ""), >+ }, >+ } >+ }) >+ } >+ >+ const filter = data.search.value >+ // Build query for each column filter >+ const and_query_parameters = settings.aoColumns >+ .filter(function (col) { >+ return ( >+ col.bSearchable && >+ typeof col.data == "string" && >+ data.columns[col.idx].search.value != "" >+ ) >+ }) >+ .map(function (col) { >+ const value = data.columns[col.idx].search.value >+ return build_query(col, value) >+ }) >+ .map(function r(e) { >+ return $.isArray(e) ? $.map(e, r) : e >+ }) >+ >+ // Build query for the global search filter >+ const or_query_parameters = settings.aoColumns >+ .filter(function (col) { >+ return col.bSearchable && filter != "" >+ }) >+ .map(function (col) { >+ const value = filter >+ return build_query(col, value) >+ }) >+ .map(function r(e) { >+ return $.isArray(e) ? $.map(e, r) : e >+ }) >+ >+ if (this.default_filters) { >+ const additional_filters = {} >+ for (f in this.default_filters) { >+ let k >+ let v >+ if ( >+ typeof this.default_filters[f] === >+ "function" >+ ) { >+ const val = this.default_filters[f]() >+ if (val != undefined && val != "") { >+ k = f >+ v = val >+ } >+ } else { >+ k = f >+ v = this.default_filters[f] >+ } >+ >+ // Pass to -or if you want a separate OR clause >+ // It's not the usual DBIC notation! >+ if (f == "-or") { >+ if (v) or_query_parameters.push(v) >+ } else if (f == "-and") { >+ if (v) and_query_parameters.push(v) >+ } else if (v) { >+ additional_filters[k] = v >+ } >+ } >+ if (Object.keys(additional_filters).length) { >+ and_query_parameters.push(additional_filters) >+ } >+ } >+ let query_parameters = and_query_parameters >+ if (or_query_parameters.length) { >+ query_parameters.push(or_query_parameters) >+ } >+ >+ if (query_parameters.length) { >+ query_parameters = JSON.stringify( >+ query_parameters.length === 1 >+ ? query_parameters[0] >+ : { "-and": query_parameters } >+ ) >+ if (options.header_filter) { >+ headers["x-koha-query"] = query_parameters >+ } else { >+ dataSet.q = query_parameters >+ } >+ } >+ >+ dataSet._match = options.criteria >+ >+ if (data.draw !== undefined) { >+ headers["x-koha-request-id"] = data.draw >+ } >+ >+ if (options.columns) { >+ const order = data.order >+ const orderArray = new Array() >+ order.forEach(function (e, i) { >+ const order_col = e.column >+ let order_by = options.columns[order_col].data >+ order_by = order_by.split(":") >+ const order_dir = e.dir == "asc" ? "+" : "-" >+ Array.prototype.push.apply( >+ orderArray, >+ order_by.map( >+ x => >+ order_dir + >+ (!x.includes(".") ? "me." + x : x) >+ ) >+ ) >+ }) >+ dataSet._order_by = orderArray >+ .filter((v, i, a) => a.indexOf(v) === i) >+ .join(",") >+ } >+ >+ const query = new URLSearchParams(dataSet) >+ >+ const response = await this.list({ query, headers }) >+ if (!response.ok) { >+ throw Error( >+ `${response.status} ${response.statusText}` >+ ) >+ } >+ const json = { data: response.data } >+ if (response.headers["x-total-count"]) { >+ const total = response.headers["x-total-count"] >+ json.recordsTotal = total >+ json.recordsFiltered = total >+ } >+ if (response.headers["x-base-total-count"]) { >+ json.recordsTotal = >+ response.headers["x-base-total-count"] >+ } >+ if (response.headers["x-koha-request-id"]) { >+ json.draw = response.headers["x-koha-request-id"] >+ } >+ callback(json) >+ } catch (error) { >+ this.errorMessage = `<div>${this.$__( >+ "Error retrieving data: %s" >+ ).format(error.message)}</div>` >+ callback([]) >+ } >+ }, >+ serverSide: true, >+ }, >+ dataTablesDefaults: { >+ language: { >+ paginate: { >+ first: this.$__("First"), >+ last: this.$__("Last"), >+ next: this.$__("Next"), >+ previous: this.$__("Previous"), >+ }, >+ emptyTable: this.options.emptyTable >+ ? this.options.emptyTable >+ : this.$__("No data available in table"), >+ info: this.$__( >+ "Showing _START_ to _END_ of _TOTAL_ entries" >+ ), >+ infoEmpty: this.$__("No entries to show"), >+ infoFiltered: this.$__( >+ "(filtered from _MAX_ total entries)" >+ ), >+ lengthMenu: this.$__("Show _MENU_ entries"), >+ loadingRecords: this.$__("Loading..."), >+ processing: this.$__("Processing..."), >+ search: this.$__("Search:"), >+ zeroRecords: this.$__("No matching records found"), >+ buttons: { >+ copyTitle: this.$__("Copy to clipboard"), >+ copyKeys: this.$__( >+ "Press <i>ctrl</i> or <i>â</i> + <i>C</i> to copy the table data<br>to your system clipboard.<br><br>To cancel, click this message or press escape." >+ ), >+ copySuccess: { >+ _: this.$__("Copied %d rows to clipboard"), >+ 1: this.$__("Copied one row to clipboard"), >+ }, >+ }, >+ }, >+ dom: '<"dt-info"i><"top pager"<"table_entries"lp><"table_controls"fB>>tr<"bottom pager"ip>', >+ buttons: [ >+ { >+ fade: 100, >+ className: "dt_button_clear_filter", >+ titleAttr: this.$__("Clear filter"), >+ enabled: false, >+ text: >+ '<i class="fa fa-lg fa-remove"></i> <span class="dt-button-text">' + >+ this.$__("Clear filter") + >+ "</span>", >+ available: function (dt) { >+ // The "clear filter" button is made available if this test returns true >+ if (dt.settings()[0].aanFeatures.f) { >+ // aanFeatures.f is null if there is no search form >+ return true >+ } >+ }, >+ action: function (e, dt, node) { >+ dt.search("").draw("page") >+ node.addClass("disabled") >+ }, >+ }, >+ ], >+ lengthMenu: [ >+ [10, 20, 50, 100, -1], >+ [10, 20, 50, 100, this.$__("All")], >+ ], >+ pageLength: 20, >+ fixedHeader: true, >+ criteria: "contains", >+ initComplete: function (settings) { >+ var tableId = settings.nTable.id >+ var state = settings.oLoadedState >+ state && toggledClearFilter(state.search.search, tableId) >+ // When the DataTables search function is triggered, >+ // enable or disable the "Clear filter" button based on >+ // the presence of a search string >+ $("#" + tableId).on("search.dt", function (e, settings) { >+ toggledClearFilter( >+ settings.oPreviousSearch.sSearch, >+ tableId >+ ) >+ }) >+ }, >+ }, >+ } >+ }, >+ setup() { >+ const { data, list, setOptions } = inject("tableStore") >+ const { setError, removeMessages, setWarning } = inject("mainStore") >+ return { data, list, setOptions, setError, removeMessages, setWarning } >+ }, >+ beforeMount() { >+ if (this.url) this.setOptions(this.url) >+ if (this.edit || this.remove) { >+ this.tableColumns = [ >+ ...this.tableColumns, >+ { >+ name: "actions", >+ title: this.$__("Actions"), >+ searchable: false, >+ render: (data, type, row) => { >+ let content = "" >+ if (this.edit) { >+ content += >+ '<button class="edit btn"><i class="fa fa-edit"></i></button>' >+ } >+ if (this.remove) { >+ content += >+ '<button class="remove btn"><i class="fa fa-trash"></i></button>' >+ } >+ return content >+ }, >+ }, >+ ] >+ } >+ // this.dt = this.$refs.table.value.dt(); >+ }, >+ mounted() { >+ if (this.edit || this.remove) { >+ const dt = this.$refs.table.dt() >+ const self = this >+ dt.on("draw", () => { >+ const dataSet = dt.rows().data() >+ dt.column(-1) >+ .nodes() >+ .to$() >+ .each(function (idx) { >+ const data = dataSet[idx] >+ console.log(this) >+ $(".edit", this).on("click", () => { >+ self.$emit("edit", data) >+ }) >+ $(".remove", this).on("click", () => { >+ self.setWarning( >+ `<h3>${self.$__( >+ "Remove element" >+ )}</h3><p>${self.$__( >+ "Are you sure you want to remove this element?" >+ )}</p>`, >+ () => { >+ self.$emit("remove", data, () => { >+ dt.draw() >+ }) >+ } >+ ) >+ }) >+ }) >+ }) >+ } >+ }, >+ beforeUnmount() { >+ const dt = this.$refs.table.dt() >+ dt.destroy() >+ }, >+ watch: { >+ errorMessage(newError) { >+ this.removeMessages() >+ if (newError) this.setError(newError) >+ }, >+ }, >+ components: { >+ DataTable, >+ }, >+ props: { >+ url: { >+ type: String, >+ default: "", >+ }, >+ columns: { >+ type: Array, >+ default: [], >+ }, >+ edit: { >+ type: Boolean, >+ default: true, >+ }, >+ remove: { >+ type: Boolean, >+ default: true, >+ }, >+ options: { >+ type: Object, >+ default: {}, >+ }, >+ default_filters: { >+ type: Object, >+ required: false, >+ }, >+ }, >+} >+</script> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Menu.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Menu.vue >new file mode 100644 >index 0000000000..5beca4ce68 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Menu.vue >@@ -0,0 +1,57 @@ >+<template> >+ <aside> >+ <div id="navmenu"> >+ <div id="navmenulist"> >+ <h5>{{ $__(title) }}</h5> >+ <ul> >+ <MenuItem >+ v-for="(item, key) in menu" >+ v-bind:key="key" >+ :item="item" >+ ></MenuItem> >+ </ul> >+ </div> >+ </div> >+ </aside> >+</template> >+ >+<script> >+import { inject } from "vue" >+import MenuItem from "./MenuItem.vue" >+export default { >+ name: "Menu", >+ setup: () => { >+ const menuStore = inject("menuStore") >+ const { menu } = menuStore >+ return { >+ menu, >+ } >+ }, >+ props: { >+ title: String, >+ }, >+ components: { >+ MenuItem, >+ }, >+} >+</script> >+ >+<style scoped> >+#navmenulist a.router-link-active { >+ font-weight: 700; >+} >+#menu ul ul, >+#navmenulist ul ul { >+ padding-left: 2em; >+ font-size: 100%; >+} >+ >+#navmenulist ul li a.disabled { >+ color: #666; >+ pointer-events: none; >+ font-weight: 700; >+} >+#navmenulist ul li a.disabled.router-link-active { >+ color: #000; >+} >+</style> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/MenuItem.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/MenuItem.vue >new file mode 100644 >index 0000000000..1705fc53c6 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/MenuItem.vue >@@ -0,0 +1,41 @@ >+<template> >+ <li> >+ <span> >+ <router-link v-if="item.path" :to="item.path"> >+ <i v-if="item.icon" :class="`fa ${item.icon}`"></i> >+ <span v-if="item.title">{{ $__(item.title) }}</span> >+ </router-link> >+ <a v-else-if="item.href" :href="item.href"> >+ <i v-if="item.icon" :class="`fa ${item.icon}`"></i> >+ <span v-if="item.title">{{ $__(item.title) }}</span> >+ </a> >+ <span v-else> >+ <i v-if="item.icon" :class="`fa ${item.icon}`"></i> >+ <span class="item-last" v-if="item.title">{{ >+ $__(item.title) >+ }}</span> >+ </span> >+ </span> >+ <ul v-if="item.children && item.children.length"> >+ <MenuItem >+ v-for="(item, key) in item.children" >+ :item="item" >+ ></MenuItem> >+ </ul> >+ </li> >+</template> >+ >+<script> >+export default { >+ name: "MenuItem", >+ props: { >+ item: Object, >+ }, >+} >+</script> >+ >+<style> >+span.item-last { >+ padding: 7px 3px; >+} >+</style> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Page.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Page.vue >new file mode 100644 >index 0000000000..94bdb93b1f >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/shared/Page.vue >@@ -0,0 +1,48 @@ >+<template> >+ <div :id="id"> >+ <Breadcrumbs :title="title"></Breadcrumbs> >+ <div class="main container-fluid"> >+ <div class="row"> >+ <template v-if="sideMenu"> >+ <div class="col-sm-10 col-sm-push-2"> >+ <Dialog></Dialog> >+ <slot></slot> >+ </div> >+ <div class="col-sm-2 col-sm-pull-10"> >+ <Menu :title="title"></Menu> >+ </div> >+ </template> >+ <template v-else> >+ <div class="col-sm-12"> >+ <Dialog></Dialog> >+ <slot></slot> >+ </div> >+ </template> >+ </div> >+ </div> >+ </div> >+</template> >+ >+<script> >+import Menu from "./Menu.vue" >+import Breadcrumbs from "./Breadcrumbs.vue" >+import Dialog from "./Dialog.vue" >+export default { >+ name: "Page", >+ data: () => ({ >+ sideMenu: true, >+ }), >+ components: { >+ Menu, >+ Dialog, >+ Breadcrumbs, >+ }, >+ props: { >+ id: String, >+ title: String, >+ sideMenu: Boolean, >+ }, >+} >+</script> >+ >+<style></style> >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/i18n/index.js b/koha-tmpl/intranet-tmpl/prog/js/vue/i18n/index.js >new file mode 100644 >index 0000000000..5ba6486e8f >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/i18n/index.js >@@ -0,0 +1,9 @@ >+export const __ = (key) => { >+ return window["__"](key); >+}; >+ >+export const i18n = { >+ install: (app, options) => { >+ app.config.globalProperties.$__ = __ >+ }, >+}; >\ No newline at end of file >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/main.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/main.ts >new file mode 100644 >index 0000000000..7debcf10ce >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/main.ts >@@ -0,0 +1,77 @@ >+import { createApp } from "vue"; >+import { createPinia } from "pinia"; >+import { plugin as formkit, defaultConfig } from '@formkit/vue' >+import '@formkit/themes/genesis' >+import getRouter from './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 { useMenuStore } from '../stores/menu'; >+import { useMainStore } from '../stores/main'; >+import { useTableStore } from '../stores/table'; >+import { useModelStore } from '../stores/model'; >+import tree from './tree'; >+import model from './model'; >+ >+library.add(faPlus, faMinus, faPencil, faTrash, faSpinner); >+ >+const pinia = createPinia(); >+const menuStore = useMenuStore(pinia); >+const mainStore = useMainStore(pinia); >+const tableStore = useTableStore(pinia); >+const modelStore = useModelStore(pinia); >+const { removeMessages } = mainStore; >+const { setTree } = menuStore; >+const { setModel } = modelStore; >+const { setOptions } = tableStore; >+setTree(tree); >+setModel(model); >+setOptions('/api/v1/import_sources') >+ >+const router = getRouter(menuStore); >+ >+import App from "../components/import-sources/ISMain.vue"; >+ >+// import { routes } from "./routes"; >+ >+// const router = createRouter({ >+// history: createWebHistory(), >+// linkExactActiveClass: "current", >+// routes, >+// }); >+ >+// import { useAVStore } from "../stores/authorised_values"; >+ >+import { i18n } from "../i18n" >+import inputs from "../inputs" >+const app = createApp(App); >+ >+const rootComponent = app >+ .use(i18n) >+ .use(pinia) >+ .use(router) >+ .use(formkit, defaultConfig({ >+ inputs >+ })) >+ .component("font-awesome-icon", FontAwesomeIcon) >+ .component("v-select", vSelect); >+ >+app.config.unwrapInjectedRef = true; >+app.provide("mainStore", mainStore); >+app.provide("menuStore", menuStore); >+app.provide("tableStore", tableStore); >+app.provide("modelStore", modelStore); >+app.mount("#import-source"); >+ >+router.beforeEach((to, from) => { >+ menuStore.$patch({current: to.meta.self}); >+ removeMessages(); // This will actually flag the messages as displayed already >+}); >\ No newline at end of file >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/model.js b/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/model.js >new file mode 100644 >index 0000000000..49a461dc4a >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/model.js >@@ -0,0 +1,83 @@ >+import { __ } from "../i18n"; >+ >+export default [ >+ { >+ name: 'import_source_id', >+ table: { >+ data: 'import_source_id', >+ visible: false >+ }, >+ form: { >+ type: 'hidden' >+ } >+ }, >+ { >+ name: 'name', >+ title: __('Source name'), >+ table: { >+ data: 'name', >+ searchable: true >+ }, >+ form: { >+ type: 'text', >+ label: { >+ $ref: 'title' >+ }, >+ validation: 'required' >+ } >+ }, >+ { >+ name: 'patron_id', >+ title: __('Patron'), >+ table: { >+ searchable: true, >+ data: "patron.firstname:patron.surname:patron.middle_name", >+ render: (data, type, row) => { >+ const {firstname, surname, middle_name} = row.patron >+ return `${firstname?firstname:''} ${middle_name?`${middle_name} `:''}${surname?surname:''}`.trim() >+ } >+ }, >+ form: { >+ type: 'autocomplete', >+ label: 'Choose a patron', >+ validation: 'required', >+ optionLoader: async (id) => { >+ const response = await fetch(`/api/v1/patrons/${id}`) >+ if(!response.ok) return null >+ const {firstname, surname, middle_name, patron_id} = await response.json() >+ return {value: patron_id, label: `${firstname?firstname:''} ${middle_name?`${middle_name} `:''}${surname?surname:''}`.trim()} >+ }, >+ options: async (value) => { >+ let query = ''; >+ if(value !== undefined) { >+ query = new URLSearchParams({ >+ q: JSON.stringify([ >+ { >+ firstname: { >+ like: `%${value}%` >+ } >+ }, >+ { >+ middle_name: { >+ like: `%${value}%` >+ } >+ }, >+ { >+ surname: { >+ like: `%${value}%` >+ } >+ } >+ ]) >+ }) >+ } >+ const response = await fetch(`/api/v1/patrons${query?`?${query}`:''}`) >+ if(!response.ok) return [] >+ const rows = await response.json() >+ return rows.map(row => { >+ const {firstname, surname, middle_name, patron_id} = row >+ return {value: patron_id, label: `${firstname?firstname:''} ${middle_name?`${middle_name} `:''}${surname?surname:''}`.trim()} >+ }) >+ } >+ } >+ } >+] >\ No newline at end of file >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/router.js b/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/router.js >new file mode 100644 >index 0000000000..8fd05b5ce5 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/router.js >@@ -0,0 +1,12 @@ >+import { createWebHistory, createRouter } from "vue-router"; >+ >+export default (menuStore) => { >+ >+ const { routes } = menuStore >+ const router = createRouter({ >+ history: createWebHistory(), >+ linkExactActiveClass: "current", >+ routes, >+ }) >+ return router >+} >\ No newline at end of file >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/tree.js b/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/tree.js >new file mode 100644 >index 0000000000..471dc59472 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/import-source/tree.js >@@ -0,0 +1,31 @@ >+import ISEdit from '../components/import-sources/ISEdit.vue' >+import ISList from '../components/import-sources/ISList.vue' >+ >+export default { >+ title: 'Administration', >+ href: '/cgi-bin/koha/admin/admin-home.pl', >+ children: [ >+ { >+ title: 'Import sources', >+ path: '/cgi-bin/koha/admin/import-sources.pl', >+ component: {template: '<router-view></router-view>', name: 'ISBase'}, >+ children: [ >+ { >+ title: 'List', >+ path: '', >+ component: ISList >+ }, >+ { >+ title: 'Add import source', >+ path: 'add', >+ component: ISEdit >+ }, >+ { >+ title: 'Edit import source', >+ path: ':id', >+ component: ISEdit >+ } >+ ] >+ } >+ ] >+} >\ No newline at end of file >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/inputs/autocomplete.js b/koha-tmpl/intranet-tmpl/prog/js/vue/inputs/autocomplete.js >new file mode 100644 >index 0000000000..5333b6e73b >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/inputs/autocomplete.js >@@ -0,0 +1,199 @@ >+import { createInput } from '@formkit/vue' >+ >+/** >+ * This is an input "feature" â a function that accepts a node and exposes >+ * some additional functionality to an input. When using schemas, this can >+ * take the place of a traditional "script" block in a Vue component. In this >+ * example, we expose: >+ * >+ * - An input handler `search`. >+ * - An input handler `selections`. >+ * - Commit middleware to place filtered options into the `matches` prop. >+ * >+ * Once written, input features are added via the input declaration. >+ */ >+const searchFeature = (node) => { >+ >+ const _transform_options = (options) => { >+ if(!Array.isArray(options)) { >+ return Object.keys(options).map((key) => ({label: options[key], value: key})) >+ } >+ return options.map((option) => { >+ if(typeof option === 'string') return {label: option, value: option} >+ return option >+ }) >+ } >+ >+ // We wait for our node to be fully "created" before we start to add our >+ // handlers to ensure the core Vue plugin has added its context object: >+ node.on('created', async () => { >+ // Ensure our matches prop starts as an array. >+ node.props.matches = [] >+ node.props.selection = '' >+ >+ if(node.value !== undefined && node.props.attrs && typeof node.props.attrs.optionLoader === 'function') { >+ node.props.selection = await node.props.attrs.optionLoader(node.value) >+ } >+ >+ const clearValue = async (e) => { >+ if (e && typeof e.preventDefault === 'function') e.preventDefault() >+ node.input('') >+ node.props.matches = [] >+ node.props.selection = '' >+ node.props.searchValue = '' >+ setTimeout(() => { >+ if (document.querySelector('input#' + node.props.id)) { >+ document.querySelector('input#' + node.props.id).focus() >+ } >+ }, 50) >+ } >+ // When we actually have an value to set: >+ const setValue = async (e) => { >+ if (e && typeof e.preventDefault === 'function') e.preventDefault() >+ node.input(node.props.selection.value) >+ node.props.display = node.props.selection.label >+ node.props.searchValue = '' >+ await new Promise((r) => setTimeout(r, 50)) // "next tick" >+ } >+ >+ // Perform a soft selection, this is shown as a highlight in the dropdown >+ const select = (delta) => { >+ const available = node.props.matches >+ let idx = available.indexOf(node.props.selection) + delta >+ if (idx >= available.length) { >+ idx = 0 >+ } else if (idx < 0) { >+ idx = available.length - 1 >+ } >+ node.props.selection = available[idx] >+ } >+ >+ // Add some new "handlers" for our autocomplete. The handlers object is >+ // just a conventionally good place to put event handlers. Auto complete >+ // inputs always have to deal with lots of keyboard events, so that logic >+ // is registered here. >+ Object.assign(node.context.handlers, { >+ setValue, >+ clearValue, >+ selection: (e) => { >+ // This handler is called when entering data into the search input. >+ switch (e.key) { >+ case 'Enter': >+ return setValue() >+ case 'ArrowDown': >+ e.preventDefault() >+ return select(1) >+ case 'ArrowUp': >+ e.preventDefault() >+ return select(-1) >+ case 'Escape': >+ return node.props.matches = [] >+ } >+ }, >+ blur: (e) => { >+ if(!node.props.hovering) node.props.matches = [] >+ }, >+ search(e) { >+ node.props.searchValue = e.target.value >+ }, >+ hover: (e) => { >+ node.props.hovering = true >+ node.props.selection = node.props.matches.find(match => String(match.value) === e.target.attributes.value.value) >+ console.log(node.props.selection) >+ }, >+ unhover: (e) => { >+ if (node.props.matches.find(match => String(match.value) === e.target.attributes.value.value) === node.props.selection) { >+ node.props.selection = '' >+ node.props.hovering = false >+ } >+ }, >+ }) >+ }) >+ >+ // Perform filtering when the search value changes >+ node.on('prop:searchValue', async ({ payload: value }) => { >+ let results >+ if(typeof node.props.options === 'function') { >+ results = _transform_options(await node.props.options(value)) >+ } else { >+ results = _transform_options(node.props.options).filter((option) => >+ option.label.toLowerCase().split(/\W/).find((part).startsWith(value.toLowerCase())) >+ ) >+ } >+ //if (!results.length) results.push('No matches') >+ node.props.matches = results >+ }) >+} >+ >+/** >+ * This is our input schema responsible for rendering the inner âinputâ >+ * section. In our example, we render an text input which will be used >+ * to filter search results, and an unordered list that shows all remaining >+ * matches. >+ */ >+const schema = { >+ if: '$value', >+ then: [ >+ { >+ $el: 'a', >+ attrs: { >+ id: '$id', >+ href: '#', >+ class: '$classes.value', >+ onClick: '$handlers.clearValue', >+ }, >+ children: '$selection.label' >+ } >+ ], >+ else: [ >+ { >+ $el: 'input', >+ bind: '$attrs', >+ attrs: { >+ id: '$id', >+ class: '$classes.input', >+ onKeydown: '$handlers.selection', >+ onInput: '$handlers.search', >+ onBlur: '$handlers.blur', >+ value: '$searchValue', >+ }, >+ }, >+ { >+ $el: 'ul', >+ if: '$matches.length', >+ attrs: { >+ class: '$classes.dropdown', >+ }, >+ children: [ >+ { >+ $el: 'li', >+ for: ['match', '$matches'], >+ attrs: { >+ 'data-selected': { >+ if: '$selection === $match', >+ then: 'true', >+ else: 'false', >+ }, >+ value: '$match.value', >+ class: '$classes.dropdownItem', >+ onClick: '$handlers.setValue', >+ onMouseenter: '$handlers.hover', >+ onMouseleave: '$handlers.unhover', >+ }, >+ children: '$match.label', >+ }, >+ ], >+ }, >+ ], >+} >+ >+/** >+ * Finally we create our actual input declaration by using `createInput` this >+ * places our schema into a "standard" FormKit schema feature set with slots, >+ * labels, help, messages etc. The return value of this function is a proper >+ * input declaration. >+ */ >+export default createInput(schema, { >+ props: ['options', 'matches', 'selection', 'searchValue', 'value'], >+ features: [searchFeature], >+}) >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/inputs/index.js b/koha-tmpl/intranet-tmpl/prog/js/vue/inputs/index.js >new file mode 100644 >index 0000000000..fb70888a28 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/inputs/index.js >@@ -0,0 +1,5 @@ >+import autocomplete from './autocomplete' >+ >+export default { >+ autocomplete >+} >\ No newline at end of file >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/main.js b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/main.js >index 94311f727f..69debd4440 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/main.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/main.js >@@ -2,37 +2,59 @@ import { defineStore } from "pinia"; > > export const useMainStore = defineStore("main", { > state: () => ({ >- message: null, >- error: null, >- warning: null, >+ _message: null, >+ _error: null, >+ _warning: null, >+ _accept: null, > previousMessage: null, > previousError: null, > displayed_already: false, > }), > actions: { >- setMessage(message) { >- this.error = null; >- this.warning = null; >- this.message = message; >- this.displayed_already = false; /* Will be displayed on the next view */ >+ setMessage(message, displayed = false) { >+ this._error = null; >+ this._warning = null; >+ this._message = message; >+ this.displayed_already = displayed; /* Will be displayed on the next view */ > }, >- setError(error) { >- this.error = error; >- this.message = null; >- this.displayed_already = true; /* Is displayed on the current view */ >+ setError(error, displayed = true) { >+ this._error = error; >+ this._message = null; >+ this.displayed_already = displayed; /* Is displayed on the current view */ > }, >- setWarning(warning) { >- this.warning = warning; >- this.message = null; >- this.displayed_already = true; /* Is displayed on the current view */ >+ setWarning(warning, accept, displayed = true) { >+ if(accept) { >+ this._accept = async () => { >+ await accept() >+ this.removeMessages() >+ } >+ } >+ this._warning = warning; >+ this._message = null; >+ this.displayed_already = displayed; /* Is displayed on the current view */ > }, > removeMessages() { > if (this.displayed_already) { >- this.error = null; >- this.warning = null; >- this.message = null; >+ this._accept = null >+ this._error = null; >+ this._warning = null; >+ this._message = null; > } > this.displayed_already = true; > }, > }, >+ getters: { >+ error() { >+ return this._error >+ }, >+ warning() { >+ return this._warning >+ }, >+ message() { >+ return this._message >+ }, >+ accept() { >+ return this._accept >+ } >+ } > }); >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/menu.js b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/menu.js >new file mode 100644 >index 0000000000..93daf6d9ee >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/menu.js >@@ -0,0 +1,100 @@ >+import { defineStore } from "pinia"; >+ >+export const useMenuStore = defineStore("menu", { >+ state: () => ({ >+ tree: { >+ alt: "Home", >+ href: "/cgi-bin/koha/mainpage.pl", >+ is_menu_item: false, >+ is_base: true, >+ children: [] >+ }, >+ current: null, >+ }), >+ actions: { >+ setTree(treeDef) { >+ const _traverse_children = (parent) => { >+ if(parent.children && parent.children.length) { >+ parent.children.forEach(child => { >+ child.parent = parent; >+ if(child.path) { >+ child.meta = { >+ self: child >+ } >+ } >+ if(parent.children.length === 1 && parent.is_base) { >+ child.is_base = true; >+ child.is_menu_item = false; >+ } else if (parent.children.length > 1 || !parent.is_base) { >+ child.is_base = false; >+ } >+ _traverse_children(child) >+ }) >+ } >+ } >+ if(!Array.isArray(treeDef)) { >+ treeDef = [treeDef] >+ } >+ this.tree.children = treeDef >+ _traverse_children(this.tree) >+ } >+ }, >+ getters: { >+ breadcrumbs() { >+ const _traverse_parent = (child) => { >+ let breadcrumbs = [] >+ let builtPath = child.path >+ if(child.parent) { >+ breadcrumbs = _traverse_parent(child.parent) >+ if(builtPath !== undefined && !/^\//.test(builtPath)) { >+ const {path:parentPath} = breadcrumbs[breadcrumbs.length - 1] >+ if(parentPath !== undefined) { >+ builtPath = `${parentPath}${/\/$/.test(parentPath)?'':'/'}${builtPath}` >+ } >+ } >+ } >+ breadcrumbs.push({...child, path: builtPath, children: null}) >+ return breadcrumbs >+ } >+ if(this.current) >+ return _traverse_parent(this.current) >+ >+ const _get_base_elements = (parent) => { >+ if(!parent.is_base) return [] >+ return [{...parent, children: null}, ..._get_base_elements(parent.children[0])] >+ } >+ >+ return _get_base_elements(this.tree) >+ }, >+ menu() { >+ const _get_menu_elements = (parent, builtPath = '') => { >+ if (parent.is_base && parent.children.length === 1) return _get_menu_elements(parent.children[0]) >+ let items = [] >+ if(builtPath) builtPath = `${builtPath}${/\/$/.test(builtPath)?'':'/'}` >+ if(parent.path !== undefined && /^\//.test(parent.path)) { >+ builtPath = parent.path >+ } else if (parent.path !== undefined) { >+ builtPath = `${builtPath}${parent.path}` >+ } else { >+ builtPath = '' >+ } >+ if (parent.children && parent.children.length) { >+ items = parent.children >+ .filter(child => child.is_menu_item) >+ .map(child => _get_menu_elements(child, builtPath)) >+ } >+ if(!parent.is_menu_item) return items >+ return {...parent, path: builtPath ? builtPath : parent.path, children: items} >+ } >+ return _get_menu_elements(this.tree) >+ }, >+ routes() { >+ const _to_route = (parent) => { >+ if(parent.path === undefined) return parent.children.map(child => _to_route(child)).flat(Infinity) >+ return parent >+ } >+ let routes = _to_route(this.tree) >+ return Array.isArray(routes) ? routes : [routes] >+ } >+ } >+}) >\ No newline at end of file >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/model.js b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/model.js >new file mode 100644 >index 0000000000..f0ccd58acd >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/model.js >@@ -0,0 +1,51 @@ >+import { defineStore } from "pinia"; >+ >+export const useModelStore = defineStore("model", { >+ state: () => ({ >+ model: [] >+ }), >+ actions: { >+ setModel(model) { >+ this.model = model >+ }, >+ getFormSchema() { >+ return this.model.map(row => { >+ const {table, form = {}, ...common} = row >+ Object.keys(form).forEach(key => { >+ if (key === 'type') { >+ form.$formkit = form[key] >+ key = '$formkit' >+ delete form.type; >+ } >+ if(form[key].$ref) { >+ const path = form[key].$ref.split('.') >+ form[key] = path.reduce((obj, key, idx) => { >+ if(obj === undefined || obj[key] === undefined) return undefined >+ const value = obj[key] >+ if(idx === path.lenght - 1) delete obj[key] >+ return value >+ }, common) >+ } >+ }) >+ return {...common, ...form} >+ }) >+ }, >+ getTableColumns() { >+ return this.model.map(row => { >+ const {table = {}, form, ...common} = row >+ Object.keys(table).forEach(key => { >+ if(table[key].$ref) { >+ const path = table[key].$ref.split('.') >+ table[key] = path.reduce((obj, key, idx) => { >+ if(obj === undefined || obj[key] === undefined) return undefined >+ const value = obj[key] >+ if(idx === path.lenght - 1) delete obj[key] >+ return value >+ }, common) >+ } >+ }) >+ return {...common, ...table} >+ }) >+ } >+ }, >+}); >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/table.js b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/table.js >new file mode 100644 >index 0000000000..bbc2fba6e5 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/table.js >@@ -0,0 +1,96 @@ >+import { defineStore } from "pinia"; >+ >+import { useMainStore } from "./main" >+import { __ } from "../i18n" >+ >+export const useTableStore = defineStore("table", { >+ state: () => ({ >+ data: [], >+ baseUrl: '' >+ }), >+ actions: { >+ setOptions(url) { >+ this.baseUrl = url.replace(/\/$/, '') >+ }, >+ async add({row, headers}) { >+ const requestHeaders = {...headers, 'Content-Type': 'application/json'} >+ const response = await fetch(this.baseUrl, { >+ headers: requestHeaders, >+ method: 'POST', >+ body: JSON.stringify(row) >+ }) >+ const { status, statusText, ok, headers: responseHeaders } = response >+ let data = null >+ if(ok) { >+ data = await response.text() >+ try { >+ data = JSON.parse(data) >+ } catch(error) {} >+ } >+ return { status, statusText, ok, headers: responseHeaders, data } >+ }, >+ async remove({id, headers}) { >+ const response = await fetch(`${this.baseUrl}/${id}`, { >+ headers, >+ method: 'DELETE' >+ }) >+ const { status, statusText, ok, headers: responseHeaders } = response >+ let data = null >+ if(ok) { >+ data = await response.text() >+ try { >+ data = JSON.parse(data) >+ } catch(error) {} >+ } >+ return { status, statusText, ok, headers: responseHeaders, data } >+ }, >+ async update({id, headers, row}) { >+ const requestHeaders = {...headers, 'Content-Type': 'application/json'} >+ const response = await fetch(`${this.baseUrl}/${id}`, { >+ headers: requestHeaders, >+ method: 'PUT', >+ body: JSON.stringify(row) >+ }) >+ const { status, statusText, ok, headers: responseHeaders } = response >+ let data = null >+ if(ok) { >+ data = await response.text() >+ try { >+ data = JSON.parse(data) >+ } catch(error) {} >+ } >+ return { status, statusText, ok, headers: responseHeaders, data } >+ }, >+ async get({id, headers}) { >+ const response = await fetch(`${this.baseUrl}/${id}`, { >+ headers, >+ }) >+ const { status, statusText, ok, headers: responseHeaders } = response >+ let data = null >+ if(ok) { >+ data = await response.text() >+ try { >+ data = JSON.parse(data) >+ } catch(error) {} >+ } >+ return { status, statusText, ok, headers: responseHeaders, data } >+ }, >+ async list({query=false, headers={}}) { >+ >+ const response = await fetch(`${this.baseUrl}${query?`?${query}`:''}`, { >+ headers, >+ }) >+ >+ const { status, statusText, ok, headers: responseHeaders } = response >+ let data = null >+ if(ok) { >+ data = await response.text() >+ try { >+ data = JSON.parse(data) >+ } catch(error) {} >+ } >+ return { status, statusText, ok, headers: responseHeaders, data } >+ >+ } >+ } >+}) >\ No newline at end of file >diff --git a/package.json b/package.json >index d7e59eec40..3504bad3cc 100644 >--- a/package.json >+++ b/package.json >@@ -9,6 +9,8 @@ > "dependencies": { > "@cypress/vue": "^3.1.1", > "@cypress/webpack-dev-server": "^1.8.3", >+ "@formkit/themes": "^1.0.0-beta.14", >+ "@formkit/vue": "^1.0.0-beta.14", > "@fortawesome/fontawesome-svg-core": "^6.1.0", > "@fortawesome/free-solid-svg-icons": "^6.0.0", > "@fortawesome/vue-fontawesome": "^3.0.0-5", >@@ -18,6 +20,8 @@ > "bootstrap": "^4.5.2", > "css-loader": "^6.6.0", > "cypress": "^9.5.2", >+ "datatables.net": "^1.13.1", >+ "datatables.net-vue3": "^2.0.0", > "gulp": "^4.0.2", > "gulp-autoprefixer": "^4.0.0", > "gulp-concat-po": "^1.0.0", >@@ -60,11 +64,6 @@ > "author": "", > "license": "GPL-3.0", > "devDependencies": { >- "postcss-selector-parser": "^6.0.10", >- "postcss": "^8.4.14", >- "stylelint-config-standard-scss": "^5.0.0", >- "stylelint-order": "^5.0.0", >- "stylelint": "^14.9.1", > "@babel/core": "^7.17.5", > "@babel/preset-env": "^7.16.11", > "@vue/compiler-sfc": "^3.2.31", >@@ -74,6 +73,11 @@ > "clean-webpack-plugin": "^4.0.0", > "gulp-tap": "^1.0.1", > "html-webpack-plugin": "^5.5.0", >+ "postcss": "^8.4.14", >+ "postcss-selector-parser": "^6.0.10", >+ "stylelint": "^14.9.1", >+ "stylelint-config-standard-scss": "^5.0.0", >+ "stylelint-order": "^5.0.0", > "ts-loader": "^9.2.7", > "typescript": "^4.6.2", > "vinyl-source-stream": "^2.0.0", >diff --git a/t/db_dependent/Koha/ImportSources.t b/t/db_dependent/Koha/ImportSources.t >new file mode 100644 >index 0000000000..6788dab4ee >--- /dev/null >+++ b/t/db_dependent/Koha/ImportSources.t >@@ -0,0 +1,44 @@ >+#!/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 Test::More tests => 1; >+ >+use Koha::ImportSources; >+ >+use t::lib::TestBuilder; >+my $schema = Koha::Database->new->schema; >+my $builder = t::lib::TestBuilder->new; >+ >+subtest 'patron' => sub { >+ plan tests => 1; >+ >+ $schema->storage->txn_begin; >+ >+ my $patron = $builder->build_object({class => 'Koha::Patrons', value => {firstname => 'Afirstname'}}); >+ >+ my $import_source = $builder->build_object({class => 'Koha::ImportSources', value => {name => 'import_source', patron_id => $patron->borrowernumber}}); >+ >+ my $fetched_patron = $import_source->patron; >+ >+ is($fetched_patron->firstname, 'Afirstname', 'Patron matches'); >+ >+ $schema->storage->txn_rollback; >+}; >\ No newline at end of file >diff --git a/t/db_dependent/api/v1/import_sources.t b/t/db_dependent/api/v1/import_sources.t >new file mode 100644 >index 0000000000..2175a20754 >--- /dev/null >+++ b/t/db_dependent/api/v1/import_sources.t >@@ -0,0 +1,318 @@ >+#!/usr/bin/env 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 Test::More tests => 5; >+use Test::Mojo; >+use Test::Warn; >+ >+use t::lib::TestBuilder; >+use t::lib::Mocks; >+ >+use C4::Auth; >+use Koha::ImportSources; >+use Koha::Database; >+ >+my $schema = Koha::Database->new->schema; >+my $builder = t::lib::TestBuilder->new; >+ >+t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 ); >+ >+my $t = Test::Mojo->new('Koha::REST::V1'); >+ >+subtest 'list() tests' => sub { >+ >+ plan tests => 12; >+ >+ $schema->storage->txn_begin; >+ >+ my $source = $builder->build_object( >+ { >+ class => 'Koha::ImportSources' >+ } >+ ); >+ my $patron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 3 } >+ } >+ ); >+ >+ for ( 1..10 ) { >+ $builder->build_object( >+ { >+ class => 'Koha::ImportSources' >+ } >+ ); >+ } >+ >+ my $nonprivilegedpatron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 0 } >+ } >+ ); >+ >+ my $password = 'thePassword123'; >+ >+ $nonprivilegedpatron->set_password( >+ { password => $password, skip_validation => 1 } ); >+ my $userid = $nonprivilegedpatron->userid; >+ >+ $t->get_ok( "//$userid:$password@/api/v1/import_sources" ) >+ ->status_is(403) >+ ->json_is( >+ '/error' => 'Authorization failure. Missing required permission(s).' ); >+ >+ $patron->set_password( { password => $password, skip_validation => 1 } ); >+ $userid = $patron->userid; >+ >+ $t->get_ok( "//$userid:$password@/api/v1/import_sources?_per_page=10" ) >+ ->status_is( 200, 'SWAGGER3.2.2' ); >+ >+ my $response_count = scalar @{ $t->tx->res->json }; >+ >+ is( $response_count, 10, 'The API returns 10 sources' ); >+ >+ my $id = $source->import_source_id; >+ $t->get_ok( "//$userid:$password@/api/v1/import_sources?q={\"import_source_id\": $id}" ) >+ ->status_is(200) >+ ->json_is( '' => [ $source->to_api ], 'SWAGGER3.3.2'); >+ >+ $source->delete; >+ >+ $t->get_ok( "//$userid:$password@/api/v1/import_sources?q={\"import_source_id\": $id}" ) >+ ->status_is(200) >+ ->json_is( '' => [] ); >+ >+ $schema->storage->txn_rollback; >+ >+}; >+ >+subtest 'get() tests' => sub { >+ >+ plan tests => 9; >+ >+ $schema->storage->txn_begin; >+ >+ my $source = $builder->build_object( >+ { >+ class => 'Koha::ImportSources' >+ } >+ ); >+ my $patron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 3 } >+ } >+ ); >+ >+ my $nonprivilegedpatron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 0 } >+ } >+ ); >+ >+ my $password = 'thePassword123'; >+ >+ $nonprivilegedpatron->set_password( >+ { password => $password, skip_validation => 1 } ); >+ my $userid = $nonprivilegedpatron->userid; >+ >+ my $id = $source->import_source_id; >+ >+ $t->get_ok( "//$userid:$password@/api/v1/import_sources/$id" ) >+ ->status_is(403) >+ ->json_is( >+ '/error' => 'Authorization failure. Missing required permission(s).' ); >+ >+ $patron->set_password( { password => $password, skip_validation => 1 } ); >+ $userid = $patron->userid; >+ >+ $t->get_ok( "//$userid:$password@/api/v1/import_sources/$id" ) >+ ->status_is( 200, 'SWAGGER3.2.2' ) >+ ->json_is( '' => $source->to_api, 'SWAGGER3.3.2' ); >+ >+ $source->delete; >+ >+ $t->get_ok( "//$userid:$password@/api/v1/import_sources/$id" ) >+ ->status_is(404) >+ ->json_is( '/error' => 'Import source not found' ); >+ >+ $schema->storage->txn_rollback; >+ >+}; >+ >+subtest 'delete() tests' => sub { >+ >+ plan tests => 6; >+ >+ $schema->storage->txn_begin; >+ >+ my $source = $builder->build_object( >+ { >+ class => 'Koha::ImportSources' >+ } >+ ); >+ my $patron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 3 } >+ } >+ ); >+ >+ my $nonprivilegedpatron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 0 } >+ } >+ ); >+ >+ my $password = 'thePassword123'; >+ >+ $nonprivilegedpatron->set_password( >+ { password => $password, skip_validation => 1 } ); >+ my $userid = $nonprivilegedpatron->userid; >+ >+ my $id = $source->import_source_id; >+ >+ $t->delete_ok( "//$userid:$password@/api/v1/import_sources/$id" ) >+ ->status_is(403) >+ ->json_is( >+ '/error' => 'Authorization failure. Missing required permission(s).' ); >+ >+ $patron->set_password( { password => $password, skip_validation => 1 } ); >+ $userid = $patron->userid; >+ >+ $t->delete_ok( "//$userid:$password@/api/v1/import_sources/$id" ) >+ ->status_is( 204, 'SWAGGER3.2.2' ); >+ >+ my $deleted_source = Koha::ImportSources->search({import_source_id => $id}); >+ >+ is($deleted_source->count, 0, 'No import source found'); >+ >+ $schema->storage->txn_rollback; >+ >+}; >+ >+subtest 'add() tests' => sub { >+ >+ plan tests => 8; >+ >+ $schema->storage->txn_begin; >+ >+ Koha::ImportSources->delete; >+ >+ my $patron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 3 } >+ } >+ ); >+ >+ my $nonprivilegedpatron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 0 } >+ } >+ ); >+ >+ my $password = 'thePassword123'; >+ >+ $nonprivilegedpatron->set_password( >+ { password => $password, skip_validation => 1 } ); >+ my $userid = $nonprivilegedpatron->userid; >+ my $patron_id = $nonprivilegedpatron->borrowernumber; >+ >+ $t->post_ok( "//$userid:$password@/api/v1/import_sources" => json => { name => 'test1', patron_id => $patron_id }) >+ ->status_is(403) >+ ->json_is( >+ '/error' => 'Authorization failure. Missing required permission(s).' ); >+ >+ $patron->set_password( { password => $password, skip_validation => 1 } ); >+ $userid = $patron->userid; >+ >+ $t->post_ok( "//$userid:$password@/api/v1/import_sources" => json => { name => 'test1', patron_id => $patron_id }) >+ ->status_is( 201, 'SWAGGER3.2.2' ) >+ ->json_is('/name', 'test1') >+ ->json_is('/patron_id', $patron_id); >+ >+ my $created_source = Koha::ImportSources->search->next; >+ >+ is($created_source->name, 'test1', 'Import source found'); >+ >+ $schema->storage->txn_rollback; >+ >+}; >+ >+subtest 'update() tests' => sub { >+ >+ plan tests => 7; >+ >+ $schema->storage->txn_begin; >+ >+ my $source = $builder->build_object( >+ { >+ class => 'Koha::ImportSources', >+ value => { name => 'Oldname' } >+ } >+ ); >+ my $patron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 3 } >+ } >+ ); >+ >+ my $nonprivilegedpatron = $builder->build_object( >+ { >+ class => 'Koha::Patrons', >+ value => { flags => 0 } >+ } >+ ); >+ >+ my $password = 'thePassword123'; >+ >+ $nonprivilegedpatron->set_password( >+ { password => $password, skip_validation => 1 } ); >+ my $userid = $nonprivilegedpatron->userid; >+ >+ my $id = $source->import_source_id; >+ >+ $t->put_ok( "//$userid:$password@/api/v1/import_sources/$id" => json => { name => 'Newname', patron_id => $source->patron_id } ) >+ ->status_is(403) >+ ->json_is( >+ '/error' => 'Authorization failure. Missing required permission(s).' ); >+ >+ $patron->set_password( { password => $password, skip_validation => 1 } ); >+ $userid = $patron->userid; >+ >+ $t->put_ok( "//$userid:$password@/api/v1/import_sources/$id" => json => { name => 'Newname', patron_id => $source->patron_id } ) >+ ->status_is( 200, 'SWAGGER3.2.2' ) >+ ->json_is('/name', 'Newname'); >+ >+ my $updated_source = Koha::ImportSources->find($id); >+ >+ is($updated_source->name, 'Newname', 'Import source updated'); >+ >+ $schema->storage->txn_rollback; >+ >+}; >\ No newline at end of file >diff --git a/webpack.config.js b/webpack.config.js >index 217dad8db5..30eb52159f 100644 >--- a/webpack.config.js >+++ b/webpack.config.js >@@ -6,6 +6,7 @@ const webpack = require('webpack'); > module.exports = { > entry: { > erm: "./koha-tmpl/intranet-tmpl/prog/js/vue/main-erm.ts", >+ "import-source": "./koha-tmpl/intranet-tmpl/prog/js/vue/import-source/main.ts" > }, > output: { > filename: "[name].js", >-- >2.25.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 32607
:
146410
|
146411
|
146412
|
149777
|
149827
|
149828
|
149829
|
154006
|
154007
|
154008
|
154009
|
154010
|
154438
|
155682
|
155683
|
155684
|
155685
|
155686
|
155958
|
157756
|
157757
|
157758
|
157759
|
157760
|
157761
|
157762
|
161362
|
161363
|
161364
|
161365
|
161366
|
161367
|
161435
|
161436
|
161437
|
161438
|
161439
|
161440
|
161441
|
161474