From b91fabfddb0f9043766e655672fd9cb059ae4a85 Mon Sep 17 00:00:00 2001 From: Jonathan Druart Date: Wed, 23 Feb 2022 10:04:18 +0100 Subject: [PATCH] Bug 30160: Cities view rewrite using React This patch, for discussion, is trying to provide the easier implementation of the cities CRUD operation using React. The react-bootstrap library is used to take profit of the different bootstrap elements. Note that it's for me an initiation at React and I don't have much skills. I've tried to provide the more concise and readable approach, with as less bugs as possible. Feedback welcome, especially if you know React better than me! The bad thing with this approach is that we are loosing the history, clicking browser's back button won't work as before. I don't think we can use the react-router approach as we are not having a full React app. TODO: * There are some UI glitches on the add/edit and delete form (TODO) * Translation strings? Test plan: % yarn install Hit Home > Administration > Cities Add, edit and delete cities Note that if you want to modify the JS code here you will need to run % gulp build_js in order to regenerate the built version. --- admin/cities.pl | 68 ------- gulpfile.js | 20 +- .../prog/en/modules/admin/cities.tt | 191 +----------------- koha-tmpl/intranet-tmpl/prog/js/datatables.js | 8 +- koha-tmpl/intranet-tmpl/prog/js/src/Cities.js | 161 +++++++++++++++ .../prog/js/src/cities/AddCity.js | 121 +++++++++++ .../prog/js/src/cities/CityList.js | 176 ++++++++++++++++ .../prog/js/src/cities/DeleteCity.js | 67 ++++++ koha-tmpl/intranet-tmpl/prog/js/src/index.js | 5 + package.json | 25 ++- 10 files changed, 578 insertions(+), 264 deletions(-) create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/Cities.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/cities/AddCity.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/cities/CityList.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/cities/DeleteCity.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/src/index.js diff --git a/admin/cities.pl b/admin/cities.pl index f16c9d7a50f..9e02c85e607 100755 --- a/admin/cities.pl +++ b/admin/cities.pl @@ -28,9 +28,6 @@ use Koha::Cities; my $input = CGI->new; my $city_name_filter = $input->param('city_name_filter') // q||; -my $cityid = $input->param('cityid'); -my $op = $input->param('op') || 'list'; -my @messages; my ( $template, $loggedinuser, $cookie ) = get_template_and_user( { template_name => "admin/cities.tt", @@ -40,73 +37,8 @@ my ( $template, $loggedinuser, $cookie ) = get_template_and_user( } ); -my $dbh = C4::Context->dbh; -if ( $op eq 'add_form' ) { - my $city; - if ($cityid) { - $city = Koha::Cities->find($cityid); - } - - $template->param( city => $city, ); -} elsif ( $op eq 'add_validate' ) { - my $city_name = $input->param('city_name'); - my $city_state = $input->param('city_state'); - my $city_zipcode = $input->param('city_zipcode'); - my $city_country = $input->param('city_country'); - - if ($cityid) { - my $city = Koha::Cities->find($cityid); - $city->city_name($city_name); - $city->city_state($city_state); - $city->city_zipcode($city_zipcode); - $city->city_country($city_country); - eval { $city->store; }; - if ($@) { - push @messages, { type => 'error', code => 'error_on_update' }; - } else { - push @messages, { type => 'message', code => 'success_on_update' }; - } - } else { - my $city = Koha::City->new( - { city_name => $city_name, - city_state => $city_state, - city_zipcode => $city_zipcode, - city_country => $city_country, - } - ); - eval { $city->store; }; - if ($@) { - push @messages, { type => 'error', code => 'error_on_insert' }; - } else { - push @messages, { type => 'message', code => 'success_on_insert' }; - } - } - $city_name = q||; - $op = 'list'; -} elsif ( $op eq 'delete_confirm' ) { - my $city = Koha::Cities->find($cityid); - $template->param( city => $city, ); -} elsif ( $op eq 'delete_confirmed' ) { - my $city = Koha::Cities->find($cityid); - my $deleted = eval { $city->delete; }; - - if ( $@ or not $deleted ) { - push @messages, { type => 'error', code => 'error_on_delete' }; - } else { - push @messages, { type => 'message', code => 'success_on_delete' }; - } - $op = 'list'; -} - -if ( $op eq 'list' ) { - $template->param( cities_count => Koha::Cities->search->count ); -} - $template->param( - cityid => $cityid, city_name_filter => $city_name_filter, - messages => \@messages, - op => $op, ); output_html_with_http_headers $input, $cookie, $template->output; diff --git a/gulpfile.js b/gulpfile.js index bf1f5150ef8..dc738521f47 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -8,6 +8,9 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const util = require('util'); +const tap = require( "gulp-tap" ); +const browserify = require( "browserify" ); +const babelify = require( "babelify" ); const sass = require("gulp-sass"); const cssnano = require("gulp-cssnano"); @@ -81,8 +84,20 @@ function build() { })) // Append "-rtl" to the filename. .pipe(dest(css_base)); } +} + +function build_js(){ + + let bundler = browserify().transform(babelify, {presets: ["@babel/preset-env", "@babel/preset-react"]}); + + return src( js_base + '/src/index.js' ) + .pipe( tap( file => { + let base_start = file.path.indexOf( js_base ); + bundler.require( [ "react", "react-dom" ] ); + file.contents = bundler.add( file.path ).bundle(); + } ) ) + .pipe( dest( js_base+ "/built" ) ); - return stream; } const poTasks = { @@ -358,6 +373,7 @@ function getLanguages () { } exports.build = build; +exports.build_js = build_js; exports.css = css; exports['po:create'] = parallel(...poTypes.map(type => series(poTasks[type].extract, poTasks[type].create))); @@ -366,4 +382,4 @@ exports['po:extract'] = parallel(...poTypes.map(type => poTasks[type].extract)); exports.default = function () { watch(css_base + "/src/**/*.scss", series('css')); -} +}; diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/cities.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/cities.tt index 2138aac1242..5fc417f3d86 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/cities.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/cities.tt @@ -69,135 +69,7 @@
- -[% FOR m IN messages %] -
- [% SWITCH m.code %] - [% CASE 'error_on_update' %] - An error occurred when updating this city. Perhaps it already exists. - [% CASE 'error_on_insert' %] - An error occurred when adding this city. The city id might already exist. - [% CASE 'error_on_delete' %] - An error occurred when deleting this city. Check the logs. - [% CASE 'success_on_update' %] - City updated successfully. - [% CASE 'success_on_insert' %] - City added successfully. - [% CASE 'success_on_delete' %] - City deleted successfully. - [% CASE 'already_exists' %] - This city already exists. - [% CASE %] - [% m.code | html %] - [% END %] -
-[% END %] - -[% IF op == 'add_form' %] - [% IF city %] -

Modify a city

- [% ELSE %] -

New city

- [% END %] - -
- - - -
-
    - [% IF city %] -
  1. City ID: [% city.cityid | html %]
  2. - [% END %] -
  3. - - Required -
  4. -
  5. - - -
  6. -
  7. - - Required -
  8. -
  9. - - -
  10. -
-
- -
- - Cancel -
-
-[% END %] - -[% IF op == 'delete_confirm' %] -
-

Delete city "[% city.city_name | html %]?"

- - - - - - - - - - - - - - - - -
City id[% city.cityid | html %]
City[% city.city_name | html %]
State[% city.city_state | html %]
ZIP/Postal code[% city.city_zipcode | html %]
Country[% city.city_country | html %]
-
- - - -
-
- -
-
-[% END %] - -[% IF op == 'list' %] - - - -

Cities

- [% IF city_name_filter %] - Searching: [% city_name_filter | html %] - [% END %] - - [% IF cities_count > 0 %] -
- - - - - - - - - - - -
City IDCityStateZIP/Postal codeCountryActions
- [% ELSE %] -
- There are no cities defined. Create a new city. -
- [% END %] -[% END %] - +
@@ -213,10 +85,8 @@ [% INCLUDE 'datatables.inc' %] [% INCLUDE 'columns_settings.inc' %] + [% Asset.js("js/built/index.js") | $raw %] [% END %] [% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/js/datatables.js b/koha-tmpl/intranet-tmpl/prog/js/datatables.js index c95a9723284..99d7d470d72 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/datatables.js +++ b/koha-tmpl/intranet-tmpl/prog/js/datatables.js @@ -505,10 +505,6 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) { $.fn.kohaTable = function(options, columns_settings, add_filters, default_filters) { var settings = null; - if ( add_filters ) { - $(this).find('thead tr').clone(true).appendTo( $(this).find('thead') ); - } - if(options) { if(!options.criteria || ['contains', 'starts_with', 'ends_with', 'exact'].indexOf(options.criteria.toLowerCase()) === -1) options.criteria = 'contains'; options.criteria = options.criteria.toLowerCase(); @@ -769,8 +765,12 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) { if ( add_filters ) { var table_dt = table.DataTable(); + + $(this).find('thead tr').clone().appendTo( $(this).find('thead') ); + $(this).find('thead tr:eq(1) th').each( function (i) { var is_searchable = table_dt.settings()[0].aoColumns[i].bSearchable; + $(this).removeClass('sorting'); if ( is_searchable ) { var title = $(this).text(); var existing_search = table_dt.column(i).search(); diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/Cities.js b/koha-tmpl/intranet-tmpl/prog/js/src/Cities.js new file mode 100644 index 00000000000..70b5c815ee2 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/Cities.js @@ -0,0 +1,161 @@ +import React, { Component } from 'react' +import { Container, Button, Alert } from 'react-bootstrap' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPlus } from '@fortawesome/free-solid-svg-icons' +import CityList from './cities/CityList' +import AddCity from './cities/AddCity' +import DeleteCity from './cities/DeleteCity' + +class App extends Component { + constructor(props) { + super(props) + this.state = { + op: 'list', + error: null, + message: null, + city: {}, + cities: [], + } + } + + onCreate() { + this.setState({ op: 'add', city: {}, error: null, message: null }) + } + + addCity = city => { + let apiUrl + + apiUrl = '/api/v1/cities' + + const myHeaders = new Headers() + myHeaders.append('Content-Type', 'application/json') + + let method = 'POST' + if ( city.city_id ) { + method = 'PUT' + apiUrl += '/' + city.city_id + } + delete city.city_id + + const options = { + method: method, + body: JSON.stringify(city), + myHeaders + } + + fetch(apiUrl, options) + .then(response => { + if ( response.status == 200 ) { + this.setState({ + message: 'City updated', + error: null, + op: 'list', + }) + } else if ( response.status == 201 ) { + this.setState({ + message: 'City created', + error: null, + op: 'list', + }) + } else { + this.setState({ error: response.message }) + } + }, (error) => { + this.setState({ error }) + }) + } + + editCity = city_id => { + + const apiUrl = '/api/v1/cities/' + city_id + + const options = { + method: 'GET', + } + + fetch(apiUrl, options) + .then(res => res.json()) + .then( + (result) => { + this.setState({ + city: result, + op: 'add', + message: null, + error: null, + }) + }, (error) => { + this.setState({ error }) + } + ) + } + + confirmDeleteCity = city_id => { + const apiUrl = '/api/v1/cities/' + city_id + + const options = { + method: 'GET', + } + + fetch(apiUrl, options) + .then(res => res.json()) + .then( + (result) => { + this.setState({ + city: result, + op: 'delete', + message: null, + error: null, + }) + }, (error) => { + this.setState({ error }) + } + ) + } + + deleteCity = city_id => { + + const { cities } = this.state + const apiUrl = '/api/v1/cities/' + city_id + + const options = { + method: 'DELETE', + } + + fetch(apiUrl, options) + .then(response => { + if ( response.status == 204 ) { + this.setState({ + message: 'City deleted', + error: null, + op: 'list', + }) + } else { + this.setState({ error: response.message }) + } + }, (error) => { + this.setState({ error }) + } + ) + } + + cancel = event => { + event.preventDefault() + this.setState({op: 'list'}) + } + + render() { + + return ( +
+ {this.state.op == 'list' &&
} + {this.state.message &&
{this.state.message}
} + {this.state.error &&
Error: {this.state.error.message}
} + {this.state.op == 'list' && } + {this.state.op == 'add' && } + {this.state.op == 'delete' && } +
+ ) + } +} + +export default App diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/cities/AddCity.js b/koha-tmpl/intranet-tmpl/prog/js/src/cities/AddCity.js new file mode 100644 index 00000000000..57205c9c031 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/cities/AddCity.js @@ -0,0 +1,121 @@ +import React from 'react'; +import { Row, Form, Col, Button } from 'react-bootstrap'; + +class AddCity extends React.Component { + constructor(props) { + super(props); + this.initialState = { + city_id: '', + name: '', + state: '', + postal_code: '', + country: '' + } + + if(props.city.city_id){ + this.state = props.city + } else { + this.state = this.initialState; + } + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleCancel = this.handleCancel.bind(this); + } + + handleChange(event) { + const name = event.target.name; + const value = event.target.value; + + this.setState({ + [name]: value + }) + } + + handleSubmit(event) { + event.preventDefault(); + this.props.onFormSubmit(this.state); + } + + handleCancel(event) { + event.preventDefault(); + this.props.onFormCancel(event); + } + + render() { + + let pageTitle; + if(this.state.city_id) { + pageTitle =

Edit city

+ } else { + pageTitle =

New city

+ } + + return( +
+ {pageTitle} +
+
+ + City: + + + Required + + + + State: + + + + + + ZIP/Postal code: + + + Required + + + + Country: + + + + +
+
+ + + + this.handleCancel(e)}>Cancel + +
+
+
+ ) + } +} + +export default AddCity; diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/cities/CityList.js b/koha-tmpl/intranet-tmpl/prog/js/src/cities/CityList.js new file mode 100644 index 00000000000..c68bab36533 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/cities/CityList.js @@ -0,0 +1,176 @@ +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPencil, faTrash } from '@fortawesome/free-solid-svg-icons' +import { Button, Alert } from 'react-bootstrap'; + +class EditButton extends Component{ + constructor(props){ + super(props); + this.handleClick = this.handleClick.bind(this); + } + handleClick(){ + this.props.handleClick(this.props.city_id); + } + + render(){ + return ( + + ) + } +} + +class DeleteButton extends Component{ + constructor(props){ + super(props); + this.handleClick = this.handleClick.bind(this); + } + handleClick(){ + this.props.handleClick(this.props.city_id); + } + + render(){ + return ( + + ) + } +} + +class Table extends Component { + constructor(props){ + super(props); + } + + componentDidMount() { + + const editCity = this.props.editCity; + const confirmDeleteCity = this.props.confirmDeleteCity; + $(this.refs.main).kohaTable({ + "ajax": { + "url": cities_table_url, + }, + "order": [[ 1, "asc" ]], + "columnDefs": [ { + "targets": [0,1,2,3,4], + "render": function (data, type, row, meta) { + if ( type == 'display' ) { + return escape_str(data); + } + return data; + } + } ], + "columns": [ + { + "title": __("City ID"), + "data": "city_id", + "searchable": true, + "orderable": true + }, + { + "title": __("City"), + "data": "name", + "searchable": true, + "orderable": true + }, + { + "title": __("State"), + "data": "state", + "searchable": true, + "orderable": true + }, + { + "title": __("ZIP/Postal code"), + "data": "postal_code", + "searchable": true, + "orderable": true + }, + { + "title": __("Country"), + "data": "country", + "searchable": true, + "orderable": true + }, + { + "title": __("Actions"), + "data": function( row, type, val, meta ) { + return '
'; + }, + "searchable": false, + "orderable": false + } + ], + drawCallback: function(settings){ + $.each($(this).find(".actions"), function(index, e) { + ReactDOM.render(, e); + }); + } + + }, columns_settings, 1); + } + + componentWillUnmount(){ + $('.data-table-wrapper') + .find('table') + .DataTable() + .destroy(true); + } + + shouldComponentUpdate() { + return true; + } + + render() { + return ( +
+ + ); + } +} + +class CityList extends React.Component { + constructor(props) { + super(props); + this.state = { + error: null, + cities: [], + } + + if ( props.cities ){ + this.state.cities = props.cities + } + } + + componentDidMount() { + const apiUrl = '/api/v1/cities'; + + fetch(apiUrl) + .then(res => res.json()) + .then( + (result) => { + this.setState({ + cities: result + }); + }, + (error) => { + this.setState({ error }); + } + ) + } + + render() { + const { error, cities } = this.state; + + if(error) { + return ("Error: {error.message}") + } else if(cities.length) { + return(
+

Cities

+
+ ) + } else { + return(

Cities

There are no cities defined.
) + } + } +} + +export default CityList; diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/cities/DeleteCity.js b/koha-tmpl/intranet-tmpl/prog/js/src/cities/DeleteCity.js new file mode 100644 index 00000000000..b2bb037eded --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/cities/DeleteCity.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { Row, Form, Col, Button } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCheck, faRemove } from '@fortawesome/free-solid-svg-icons' + +class DeleteCity extends React.Component { + constructor(props) { + super(props); + this.state = props.city; + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleCancel = this.handleCancel.bind(this); + } + + handleSubmit(event) { + event.preventDefault(); + this.props.onFormSubmit(this.state.city_id); + } + handleCancel(event) { + event.preventDefault(); + this.props.onFormCancel(event); + } + + render() { + + let pageTitle =

Delete city

+ + return( +
+ + {pageTitle} +
+
+

Delete city "{this.state.name}"?

+ + City id: +
{this.state.city_id} + + + City: + {this.state.name} + + + State: + {this.state.state} + + + ZIP/Postal code: + {this.state.postal_code} + + + Country: + {this.state.country} + + +
+ + +
+ + + + ) + } +} + +export default DeleteCity; diff --git a/koha-tmpl/intranet-tmpl/prog/js/src/index.js b/koha-tmpl/intranet-tmpl/prog/js/src/index.js new file mode 100644 index 00000000000..06f48cac9a8 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/src/index.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './Cities'; + +ReactDOM.render(, document.getElementById('cities_container')); diff --git a/package.json b/package.json index ac7ff7bcd78..0dc659a0b70 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,18 @@ "test": "test" }, "dependencies": { + "@babel/core": "^7.0.0-beta.3", + "@babel/preset-env": "^7.0.0-beta.3", + "@babel/preset-react": "^7.0.0-beta.3", + "@fortawesome/fontawesome-svg-core": "^1.3.0", + "@fortawesome/free-regular-svg-icons": "^6.0.0", + "@fortawesome/free-solid-svg-icons": "^6.0.0", + "@fortawesome/react-fontawesome": "^0.1.17", + "@types/react-dom": "^17.0.11", + "babel-core": "^7.0.0-beta.3", + "babelify": "^10.0.0", "bootstrap": "^4.5.2", + "browserify": "^17.0.0", "gulp": "^4.0.2", "gulp-autoprefixer": "^4.0.0", "gulp-concat-po": "^1.0.0", @@ -17,10 +28,17 @@ "gulp-rtlcss": "^1.4.1", "gulp-sass": "^3.1.0", "gulp-sourcemaps": "^2.6.1", + "gulp-tap": "^1.0.1", "js-yaml": "^3.13.1", "lodash": "^4.17.12", "merge-stream": "^2.0.0", - "minimist": "^1.2.5" + "minimist": "^1.2.5", + "react": "^17.0.2", + "react-bootstrap": "^2.1.2", + "react-dom": "^17.0.2", + "react-router": "^6.2.1", + "react-router-dom": "^6.2.1", + "react-script": "^2.0.5" }, "scripts": { "build": "node_modules/.bin/gulp build", @@ -37,5 +55,8 @@ "js-yaml": "^3.13.1" }, "author": "", - "license": "GPL-3.0" + "license": "GPL-3.0", + "devDependencies": { + "@types/react": "^17.0.39" + } } -- 2.25.1