diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..1f0809a21 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +**NOTE**: (PR title should be of the form ticket:short description, e.g. TICKET-123: Add more templates) + +**JIRA ticket**: [TICKET-123](https://link.to.jira) + +**Summary**: + +A brief description of what this pull request aims to achieve + +**Changes**: +- List changes made in this pull request + +**To test**: +- List actions that should be taken to test that functionality works as expected diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..709c7f49c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: Build and Test SynchWeb + +on: + push: + branches: + - 'master' + - 'prerelease' + - 'release/**' + - 'pre-release/**' + pull_request: + branches: + - 'master' + - 'prerelease' + - 'release/**' + - 'pre-release/**' + +defaults: + run: + shell: bash + working-directory: ./api + +# Note, jobs do not share the same working environment, whereas steps do. Also, jobs will run in parallel unless the 'needs' tag is used to flag a dependency +jobs: + php-build: + name: Checkout, build, test and lint PHP code + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP 7.3 + uses: shivammathur/setup-php@v2 + with: + php-version: 7.3 + tools: psalm:4 + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: PHPUnit Tests + uses: php-actions/phpunit@v3 + env: + XDEBUG_MODE: coverage + with: + bootstrap: api/vendor/autoload.php + configuration: api/tests/phpunit.xml + php_extensions: xdebug mysqli zip + args: --coverage-text + php_version: 7.3 + version: 9 + + - name: Run Psalm + run: psalm --output-format=github + + js_build: + name: JavaScript build, test and lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Use Node.js 18 + uses: actions/setup-node@v3 + with: + node-version: 18.x + - name: JavaScript build, lint and test + working-directory: ./client + # hack the output from the linting steps to avoid these stopping the builds - we are not going to get + # to a clean output without considerable effort, but it's useful to see the output + run: | + node --version + npm ci + npm run build + npm run test + npm run lint || exit 0 + npm run lint-vue || exit 0 diff --git a/.github/workflows/php_ci.yml b/.github/workflows/php_ci.yml deleted file mode 100644 index 6c5272354..000000000 --- a/.github/workflows/php_ci.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Build and Test PHP - -on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - -defaults: - run: - shell: bash - working-directory: api - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 5.4 - - - name: Validate composer.json and composer.lock - run: composer validate - - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v3 - with: - path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php- - - - name: Install dependencies - run: composer install --prefer-dist --no-progress - - phplint: - needs: build - runs-on: ubuntu-latest - steps: - - name: Psalm Linting - uses: docker://vimeo/psalm-github-actions - with: - security_analysis: true - - phptest: - needs: build - runs-on: ubuntu-latest - steps: - - name: PHPUnit Tests - uses: php-actions/phpunit@v2 - with: - bootstrap: vendor/autoload.php - configuration: test/phpunit.xml - args: --coverage-text diff --git a/.gitignore b/.gitignore index 28b7ff542..ba6e91405 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,11 @@ client/.env api/config.php api/vendor - - +api/tests/.phpunit.result.cache +api/tests/_coverage +api/src/MockUAS.php + +entrypoint.bash +php-fpm.conf +php-fpm.pid +php.ini diff --git a/README.md b/README.md index 9c5aa8fd3..d0164f719 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,43 @@ The client includes some newer components written in Vue.js Read More: https://diamondlightsource.github.io/SynchWeb/ ## Installation -Running SynchWeb requires setting up a Linux, Apache, MariaDB and PHP (LAMP) software stack. If running in production you should configure your Apache and PHP to serve secure pages only. The steps below describe how to build the software so it is ready to deploy onto your target server. - -For development, a simple environment can be setup by using scripts provided [here](https://github.com/DiamondLightSource/synchweb-devel-env). They are not intended for production use but include scripts to automatically build and deploy the software on a local VM. +Running SynchWeb requires setting up a Linux, Apache, MariaDB and PHP (LAMP) software stack. If running in production you should configure your Apache and PHP to serve secure pages only. The steps below describe how to build the software so it is ready to deploy onto your target server. +The [podman](./podman) folder provides support for creating a containerised +production deployment. Full instructions [here](./podman/README.md). + +For development (not production use), a simple environment can be setup by using scripts provided +[here](https://github.com/DiamondLightSource/synchweb-devel-env). Support is provided for both +containerisation and the use of VMs. VS Code provides a good development environment for working +with the SynchWeb codebase. PHP Tools extension provides intellisense, debugging, formatting, +linting and support for unit tests. Vetur and Volar extensions provide support for working with +the Vue.js code. They are not intended for production use but include scripts to automatically + build and deploy the software on a local VM or in a Podman container. ### Requirements -To build SynchWeb on a machine you will need [npm](https://docs.npmjs.com/) and [composer](https://getcomposer.org/) +If not using the Podman containter, to build SynchWeb on a machine you will need the following on the build machine: + +- [npm](https://docs.npmjs.com/) +- [composer](https://getcomposer.org/) +- appropriate version of PHP on the build machine + +If not using the development VMs you will also need an instance of the +ISPyB database - available +[here](https://github.com/DiamondLightSource/ispyb-database). -You will also need php5 on the build machine. +If not already installed, you must install the following packages: -If not using the development VMs you will also need an instance of the ISPyB database [here](https://github.com/DiamondLightSource/ispyb-database) +``` +php php-mysqlnd php-mbstring php-xml php-gd php-fpm php-cli php-xdebug +``` ### Check out the code ```sh $ git clone https://github.com/DiamondLightSource/SynchWeb ``` + ### Customise front end - config.json -An example configuration is provided in client/src/js/config_sample.json -This file should be copied to create a client/src/js/config.json file and edited to customise the application for your site. +An example configuration is provided in `client/src/js/config_sample.json` +This file should be copied to create a `client/src/js/config.json` file and edited to customise the application for your site. | Parameter | Description | | ------ | ------ | @@ -51,26 +70,58 @@ $ npm run build ``` ### Customise back end - config.php -An example configuration is provided in api/config_sample.php +An example configuration is provided in `api/config_sample.php`. This should be copied to +`api/config.php` and updated to include appropriate configuration details. + Main items to change include: - database connection parameters (user, password, host, port) - authentication type (cas, ldap, dummy/no authentication) -### Build backend end +### Build backend ```sh $ cd SynchWeb/api $ composer install ``` -### Developing the client application +Note, the front and backend are built automatically in the Podman deployment. + +### Run backend tests +Tests are available for the PHP code under `api/tests`. To run these, go to the `api` directory and use: + +```sh +$ cd SynchWeb/api +$ ./vendor/bin/phpunit --verbose -c tests/phpunit.xml +``` +Note, a single test can be run by specifying that instead of the `tests` directory. Tests +will also produce a coverage report - this can be disabled by specifying `--no-coverage` when +running the tests. + +### Debugging back end tests +It is possible to debug the php tests. Install xdebug and using an IDE such as VS Code. You +can then start the debugger in the IDE and put break points in the code. Running the tests +(from the command line or within VS Code) will trigger the debugger and execution will be +halted on break points or specified error types. + +### Run front end tests for Vue.js +Testing on the front end is restricted to the newer Vue.js code as it is +anticipated that the older code will eventually be migrated to this form. +To run these tests, + +```sh +$ cd SynchWeb/client +$ npm run test +``` + +## Developing the client application It is possible to run the client code on a local machine and connect to an existing SynchWeb installation on a server. -The steps required are to build the front end code and then run a webpack dev server to host the client code. +The steps required are to build the front end code and then run a webpack dev server to host the client code. Note, this is possible for both the +Podman and VM approach detailed [here](https://github.com/DiamondLightSource/synchweb-devel-env). ```sh $ cd SynchWeb/client $ npm run build:dev -$ npm run serve -- --env.port=8080 --env.proxy.target=http://192.168.33.10 +$ npm run serve -- --env port=8080 --env.proxy.target=http://localhost:8082 ``` -In this example a browser pointed at localhost:8080 will connect to a SynchWeb back end on 192.168.33.10. Don't ignore the middle '--' otherwise the dev server will not receive the arguments! +In this example a browser pointed at localhost:9000 will connect to a SynchWeb back end on localhost:8082. Don't ignore the middle '--' otherwise the dev server will not receive the arguments! The command line options available are described in this table. These override the defaults set in webpack.config.js. @@ -78,14 +129,49 @@ The command line options available are described in this table. These override t | ------ | ------ | | env.port | Webpack dev server port | | env.proxy.target | Full address of the SynchWeb PHP backend server (can include port if required) | -| env.proxy.secure | Flag to set if connecting to an https address for the SynchWeb backend | - - -Acknowledgements +| env.proxy.secure | Flag to set if connecting to an https address for the SynchWeb backend. Setting to `false` can also help with self-signed SSL certs (which may be insecure so should not be used in production). | + +## Continuous Integration +Basic CI is included via the GitHub workflows functionality, defined by +`.github/workflows/ci.yml`. Currently this will run whenever a branch change or +pull request is pushed to `master`, `pre-release` or `release`. The workflow will run two parallel jobs: + +* Checkout the SynchWeb code - for the PHP build + 1. Install the correct version of PHP + 1. Validate the `composer.json` file + 1. Set up a cache for the composer dependencies + 1. Install the required composer dependencies + 1. Run the PHP unit tests - using PHPUnit + 1. Run linting against the PHP code, using PSalm +* Checkout the SynchWeb code - for the JavaScript build + 1. Install npm dependencies (using `ci` mode) + 1. Build the JavaScript bundle + 1. Run Vue unit tests + 1. Run basic JavaScript linting + 1. Run Vue linting + +Note, currently the workflows will not fail if linting errors or warnings are +encountered - this is to enable an initial period of tidying to be enacted. Once +the code is in a suitable state, the rules should be tightened to prevent changes +that introduce new issues. + +## Work in Progress +The codebase is currently subject to some degree of refactoring. The front end is being gradually +migrated away from its Backbone/Marionette origins to use Vue.js instead. Additionally, the +PHP back end is being updated to have a more structured form - breaking down the Page monolith +classes into a more layered architecture - with data layer services under the `Model` folder, and +controller/service classes under the `Controllers` folder. The intention here is to isolate data +access code in a separate layer to allow a more formal API to be identified and to decouple the code +to simplify testing and maintenance. The `Controllers` code currently combines what could be +further split into separate controller and service classes if this was deemed worthwhile (e.g. to +facilitate code reuse). Dependency injection is being introduced (see `index.php`) using the Slim +framework. This could potentially be simplified if common conventions are introduced (e.g. similar +to those used in `Dispatch.php` for setting up the original routes). Once more formal APIs are +identified, it may make sense to introduce proper interfaces to codify these. Swagger-like tools +can then be used to improve documentation and testing of exposed web APIs. + +### Acknowledgements ---------------- If you make use of code from this repository, please reference: Fisher et al., J. Appl. Cryst. (2015). 48, 927-932, doi:10.1107/S1600576715004847 https://journals.iucr.org/j/issues/2015/03/00/fs5101/index.html - - - diff --git a/api/assets/emails/dewar-dispatch.html b/api/assets/emails/dewar-dispatch.html index e01fbf6a8..9fc0c2a2b 100644 --- a/api/assets/emails/dewar-dispatch.html +++ b/api/assets/emails/dewar-dispatch.html @@ -38,7 +38,7 @@ Courier account number: -Courer airway bill number: +Courier air waybill number: Collection date diff --git a/api/assets/emails/dewar-transfer.html b/api/assets/emails/dewar-transfer.html index 7d930256d..099ed9587 100644 --- a/api/assets/emails/dewar-transfer.html +++ b/api/assets/emails/dewar-transfer.html @@ -5,16 +5,7 @@ Dear local contact: , -Your user: requested an internal transfer for his/her dewar(s). Please print this e-mail and affix it on the shipping case and put the dewar for the visit on the rack outside the beamline: "to be Stored at Diamond" by the . - -Local contact for next visit: - - -Dear EHC, - -Please collect the dewar from visit (Dewar Code: ) currently stored on the rack outside and transfer it to the rack in the MX storage room -The dewar should be ready for internal transfer by the - +Your user: from visit requested an internal transfer for his/her dewar(s). Please print this e-mail and affix it on the shipping case. Information for the transfer: @@ -42,14 +33,11 @@ Current dewar location -New Location in storage room - - -Pickup date: - - Local contact email (in case of problems finding the dewar): +Local contact for next visit: + + Comments: diff --git a/api/assets/emails/html/dewar-dispatch.html b/api/assets/emails/html/dewar-dispatch.html index 03c656617..d32821102 100644 --- a/api/assets/emails/html/dewar-dispatch.html +++ b/api/assets/emails/html/dewar-dispatch.html @@ -7,6 +7,21 @@
requested an internal transfer for his/her dewar(s). Please print this e-mail and affix it on the shipping case and put the dewar for the visit on the rack outside the beamline: "to be Stored at Diamond" by the
-Local contact for next visit:
+from visit requested an internal transfer for his/her dewar(s). Please print this e-mail and affix it on the shipping case.
-Please collect the dewar from visit (Dewar Code: ) currently stored on the rack outside and transfer it to the rack in the MX storage room
-The dewar should be ready for internal transfer by the
-New Location | -+ | New Local Contact | +||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Full Name | @@ -61,12 +53,7 @@ | Lab / Company Name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Pickup Date | -- | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comments | diff --git a/api/assets/pdf/shipment_label.php b/api/assets/pdf/shipment_label.php index 48c1aafb5..4fb7a630a 100644 --- a/api/assets/pdf/shipment_label.php +++ b/api/assets/pdf/shipment_label.php @@ -93,18 +93,14 @@ |
{{header.title}} | -{{actions}} | + @click="$emit('sort-by', header.key)" + > + {{ header.title }} + ++ {{ actions }} + | @@ -39,30 +45,47 @@ TODO - move relevant styles to this component style section
---|---|---|
{{ getRowData(row, header) }} | + > + {{ getRowData(row, header) }} +
+ |
|
{{noDataText}} | ++ {{ noDataText }} + |
{{message}}
++ {{ message }} +
+ Successfully logged out. You might not be logged out of your SSO provider yet. + Log out of it too? +
+Go to home
++ {{ messageType.length }} {{ iconMappings[messageTypeIndex].iconMessage }} +
+ ++ {{ message['MESSAGE'] }} +
+This page lists the visits available to the currently selected proposal
'), - regions: { 'wrap': '.wrapper' }, - - templateHelpers: function() { - return { - showTitle: this.getOption('showTitle') - } - }, - - initialize: function(options) { - var columns = [{ name: 'ST', label: 'Start', cell: 'string', editable: false }, - { name: 'EN', label: 'End', cell: 'string', editable: false }, - { name: 'VIS', label: 'Number', cell: 'string', editable: false }, - { name: 'BL', label: 'Beamline', cell: 'string', editable: false }, - { name: 'LC', label: 'Local Contact', cell: 'string', editable: false }, - { name: 'COMMENTS', label: 'Comments', cell: 'string', editable: true }, - { name: 'DCCOUNT', label: 'Data Collections', cell: 'string', editable: false }, - { name: 'SESSIONTYPE', label: 'Type', cell: 'string', editable: false }, - { name: 'LINKS', label: '', cell: this.getOption('linksCell'), template: this.getOption('linksTemplate'), test: 'DCCOUNT', editable: false }, - { name: 'ARCHIVED', label: '', cell: ArchivedCell, editable: false }] - - if (app.mobile()) { - console.log('mobile!') - _.each([1,4,5,6], function(v) { - columns[v].renderable = false - }) - } - - var bgopts = { emptyText: 'No visits found' } - if (this.getOption('clickable')) bgopts.row = this.getOption('clickableRow') - - this.listenTo(this.collection, 'change:COMMENTS', this.saveComment, this) - - this.table = new TableView({ collection: options.collection, columns: columns, filter: 's', search: options.params.s, tableClass: 'proposals', loading: true, backgrid: bgopts }) - }, - - - saveComment: function(m, v) { - console.log('model changed', arguments) - m.save(m.changedAttributes(), { patch: true }) - }, - - onRender: function() { - this.wrap.show(this.table) - } - }) - -}) \ No newline at end of file diff --git a/client/src/js/modules/samples/components/ContainerGraphic.vue b/client/src/js/modules/samples/components/ContainerGraphic.vue new file mode 100644 index 000000000..e5ae7850e --- /dev/null +++ b/client/src/js/modules/samples/components/ContainerGraphic.vue @@ -0,0 +1,120 @@ + +Container Geometry: {{ JSON.stringify(container) }}
+ +This page allows you to add all sample information for one or more samples in a single transaction
-+ This page allows you to add all sample information for one or more samples in a single transaction +
+Select a shipment to see all associated containers
+Shipment
+Select a container to see the samples viewer.
+No multiplex job for this sample group at this time.
+Select a field and enter a value to bulk populate in all samples
++ Select a field and enter a value to bulk populate in all samples +
Click on the button to save changes
++ Click on the button to save changes +
Sample Groups
- +{{ sample['NAME'] }}
Components
++ Components +
Add:
++ Add: +
No Components
No Components
+{{ containerComponent }}
+{{ containerGraphicHeader }}
+Please fix the errors on the form
-{{error[0]}}
++ Please fix the errors on the form +
++ {{ error[0] }} +
View Schedule
This page shows the contents of the selected container. Samples can be added and edited by clicking the pencil icon, and removed by clicking the x
- - - -+ This page shows the contents of the selected container. Samples can be added and edited by clicking the pencil icon, and removed by clicking the x +
+ + + +Please fix the errors on the form
-{{error[0]}}
++ Please fix the errors on the form +
++ {{ error[0] }} +
Queue Container?
This procesing job failed: <%-PROCESSINGMESSAGE%>
+This processing job failed: <%-PROCESSINGMESSAGE%>
<% } else { %> diff --git a/client/src/js/templates/dc/dc_title.html b/client/src/js/templates/dc/dc_title.html index 117ffe0d2..1d6422b1a 100644 --- a/client/src/js/templates/dc/dc_title.html +++ b/client/src/js/templates/dc/dc_title.html @@ -9,13 +9,17 @@ <%-DCCC%> Comment(s) - <%-DCAC%> + <% if (RECIP == "1") { %> + <%-DCAC%> + <% } else { %> + <%-DCAC%> + <% } %> - <% if (!IS_VISIT) { %>[<%-VIS_LINK%>]<% } %> <%-ST%> - <%-DIR%><%-FILETEMPLATE%> + <% if (!IS_VISIT) { %>[<%-VIS_LINK%>]<% } %> <%-STA %> - <%-DIR%><%-FILETEMPLATE%> <% if (ARCHIVED == "1") { %> - <% } %> \ No newline at end of file + <% } %> diff --git a/client/src/js/templates/dc/dclist.html b/client/src/js/templates/dc/dclist.html index bcbc6e0cf..ba1a31e3f 100644 --- a/client/src/js/templates/dc/dclist.html +++ b/client/src/js/templates/dc/dclist.html @@ -15,9 +15,13 @@This procesing job failed: <%-PROCESS.PROCESSINGMESSAGE%>
+This processing job failed: <%-PROCESS.PROCESSINGMESSAGE%>
diff --git a/client/src/js/templates/dc/grid.html b/client/src/js/templates/dc/grid.html index ad91449ea..4232c7392 100644 --- a/client/src/js/templates/dc/grid.html +++ b/client/src/js/templates/dc/grid.html @@ -13,7 +13,7 @@- Select data sets to reintegrate by selecting a number of images in the DISTL plot. Provide unit cell parameters, space group, and high resolution cut off as needed. + Select data sets to reintegrate by selecting a number of images in the per-image analysis plot. Provide unit cell parameters, space group, and high resolution cut off as needed.
Use this page to reintegrate multiple data collections together
-Select data sets to integrate by dragging accross the DISTL plot below. This selects which images of the data set to integrate
+Select data sets to integrate by dragging accross the per-image analysis plot below. This selects which images of the data set to integrate
This page shows details for the selected protein and a list of samples which make use of it
<% if (EXTERNAL == 1) { %> - Clone Protein + Clone Protein <% } %>This is a clone of a protein
@@ -15,9 +15,9 @@This page shows details and contents of the selected shipment. Most parameters can be edited by simply clicking on them.
Shipments need to have an outgoing and return home lab contact before shipment labels can be printed
<% if (DHL_ENABLE) { %> <% if (DELIVERYAGENT_HAS_LABEL == '1') { %> - + <% } else if (COUNTRY && COUNTRY !="United Kingdom" ) { %> <% } else { %> - + <% } %> <% } %> <% if (LCOUT && LCRET) { %> />
From this page you can view diffraction /assetsimages for each data collection, crystal snapshots, and DISTL plots. You can search through data collections using the search box on the right and filter data collections by type using the list of filters on the top
+From this page you can view diffraction /assetsimages for each data collection, crystal snapshots, and per-image analysis plots. You can search through data collections using the search box on the right and filter data collections by type using the list of filters on the top
Auto processing results from the Fast DP and XIA2 pipelines can be displayed by clicking the "Auto Processing" header. "Downstream Processing" shows results from the Fast EP and DIMPLE pipelines
@@ -23,4 +23,4 @@MCA spectra are also displayed along with peaks identified automatically via AutoPyMCA
'+text+'' + if (mimeType.indexOf('text/plain') > -1) text = '
'+escapeHTMLTags(text)+'' doc.open() doc.write(sh+text) @@ -89,12 +113,13 @@ define(['marionette', 'views/dialog', 'utils'], function(Marionette, DialogView, xhr.send() }, - + onRender: function() { this.$el.append(this.iframe) this.$el.find('iframe').css('width', $(window).width()*(app.mobile() ? 0.8 : 0.5)) - this.$el.find('iframe').css('height', $(window).width()*(app.mobile() ? 0.8 : 0.5)) + this.$el.find('iframe').css('height', $(window).height()*(app.mobile() ? 0.8 : 0.5)) + } }) diff --git a/client/src/js/views/search.js b/client/src/js/views/search.js index 788c06d2e..db89d1453 100644 --- a/client/src/js/views/search.js +++ b/client/src/js/views/search.js @@ -1,6 +1,44 @@ define(['backbone'], function(Backbone) { var Search = Backbone.View.extend({ + /** @property */ + events: { + "keyup input[type=search]": "search", + "click a[data-backgrid-action=clear]": "clear", + "submit": "search" + }, + + /** + @param {Object} options + @param {Backbone.Collection} options.collection + @param {string} [options.name] + @param {string} [options.value] + @param {string} [options.placeholder] + @param {function(Object): string} [options.template] + */ + initialize: function (options) { + Search.__super__.initialize.apply(this, arguments); + this.name = options.name || this.name; + this.value = options.value || this.value; + this.placeholder = options.placeholder || this.placeholder + this.template = options.template || this.template; + + //this.url = options.url || this.url + if (options.url == false) this.url = false + this.urlFragment = options.urlFragment || this.urlFragment + + // Persist the query on pagination + var collection = this.collection, self = this; + if (Backbone.PageableCollection && + collection instanceof Backbone.PageableCollection && + collection.mode == "server") { + collection.queryParams[this.name] = function () { + return self.searchBox().val() || null; + }; + } + this.search = _.debounce(this.search, 400) + }, + /** @property */ tagName: 'div', @@ -12,13 +50,6 @@ define(['backbone'], function(Backbone) { return '' }, - /** @property */ - events: { - "keyup input[type=search]": "search", - "click a[data-backgrid-action=clear]": "clear", - "submit": "search" - }, - /** @property {string} [name='q'] Query key */ name: "s", @@ -33,37 +64,6 @@ define(['backbone'], function(Backbone) { url: true, urlFragment: 's', - - /** - @param {Object} options - @param {Backbone.Collection} options.collection - @param {string} [options.name] - @param {string} [options.value] - @param {string} [options.placeholder] - @param {function(Object): string} [options.template] - */ - initialize: function (options) { - Search.__super__.initialize.apply(this, arguments); - this.name = options.name || this.name; - this.value = options.value || this.value; - this.placeholder = options.placeholder || this.placeholder - this.template = options.template || this.template; - - //this.url = options.url || this.url - if (options.url == false) this.url = false - this.urlFragment = options.urlFragment || this.urlFragment - - // Persist the query on pagination - var collection = this.collection, self = this; - if (Backbone.PageableCollection && - collection instanceof Backbone.PageableCollection && - collection.mode == "server") { - collection.queryParams[this.name] = function () { - return self.searchBox().val() || null; - }; - } - this.search = _.debounce(this.search, 400) - }, /** Event handler. Clear the search box and reset the internal search value. @@ -132,11 +132,9 @@ define(['backbone'], function(Backbone) { } else collection.fetch({data: data, reset: true}); if (this.url) { - if (this.urlFragment) { - var url = window.location.pathname.replace(new RegExp('\\/'+this.urlFragment+'\\/(\\w|-)+'), '')+(this.value ? '/'+this.urlFragment+'/'+this.value : '') - } else { - var url = window.location.pathname.replace(/\/\w+$/, '')+(this.value ? '/'+this.value : '') - } + var url = this.urlFragment ? + window.location.pathname.replace(new RegExp('\\/'+this.urlFragment+'\\/(\\w|-)+'), '')+(this.value ? '/'+this.urlFragment+'/'+this.value : '') : + window.location.pathname.replace(/\/\w+$/, '')+(this.value ? '/'+this.value : '') window.history.pushState({}, '', url) } }, diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 8f383345a..bff83ce6f 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -38,7 +38,7 @@ module.exports = { 'content-header': ['Droid Sans'], 'icon': ['FontAwesome'], 'fixed': ["Courier"], - }, + }, // Add a tiny font size for breadcrumbs etc. fontSize: { 'xxs': '0.65rem', @@ -53,7 +53,7 @@ module.exports = { // '4xl': '2.25rem', // '5xl': '3rem', // '6xl': '4rem', - }, + }, width: { '1/7': '14.2857143%', '2/7': '28.5714286%', @@ -165,7 +165,27 @@ module.exports = { 'screened': '#fdfd96', 'grid-scanned': '#fdfd96', 'loaded-by-robot': '#ff6961', - 'dark-amber': '#d9bf98' + 'dark-amber': '#d9bf98', + +// Sample Groups Color Theme + 'sample-group-added-light': '#f4e7Ba', + 'sample-group-added-dark': '#e6daae', +// Data Collections messages color + 'info-color': { + default: '#00ff00', + lighter: '#ccffcc', + darker: '#003300' + }, + 'alert-color': { + default: '#ffa500', + lighter: '#ffb733', + darker: '#332100' + }, + 'warning-color': { + default: '#ff0000', + lighter: '#ff4d4d', + darker: '#330000' + }, }, zIndex: { '75': 75, diff --git a/client/tests/unit/js/components/login.spec.js b/client/tests/unit/js/components/login.spec.js new file mode 100644 index 000000000..1e8b7fd2d --- /dev/null +++ b/client/tests/unit/js/components/login.spec.js @@ -0,0 +1,89 @@ +import Vuex from "vuex"; +import { expect, jest, test } from "@jest/globals"; + +import { shallowMount, createLocalVue } from "@vue/test-utils"; +import Login from "../../../../src/js/app/views/login.vue"; + +const originalLocation = window.location; + +const mockToken = jest.fn(); + +const mockRouter = { + push: jest.fn(), +}; + +describe("Login", () => { + const localVue = createLocalVue(); + localVue.use(Vuex); + localVue.prototype.$store = new Vuex.Store({ + getters: { + sso: () => true, + apiUrl: () => "authUrl.co.uk", + }, + actions: { + "auth/checkAuth": () => new Promise((resolve) => { + resolve(false) + }), + "auth/getToken": () => new Promise((resolve) => { + resolve(true) + }), + }, + }); + + beforeAll(() => { + Object.defineProperty(window, "location", { + value: { ...originalLocation, assign: jest.fn(), href: "http://localhost/login" }, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(window, "location", { value: originalLocation, configurable: true }); + }); + + it("should redirect to SSO login when unauthorised", async () => { + const wrapper = shallowMount(Login, { + localVue, + }); + + await wrapper.vm.$nextTick(); + + expect(window.location.assign).toBeCalledWith("authUrl.co.uk/authenticate/authorise"); + }); + + it("should not redirect if code is in URL", async () => { + const mockPush = jest.fn(); + window.location.assign = jest.fn(); + window.location.href = "http://localhost/login?code=testcode"; + + const wrapper = shallowMount(Login, { + mocks: { + $router: { + push: mockPush, + }, + }, + localVue, + }); + + await wrapper.vm.$nextTick(); + + expect(window.location.assign).not.toBeCalled(); + expect(mockPush).toBeCalled(); + }); + + it("should display login form if SSO is disabled", () => { + const store = new Vuex.Store({ + getters: { + sso: () => false, + }, + }); + + const wrapper = shallowMount(Login, { + store, + localVue, + }); + + expect(wrapper.find('[data-testid="username"]')).toBeTruthy(); + expect(wrapper.find('[data-testid="password"]')).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/client/tests/unit/js/components/motd.spec.js b/client/tests/unit/js/components/motd.spec.js new file mode 100644 index 000000000..ffbaa32e8 --- /dev/null +++ b/client/tests/unit/js/components/motd.spec.js @@ -0,0 +1,65 @@ +import Vuex from "vuex"; +import { shallowMount, createLocalVue } from "@vue/test-utils"; +import motd from "../../../../src/js/app/components/motd.vue"; +import { expect } from "@jest/globals"; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +function setupComponent(msg) { + const wrapper = shallowMount(motd, { + propsData: { message: msg }, + }); + return { wrapper, msg }; +} + +describe("motd", () => { + it("displays nothing if message not set", () => { + const wrapper = shallowMount(motd); + expect(wrapper.findAll("div").length).toBe(1); + expect(wrapper.findAll("p").length).toBe(0); + expect(wrapper.text()).toBe(""); + }); + + it("displays nothing if message is set to empty string", () => { + const { wrapper, msg } = setupComponent(""); + expect(wrapper.findAll("div").length).toBe(1); + expect(wrapper.findAll("p").length).toBe(0); + expect(wrapper.text()).toBe(msg); + }); + + it("displays set message correctly", () => { + const { wrapper, msg } = setupComponent("This is the message of the day..."); + expect(wrapper.findAll("p").length).toBe(1); + expect(wrapper.findAll("p").at(0).text()).toBe(msg); + }); + + it("stores set message correctly in state", () => { + const { wrapper, msg } = setupComponent("This is the message of the day..."); + expect(wrapper.text()).toBe(msg); + }); + + it("closes when closed is true", async () => { + const { wrapper, msg } = setupComponent("This is the message of the day..."); + wrapper.setData({ closed: true }); + expect(wrapper.vm.closed).toBe(true); + await wrapper.vm.$nextTick(); + expect(wrapper.findAll("div").length).toBe(1); + expect(wrapper.findAll("p").length).toBe(0); + }); + + it("initially set to not closed", () => { + const { wrapper } = setupComponent("This is the message of the day..."); + expect(wrapper.vm.closed).toBe(false); + }); + + it("closes when clicked", async () => { + const { wrapper, msg } = setupComponent("This is the message of the day..."); + expect(wrapper.findAll("p").at(0).text()).toBe(msg); + const input = wrapper.find("i"); + input.trigger("click"); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.closed).toBe(true); + expect(wrapper.findAll("p").length).toBe(0); + }); +}); \ No newline at end of file diff --git a/client/tests/unit/js/components/sidebar.spec.js b/client/tests/unit/js/components/sidebar.spec.js new file mode 100644 index 000000000..5ff8f0718 --- /dev/null +++ b/client/tests/unit/js/components/sidebar.spec.js @@ -0,0 +1,78 @@ +import Vuex from "vuex"; +import Vue from "vue"; +import VueRouter from "vue-router"; +import { expect } from "@jest/globals"; + +import { shallowMount, createLocalVue } from "@vue/test-utils"; +import Sidebar from "../../../../src/js/app/components/sidebar.vue"; + +describe("Sidebar", () => { + const localVue = createLocalVue(); + localVue.use(Vuex); + localVue.use(VueRouter); + localVue.prototype.$store = new Vuex.Store({ + getters: { + "auth/isLoggedIn": () => { + return false; + }, + }, + }); + + it("showMenu initially false", () => { + const wrapper = shallowMount(Sidebar, { + localVue, + }); + expect(wrapper.vm.showMenu).toBe(false); + }); + + it("isLoggedIn initially false (via mock)", () => { + // This is an example testing the isLoggedIn function in the component which is calling into the store and uses the mocked getter above for the store + const wrapper = shallowMount(Sidebar, { + localVue, + }); + expect(wrapper.vm.isLoggedIn).toBe(false); + }); + + it("isProposalClosed returns true if proposal is Closed", () => { + localVue.prototype.$store.getters["proposal/currentProposalState"] = "Closed"; + const wrapper = shallowMount(Sidebar, { + localVue, + }); + expect(wrapper.vm.isProposalClosed).toBe(true); + }); + + it("isProposalClosed returns false if proposal is not Closed", () => { + localVue.prototype.$store.getters["proposal/currentProposalState"] = "anything else"; + const wrapper = shallowMount(Sidebar, { + localVue, + }); + expect(wrapper.vm.isProposalClosed).toBe(false); + }); + + it("extras initially empty without proposal", () => { + const wrapper = shallowMount(Sidebar, { + localVue, + }); + expect(wrapper.vm.extras).toEqual([]); + }); + + it("extras show data if proposal available", async () => { + localVue.prototype.$store.getters["proposal/currentProposal"] = "proposal xyz"; + const extras = new Array({ name: 1, link: "/aa" }); + const wrapper = shallowMount(Sidebar, { + propsData: { extrasMenu: extras }, + localVue, + }); + expect(wrapper.vm.extras).toBe(extras); + }); + + it("extras show data if proposal available and copes with invalidly formatted data", async () => { + localVue.prototype.$store.getters["proposal/currentProposal"] = "proposal xyz"; + const extras = new Array(1, 2, 3); + const wrapper = shallowMount(Sidebar, { + propsData: { extrasMenu: extras }, + localVue, + }); + expect(wrapper.vm.extras).toBe(extras); + }); +}); \ No newline at end of file diff --git a/client/webpack.config.js b/client/webpack.config.js index 55a38b2e2..5eae381d8 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -1,8 +1,6 @@ const path = require('path'); const webpack = require("webpack"); const childProcess = require('child_process') -// As of v3.0.3 GitRevisionPlugin does not work with MiniCssExtractPlugin -// const GitRevisionPlugin = require('git-revision-webpack-plugin') const HtmlWebpackPlugin = require('html-webpack-plugin') const CopyPlugin = require('copy-webpack-plugin'); const VueLoaderPlugin = require('vue-loader/lib/plugin') @@ -13,7 +11,7 @@ const config = require('./src/js/config.json') module.exports = (env, argv) => ({ entry: { - main: './src/js/app/index.js', + main: './src/js/app/index.js', }, output: { filename: '[name]-bundle.js', @@ -21,6 +19,7 @@ module.exports = (env, argv) => ({ publicPath: path.join('/dist', gitHash, '/'), }, devServer: { + static: __dirname, host: (env && env.host) || 'localhost', port: (env && env.port) || 9000, https: true, @@ -50,11 +49,14 @@ module.exports = (env, argv) => ({ }, }, resolve: { + fallback: { + util: require.resolve("util/") + }, alias: { marionette: 'backbone.marionette/lib/backbone.marionette.min', 'jquery.touchswipe': 'jquery-touchswipe', - 'jquery-ui.timepicker': 'jquery-ui-timepicker-addon', // Need to update timepicker css to avoid showing microseconds/milliseconds + 'jquery-ui.timepicker': 'jquery-ui-timepicker-addon', // Need to update timepicker css to avoid showing microseconds/milliseconds // Jquery-ui-combox is based on an extension from npm // The original was based on a collection of extensions: (https://github.com/bseth99/jquery-ui-extensions) // Currently using a modified version from npm @@ -63,17 +65,17 @@ module.exports = (env, argv) => ({ // Jquery.flot provided by NPM package (exact name match) // Jquery.flot.resize also from NPM but slightly older version 1.0.0 2012 instead of 2013 (vendor lib) - 'jquery.flot.resize': 'jquery-flot-resize', + 'jquery.flot.resize': 'jquery-flot-resize', 'jquery.flot.pie': 'flot-pie', 'jquery.flot.time': 'vendor/flot/jquery.flot.time.min', 'jquery.flot.selection': 'vendor/flot/jquery.flot.selection', 'jquery.flot.stack': 'vendor/flot/jquery.flot.stack', - // Jquery flot tooltip is provided ny NPM with exact name match, - // so not aliased here, was: 'vendor/flot/jquery.flot.tooltip', + // Jquery flot tooltip is provided ny NPM with exact name match, + // so not aliased here, was: 'vendor/flot/jquery.flot.tooltip', 'jquery.flot.tickrotor': 'vendor/flot/jquery.flot.tickrotor', 'jquery.flot.axislabels': 'flot-axislabels', - + // We can't currently use the magnific-popup from npm e.g.: // 'jquery.mp': 'magnific-popup', // The vendor library has been modified to append proposal to the request @@ -96,7 +98,7 @@ module.exports = (env, argv) => ({ // heatmap in npm has dependency on canvas/node-gyp... so use old one for now heatmap: 'vendor/hmap', - + // gunzip is actually the zlib library // https://npm.taobao.org/package/zlibjs gzip: 'zlibjs/bin/gunzip.min', @@ -125,112 +127,116 @@ module.exports = (env, argv) => ({ }, module: { rules: [ - { - test: /\.html$/, - use: [ - { - loader: 'underscore-template-loader', - options: { - engine: 'underscore', + // uncomment to include linting as automatic part of building webpack bundle + // { + // enforce: 'pre', + // test: /\.(js|vue)$/, + // loader: 'eslint-loader', + // exclude: /node_modules/ + // }, + { + test: /\.vue$/, + use: [ + { + loader: 'vue-loader', + } + ] + }, + { + test: /\.html$/, + use: [ + { + loader: 'underscore-template-loader', + options: { + engine: 'underscore', + } } - } - ], - exclude: [ - path.resolve(__dirname, 'src/js/templates/vue') - ] - }, - { - test: /\.xml$/, - use: [ - { - loader: 'raw-loader', - } - ] - }, - // Font loader - url should be relative to entry main.scss file - { - test: /\.(woff|woff2|eot|ttf|otf)$/, - use: { - loader: 'file-loader', - options: { - name: '[name].[ext]', - outputPath: '../../assets/fonts', // output path is relative to main module outputPath - publicPath: '/assets/fonts' - } - } - }, - // SVG could be images or fonts so use more explicit test here... - { - test: /font-awesome[\\\/].+\.(svg)$/, - use: { - loader: 'file-loader', - options: { - name: '[name].[ext]', - outputPath: '../../assets/fonts', - publicPath: '/assets/fonts' - } - } - }, - { - test: /\.vue$/, - use: [ - { - loader: 'vue-loader', - } - ] - }, - { - test: /templates[\\\/]vue[\\\/].+\.html$/, - use: ['html-loader'] - }, - // We need to help Caman load properly - // Caman adds to the window object within a browser - // The import loader ensures it it recognised as browser env not NodeJS - { - test: /caman\.min\.js$/, - use: "imports-loader?exports=>undefined,require=>false,this=>window" - }, - { - test: /\.(sa|sc|c)ss$/, - use: [ - // Extract the CSS into separate files - { - loader: MiniCssExtractPlugin.loader, - options: { - hmr: true, - reloadAll: true, + ], + exclude: [ + path.resolve(__dirname, 'src/js/templates/vue') + ] + }, + { + test: /\.xml$/, + use: [ + { + loader: 'raw-loader', + } + ] + }, + // Font loader - url should be relative to entry main.scss file + { + test: /\.(woff|woff2|eot|ttf|otf)$/, + use: { + loader: 'file-loader', + options: { + name: '[name].[ext]', + outputPath: '../../assets/fonts', // output path is relative to main module outputPath + publicPath: '/assets/fonts' + } } - }, - "css-loader", // translates CSS into CommonJS - "postcss-loader", - ] - }, - { - test: /\.(png|gif)$/, - use: [ - { - loader: 'url-loader', - options: { - limit: 4096, // Anything less than this limit is inlined - name: '[path][name].[ext]', - outputPath: '../../assets', - publicPath: '/assets', - context: 'src', + }, + // SVG could be images or fonts so use more explicit test here... + { + test: /font-awesome[\\\/].+\.(svg)$/, + use: { + loader: 'file-loader', + options: { + name: '[name].[ext]', + outputPath: '../../assets/fonts', + publicPath: '/assets/fonts' + } } - } - ] - } + }, + { + test: /templates[\\\/]vue[\\\/].+\.html$/, + use: ['html-loader'] + }, + // We need to help Caman load properly + // Caman adds to the window object within a browser + // The import loader ensures it it recognised as browser env not NodeJS + { + test: /caman\.min\.js$/, + use: "imports-loader?exports=>undefined,require=>false,this=>window" + }, + { + test: /\.(sa|sc|c)ss$/, + use: [ + // Extract the CSS into separate files + { + loader: MiniCssExtractPlugin.loader, + options: { } + }, + "css-loader", // translates CSS into CommonJS + "postcss-loader", + ] + }, + { + test: /\.(png|gif)$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 4096, // Anything less than this limit is inlined + name: '[path][name].[ext]', + outputPath: '../../assets', + publicPath: '/assets', + context: 'src', + } + } + ] + } ] }, plugins: [ new webpack.HotModuleReplacementPlugin(), new webpack.ProvidePlugin({ - $: "jquery", - jQuery: "jquery", - _: "underscore", - "window.jQuery": "jquery", - Highcharts: "highmaps" + $: "jquery", + jQuery: "jquery", + _: "underscore", + "window.jQuery": "jquery", + Highcharts: "highmaps" }), // This generates a short (8 char) git hash used for build paths // new GitRevisionPlugin({ @@ -260,24 +266,35 @@ module.exports = (env, argv) => ({ // Anything matching in the from path is copied so images/file.png => assets/images/file.png // Also copy jquery to assets dir, so we can use it for Dialog popup with log files (see js/views/log.js) // Also copy config.json to assets dir, app uses the assets/js/config.json to tell if client needs updating - new CopyPlugin([ - { context: path.resolve(__dirname, 'src'), - from: 'images/**', - to: path.resolve(__dirname, 'assets') }, - { context: path.resolve(__dirname, 'src'), - from: 'js/config.json', - to: path.resolve(__dirname, 'assets/js/') }, - { context: path.resolve(__dirname, 'src'), - from: 'js/vendor/jquery/jquery-1.9.1.min.js', - to: path.resolve(__dirname, 'assets/js/') }, - { context: path.resolve(__dirname, 'src'), - from: 'files/**', - to: path.resolve(__dirname, 'assets') } - ]), + new CopyPlugin({ + patterns: [ + { + context: path.resolve(__dirname, 'src'), + from: 'images/**', + to: path.resolve(__dirname, 'assets') + }, + { + context: path.resolve(__dirname, 'src'), + from: 'js/config.json', + to: path.resolve(__dirname, 'assets/js/') + }, + { + context: path.resolve(__dirname, 'src'), + from: 'js/vendor/jquery/jquery-1.9.1.min.js', + to: path.resolve(__dirname, 'assets/js/') + }, + { + context: path.resolve(__dirname, 'src'), + from: 'files/**', + to: path.resolve(__dirname, 'assets') + } + ] + }), new VueLoaderPlugin(), new MiniCssExtractPlugin({ filename: '[name].css', chunkFilename: '[id].css', + ignoreOrder: true, // see https://stackoverflow.com/questions/51971857/mini-css-extract-plugin-warning-in-chunk-chunkname-mini-css-extract-plugin-con/67579319#67579319 }), // Allow use to use process.env.NODE_ENV in the build // NODE_ENV should be set in scripts for production builds diff --git a/docs/index.html b/docs/index.html index a53518791..06700b7bd 100644 --- a/docs/index.html +++ b/docs/index.html @@ -221,7 +221,7 @@