diff --git a/cypress.config.js b/cypress.config.js index 4a7bf4470..cfbeb6b88 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -3,6 +3,7 @@ const config = require('./config'); const webpackPreprocessor = require('@cypress/webpack-preprocessor') module.exports = defineConfig({ + defaultCommandTimeout: 6000, projectId: '2npvgh', video: false, e2e: { diff --git a/cypress/e2e/download.cy.js b/cypress/e2e/download.cy.js index 397a161e4..11de924fd 100644 --- a/cypress/e2e/download.cy.js +++ b/cypress/e2e/download.cy.js @@ -28,10 +28,9 @@ describe('download functionality', function () { 'profile': 'driving-car', 'preference': 'recommended' }) - // TODO: extend download tests. Aliasing the readFile with as(..) doesn't work - // cy.get('@response').its('places[0].placeName').should('eq', 'Mannheim, BW,Germany') - // cy.get('@response').its('mode').should('eq', 'directions') - // cy.get('@response').its('isRouteData').should('eq', 'directions') + cy.readFile(filePath).its('places[0].placeName').should('eq', 'Mannheim, BW,Germany') + cy.readFile(filePath).its('mode').should('eq', 'directions') + cy.readFile(filePath).its('isRouteData').should('eq', true) }) it('downloads a geojson file', () => { const filePath = downloadFile('GeoJSON', 'json') diff --git a/cypress/e2e/optimization.cy.js b/cypress/e2e/optimization.cy.js new file mode 100644 index 000000000..c6bbdf69f --- /dev/null +++ b/cypress/e2e/optimization.cy.js @@ -0,0 +1,188 @@ + +describe('Optimization component', () => { + context('loads route from URL link', () => { + it('shows optimization page and features correctly', () => { + viewPage('/#/optimize/49.419614285204595,8.688426017761232/data/' + + '{"coordinates":"8.688426017761232,49.419614285204595",' + + '"options":{"center":{"lat":49.41743941444882,"lng":8.681871455062602},"zoom":18,' + + '"vehicles":[{"id":1,"start":[8.678770065307619,49.4197817871778],"end":[8.678770065307619,49.4197817871778],' + + '"profile":"driving-car","time_window":[45000,50420],"capacity":[5],' + + '"skills":[1]}],' + + '"jobProps":[{"id":1,"skills":[1],"service":3600,"delivery":[1],"pickup":[1]}]}}') + + // shows the map view correctly + cy.get('[data-cy="place-search"]').should('not.exist') + cy.get('.view-on-ors').should('not.exist') + cy.get('.v-snack__content') + cy.get('.ors-toolbar').should('not.be.visible') + cy.get('.leaflet-control-layers').should('be.visible') + cy.get('.leaflet-control-zoom').should('be.visible') + cy.get('.leaflet-draw').should('be.visible') + cy.get('#polyline-measure-control').should('be.visible') + cy.get('.my-location-btn').should('be.visible') + + // shows the sidebar correctly + cy.get('[data-cy="sidebar-header"]') + cy.get('[data-cy="sidebar-content"]').should('be.visible') + cy.get('[data-cy="route-details"]').should('be.visible') + cy.get('[data-cy="job-heading"]').should('be.visible') + cy.get('[data-cy="job-inputs"]').should('be.visible') + cy.get('[data-cy="vehicle-heading"]').should('be.visible') + cy.get('[data-cy="vehicle-inputs"]').should('be.visible') + + // shows routes correctly + cy.get('[data-cy=optimization-routes]').as('routes') + cy.get('@routes').should('have.length', 1) + cy.get('@routes').contains('Distance') + cy.get('@routes').contains('Duration') + cy.get('@routes').contains('Service time') + cy.get('@routes').contains('Deliveries') + cy.get('@routes').contains('Pickups') + cy.get('.route-details').should('have.length', 1) + cy.get('.step').should('have.length', 3) + + // shows buttons + cy.get('[data-cy="manage-skills"]').should('be.visible') + cy.get('[data-cy="add-place-input"]').should('be.not.visible') + cy.get('[data-cy="round-trip"]').should('be.not.visible') + cy.get('[data-cy="route-importer"]').should('be.not.visible') + }) + + it('shows job correctly', () => { + viewPage('/#/optimize/49.419614285204595,8.688426017761232/data/' + + '{"coordinates":"8.688426017761232,49.419614285204595",' + + '"options":{"center":{"lat":49.41743941444882,"lng":8.681871455062602},"zoom":18,' + + '"vehicles":[{"id":1,"start":[8.678770065307619,49.4197817871778],"end":[8.678770065307619,49.4197817871778],' + + '"profile":"driving-car","time_window":[45000,50420],"capacity":[5],' + + '"skills":[1]}],' + + '"jobProps":[{"id":1,"skills":[1],"service":3600,"delivery":[1],"pickup":[1]}]}}') + + cy.get('[data-cy="manage-jobs"]').should('be.visible') + cy.get('[data-cy="hide-jobs"]').as('hide').should('be.visible') + cy.get('[data-cy="job-inputs"]').should('have.length', 1) + + cy.get('[data-cy="job-list"]').as('jobs').should('be.visible') + cy.get('@jobs').contains('Job 1').should('be.visible') + cy.get('@jobs').contains('8.6884260, 49.419614').should('be.visible') + cy.get('@jobs').contains('Deliveries').should('be.not.visible') + cy.get('@jobs').contains('Pickups').should('be.not.visible') + cy.get('@jobs').contains('Skills').should('be.not.visible') + cy.get('@jobs').contains('Service time').should('be.not.visible') + //expand job + cy.get('@jobs').click() + cy.get('@jobs').contains('Deliveries').should('be.visible') + cy.get('@jobs').contains('Pickups').should('be.visible') + cy.get('@jobs').contains('Skills').should('be.visible') + cy.get('@jobs').contains('Service time').should('be.visible') + + //hide job correctly + cy.get('@hide').click() + cy.get('[data-cy="hidden-jobs"]').contains('Saved Jobs: 1') + }) + + it('shows vehicle correctly', () => { + viewPage('/#/optimize/49.419614285204595,8.688426017761232/data/' + + '{"coordinates":"8.688426017761232,49.419614285204595",' + + '"options":{"center":{"lat":49.41743941444882,"lng":8.681871455062602},"zoom":18,' + + '"vehicles":[{"id":1,"start":[8.678770065307619,49.4197817871778],"end":[8.678770065307619,49.4197817871778],' + + '"profile":"driving-car","time_window":[45000,50420],"capacity":[5],' + + '"skills":[1]}],' + + '"jobProps":[{"id":1,"skills":[1],"service":3600,"delivery":[1],"pickup":[1]}]}}') + + cy.get('[data-cy="manage-vehicles"]').should('be.visible') + cy.get('[data-cy="vehicle-inputs"]').should('have.length', 1) + + cy.get('[data-cy="vehicle-list"]').as('vehicles').should('be.visible') + cy.get('@vehicles').contains('Vehicle 1') + cy.get('@vehicles').contains('driving-car') + cy.get('@vehicles').contains('Capacity') + cy.get('@vehicles').contains('Skills') + cy.get('@vehicles').contains('Time window') + }) + }) + + context('opens edit dialog', () => { + it('shows manageJobs and features correctly', () => { + viewPage('/#/optimize/49.419614285204595,8.688426017761232/data/' + + '{"coordinates":"8.688426017761232,49.419614285204595",' + + '"options":{"center":{"lat":49.41743941444882,"lng":8.681871455062602},"zoom":18,' + + '"vehicles":[{"id":1,"start":[8.678770065307619,49.4197817871778],"end":[8.678770065307619,49.4197817871778],' + + '"profile":"driving-car","time_window":[45000,50420],"capacity":[5],' + + '"skills":[1]}],' + + '"jobProps":[{"id":1,"skills":[1],"service":3600,"delivery":[1],"pickup":[1]}]}}') + + cy.get('[data-cy="manage-jobs"]').click() + + // shows dialog and card content correctly + cy.get('.edit-header-btn') + cy.get('.download-container') + cy.get('[data-cy="dataCards"]').as('dataCards').should('have.length', 1) + cy.get('[data-cy="cardText"]').as('cardText') + + cy.get('@dataCards').contains('edit').should('be.visible') + cy.get('@dataCards').contains('copy').should('be.visible') + cy.get('@dataCards').contains('delete').should('be.visible') + + cy.get('@cardText').contains('Service time').should('be.visible') + cy.get('@cardText').contains('Skills').should('be.visible') + cy.get('@cardText').contains('Deliveries').should('be.visible') + cy.get('@cardText').contains('Pickups').should('be.visible') + + //expand card and change job + cy.get('@cardText').click() + cy.get('@dataCards').contains('check').should('be.visible') + + cy.get('[data-cy="delivery"]').clear() + .type('2') + cy.get('[data-cy="pickup"]').clear() + .type('2') + cy.get('[data-cy="service"]').clear() + .type('0') + cy.get('@cardText').contains('drop_down').click() + cy.contains('settings').should('be.visible') + cy.contains('check_box').click() + + // close dialog with x and check if job has not changed + cy.get('[data-cy="edit-dialog"]').contains('close').click() + cy.get('[data-cy="job-list"]').as('jobs').click() + cy.get('@jobs').contains('Job 1').should('be.visible') + cy.get('@jobs').contains('8.6884260, 49.419614').should('be.visible') + cy.get('@jobs').contains('Deliveries: 1').should('be.visible') + cy.get('@jobs').contains('Pickups: 1').should('be.visible') + cy.get('@jobs').contains('Skills: 1').should('be.visible') + cy.get('@jobs').contains('Service time: 1').should('be.visible') + }) + + it('picks place from map', () => { + viewPage('/#/optimize/49.419614285204595,8.688426017761232/data/' + + '{"coordinates":"8.688426017761232,49.419614285204595",' + + '"options":{"center":{"lat":49.41743941444882,"lng":8.681871455062602},"zoom":18,' + + '"vehicles":[{"id":1,"start":[8.678770065307619,49.4197817871778],"end":[8.678770065307619,49.4197817871778],' + + '"profile":"driving-car","time_window":[45000,50420],"capacity":[5],' + + '"skills":[1]}],' + + '"jobProps":[{"id":1,"skills":[1],"service":3600,"delivery":[1],"pickup":[1]}]}}') + + cy.get('[data-cy="manage-jobs"]').click() + cy.get('[data-cy="cardText"]').click() + + cy.contains('search').click() + cy.get('[data-cy="location-input"]').should('be.visible') + cy.contains('map').should('be.visible') + .click() + + cy.get('[data-cy="edit-dialog"]').should('not.exist') + + cy.get('#map-view').click() + cy.get('[data-cy=save]').click() + cy.get('[data-cy="job-list"]').contains('Job 1') + }) + }) + function viewPage(url) { + cy.visit(url) + cy.viewport(1848, 980) + cy.get('#app') + cy.get('.app-content') + cy.get('#map-view') + cy.get('[data-cy="sidebar"]') + } +}) diff --git a/src/config-examples/app-config-example.js b/src/config-examples/app-config-example.js index ba25bcb96..083ac6198 100755 --- a/src/config-examples/app-config-example.js +++ b/src/config-examples/app-config-example.js @@ -36,10 +36,12 @@ const appConfig = { autoSelectFirstExactAddressMatchOnSearchEnter: true, // If the first exact address match must be auto selected when the user type a text and in the place search and hit enter/return + disabledActionsForOptimization: ['addPlaceInput', 'roundtrip', 'routeImporter'], // Possible values: `addPlaceInput`, `clearPlaces`, `reverseRoute`, `roundtrip`, `routeImporter` disabledActionsForIsochrones: ['roundtrip'], // Possible values: `addPlaceInput`, `clearPlaces`, `reverseRoute`, `roundtrip`, `routeImporter` disabledActionsForPlacesAndDirections: [], // // Possible values: `addPlaceInput`, `clearPlaces`, `reverseRoute`, `roundtrip`, `routeImporter` supportsPlacesAndDirections: true, // If the whole places and directions feature is supported/enabled in the application supportsIsochrones: true, // If isochrones is supported/enabled in the application + supportsOptimization: true, // If optimization is enabled supportsMapFiltersOnSidebar: true, // if the filters options box is present/enabled in the app supportsDirections: true, // If the directions functionality is available sidebarStartsOpenInHighResolution: false, // if the sidebar must start open in high resolution diff --git a/src/fragments/forms/map-form/MapForm.vue b/src/fragments/forms/map-form/MapForm.vue index 1ffd5e84f..a68cee3ca 100644 --- a/src/fragments/forms/map-form/MapForm.vue +++ b/src/fragments/forms/map-form/MapForm.vue @@ -7,14 +7,20 @@ {{$t('mapForm.isochrones')}} + + {{$t('mapForm.optimization')}} + - + + + + diff --git a/src/fragments/forms/map-form/components/download/Download.vue b/src/fragments/forms/map-form/components/download/Download.vue index fcdabe019..28c252181 100644 --- a/src/fragments/forms/map-form/components/download/Download.vue +++ b/src/fragments/forms/map-form/components/download/Download.vue @@ -1,9 +1,16 @@ + diff --git a/src/fragments/forms/map-form/components/download/download.css b/src/fragments/forms/map-form/components/download/download.css new file mode 100644 index 000000000..ebea18155 --- /dev/null +++ b/src/fragments/forms/map-form/components/download/download.css @@ -0,0 +1,8 @@ +.edit-btn { + padding: 0 20px; + min-width: 0; + float: right; + margin: 0; + height: 24px; + background: white; +} diff --git a/src/fragments/forms/map-form/components/download/download.js b/src/fragments/forms/map-form/components/download/download.js index aab6e5bc4..4a7e8bd22 100644 --- a/src/fragments/forms/map-form/components/download/download.js +++ b/src/fragments/forms/map-form/components/download/download.js @@ -1,6 +1,8 @@ import OrsParamsParser from '@/support/map-data-services/ors-params-parser' import {Directions} from '@/support/ors-api-runner' import MapViewData from '@/models/map-view-data' +import Job from '@/models/job' +import Vehicle from '@/models/vehicle' import constants from '@/resources/constants' import toKml from '@maphubs/tokml' import toGpx from 'togpx' @@ -15,12 +17,20 @@ export default { props: { mapViewData: { Type: MapViewData, - Required: true + Required: false + }, + data: { + Type: Array, + Required: false + }, + editProp: { + Type: String, + Required: false }, downloadFormatsSupported: { Type: Array, default: function () { - return ['json', 'ors-gpx', 'geojson', 'to-gpx', 'kml'] + return ['json', 'ors-gpx', 'geojson', 'to-gpx', 'kml', 'csv'] } } }, @@ -31,9 +41,32 @@ export default { { text: 'GeoJSON', value: 'geojson', ext: 'json' }, { text: 'ORS API GPX', value: 'ors-gpx', ext: 'gpx' }, { text: `${this.$t('download.standard')} GPX`, value: 'to-gpx', ext: 'gpx' }, - { text: 'KML', value: 'kml', ext: 'kml' } + { text: 'KML', value: 'kml', ext: 'kml' }, + { text: 'CSV', value: 'csv', ext: 'csv'} ] }, + content () { + if (this.editProp === 'jobs') { + return { + copied: this.$t('download.jobsCopiedToClipboard'), + fileName: 'ors-jobs', + } + } else if (this.editProp === 'vehicles') { + return { + copied: this.$t('download.vehiclesCopiedToClipboard'), + fileName: 'ors-vehicles', + } + } else if (this.editProp === 'skills') { + return { + copied: this.$t('download.skillsCopiedToClipboard'), + fileName: 'ors-skills', + } + } else { + return { + fileName: this.defaultDownloadName, + } + } + }, /** * Return the name of the route first's point * @returns string @@ -52,18 +85,35 @@ export default { }, availableDownloadFormats () { const context = this - const available = this.lodash.filter(this.downloadFormats, (f) => { + return this.lodash.filter(this.downloadFormats, (f) => { return context.downloadFormatsSupported.includes(f.value) }) - return available - } + }, + // low priority TODO: read jobs and vehicles out of MapView data instead of prop + dataJson () { + const jsonData = [] + for (const d of this.data) { + jsonData.push(d.toJSON()) + } + return jsonData + }, + dataGeoJson () { + if (this.editProp === 'skills') { + return Error('GeoJSON cannot be created since skills contain no geoinformation') + } + const jsonData = [] + for (const d of this.data) { + jsonData.push(d.toGeoJSON()) + } + return { type: 'FeatureCollection', features: jsonData } + }, }, methods: { /** * Set the default filename and format and open the download modal */ openDownload () { - this.downloadFileName = this.defaultDownloadName + this.downloadFileName = this.content.fileName this.downloadFormat = this.downloadFormats[0].value this.isDownloadModalOpen = true }, @@ -126,55 +176,95 @@ export default { const context = this return new Promise((resolve, reject) => { try { - if (context.downloadFormat === 'json') { - // Get the ORS mapViewData model and stringify it - const orsJSONStr = JSON.stringify(context.mapViewData) - resolve(orsJSONStr) - } else if (context.downloadFormat === 'ors-gpx') { - // If the format is ors-gpx, run anew request with the format being 'gpx' - context.getORSGpx().then((orsGpx) => { - resolve(orsGpx) - }).catch(error => { - reject(error) - }) - } else if (context.downloadFormat === 'to-gpx') { - const geoJSON = context.mapViewData.getGeoJson() - // Use the third party utility to convert geojson to gpx - const toGPX = toGpx(geoJSON) - resolve(toGPX) - } else if (context.downloadFormat === 'geojson') { - jsonData = context.mapViewData.getGeoJson() - const jsonStr = JSON.stringify(jsonData) - resolve(jsonStr) - } else if (context.downloadFormat === 'kml') { - const routeTitle = context.originName.length > 0 ? `${context.originName} -> ${context.destinationName}` : context.$t('download.documentTitle') - const kmlOptions = { - documentName: routeTitle, - documentDescription: constants.orsKmlDocumentDescription - } - jsonData = context.mapViewData.getGeoJson() - // Use the third party utility to convert geojson to kml - const toKML = toKml(jsonData, kmlOptions) - resolve(toKML) + switch (context.downloadFormat) { + case 'json': + // Get the ORS mapViewData model and stringify it + if (this.mapViewData) { + jsonData = JSON.stringify(this.parseMapView(context.mapViewData)) + } else { + jsonData = JSON.stringify(context.dataJson) + } + resolve(jsonData) + break + case 'ors-gpx': + // If the format is ors-gpx, run anew request with the format being 'gpx' + context.getORSGpx().then((orsGpx) => { + resolve(orsGpx) + }).catch(error => { + reject(error) + }) + break + case 'to-gpx': + // Use the third party utility to convert geojson to gpx + resolve(toGpx(context.mapViewData.getGeoJson())) + break + case 'geojson': + if (this.mapViewData) { + jsonData = context.mapViewData.getGeoJson() + } else { + jsonData = context.dataGeoJson + } + resolve(JSON.stringify(jsonData)) + break + case 'kml': + resolve(this.buildKmlData()) + break + case 'csv': + resolve(this.buildCsvData()) } } catch (error) { reject(error) } }) }, - /** - * Get the response data routes and make sure that the geometry format is geojson - * @returns {Array} of route objects - */ - getOrsRoutesJson () { - let orsRoutes = [] - // Retrieve the route data - if (this.mapViewData && this.mapViewData.routes) { - orsRoutes = Object.assign({}, this.mapViewData.routes) + buildKmlData() { + const routeTitle = this.originName.length > 0 ? `${this.originName} -> ${this.destinationName}` : this.$t('download.documentTitle') + const kmlOptions = { + documentName: routeTitle, + documentDescription: constants.orsKmlDocumentDescription + } + let jsonData = this.mapViewData.getGeoJson() + // Use the third party utility to convert geojson to kml + return toKml(jsonData, kmlOptions) + }, + buildCsvData() { + let csvData + if (this.editProp === 'jobs') { + csvData = Job.toCsv(this.data) + } else if (this.editProp === 'vehicles') { + csvData = Vehicle.toCsv(this.data) + } + return csvData + }, + parseMapView (mapViewData) { + let localMapViewData = mapViewData.clone() + + if (mapViewData.mode === constants.modes.optimization) { + let jsonJobs = [] + for (const job of mapViewData.jobs) { + jsonJobs.push(job.toJSON()) + } + localMapViewData.jobs = jsonJobs + + let jsonVehicles = [] + for (const v of mapViewData.vehicles) { + jsonVehicles.push(v.toJSON()) + } + localMapViewData.vehicles = jsonVehicles } - return orsRoutes + + return localMapViewData }, + copyToClipboard () { + const data = this.dataJson + + navigator.clipboard.writeText(JSON.stringify(data)).then(() => { + this.showSuccess(this.content.copied, {timeout: 3000}) + }, () => { + this.showError(this.$t('download.copiedToClipboardFailed'), {timeout: 3000}) + },) + }, /** * Get the ors gpx text running a new request * using the same args but changing the format to `gpx` diff --git a/src/fragments/forms/map-form/components/download/i18n/download.i18n.en-us.js b/src/fragments/forms/map-form/components/download/i18n/download.i18n.en-us.js index 0760d6eed..774c306c6 100755 --- a/src/fragments/forms/map-form/components/download/i18n/download.i18n.en-us.js +++ b/src/fragments/forms/map-form/components/download/i18n/download.i18n.en-us.js @@ -6,9 +6,16 @@ export default { downloadFileName: 'Download file name', downloadFormat: 'Download format', downloadRoute: 'Download route', + downloadJson: 'Download JSON', + downloadAsCsv: 'Download as CSV', + copyToClipboard: 'Copy JSON to clipboard', + jobsCopiedToClipboard: 'Jobs copied to clipboard', + vehiclesCopiedToClipboard: 'Vehicles copied to clipboard', + skillsCopiedToClipboard: 'Skills copied to clipboard', preparingDownload: 'Preparing download ...', fileReady: 'File ready', errorPreparingFile: 'Error preparing file', + copiedToClipboardFailed: 'Copy to clipboard failed', fileTooBigToBeDownloaded: 'File too big to be downloaded', standard: 'Standard' } diff --git a/src/fragments/forms/map-form/components/form-actions/FormActions.vue b/src/fragments/forms/map-form/components/form-actions/FormActions.vue index 1b717b6ef..ba6d0330d 100644 --- a/src/fragments/forms/map-form/components/form-actions/FormActions.vue +++ b/src/fragments/forms/map-form/components/form-actions/FormActions.vue @@ -1,8 +1,8 @@