From 2c6958ac3381e60be906242b94ad364496bf92a2 Mon Sep 17 00:00:00 2001 From: Matthew Wall Date: Sat, 30 Mar 2019 00:26:45 +0000 Subject: [PATCH] Shipping Rates Pre-Fetch (#385) * start on shipping manager refactor * added action/reducers * more state changes + UI updates * progress on shipping rates * state progress * small state changes * small changes to settings actions * small changes * pr changes * pr changes * Update packages/frontend/src/state/reducers/profiles/profileReducer.js * Update packages/frontend/src/state/reducers/profiles/profileReducer.js * rm unnecessary error state declaration * filter names and rate input autofilled based on selection done * small fixes * Update packages/frontend/src/profiles/shippingRates.jsx Co-Authored-By: walmat * Update packages/frontend/src/profiles/shippingRates.jsx Co-Authored-By: walmat * pr changes * some layout changes + delete/clear buttons * added delete rate action, needs work still * fixed delete rate action/reducer * rm unnecessary id field * attach settings middleware for other fields * added webhook regex and errors map * fixed product validator error mapping * Store the selected shipping rate This commit updates the checkout class to keep track of the selected shipping rate. This will be used by a subclass of the Task Runner to fetch the cheapest shipping rate. * Refactor Run Loop to a single function This commit updates the task runner to use a single function in the start method to handle all loop logic. This will allow subclasses to override the default behavior if needed. * Add Shipping Rates Runner This commit adds a new type of runner that looks for shipping rates from a specific site. This allows the rates to be "pre-fetched" and then used in actual task runs. The ShippingRatesRunner is only supported on api checkout methods at this time. Attempting to create the shipping rates runner for a frontend checkout site will cause it to throw an error. The run loop has been modified to stop if the super implementation errors out, or if the shipping rates have been received. Further, all messages emitted from this runner will be attached with a special type flag. This will allow the frontend to tell that this runner is not a normal runner. * Fix lint This commit fixes a lint caused by an unused variable. * small css tweaks with cursor:pointer * Generalize Runner Type Tag This commit updates the TaskRunner to add a type property. A set of constants has been defined for further use if needed. Now, payloads emitted from the runner will include it's type. This prevents the need for the ShippingRatesRunner to inject it in using a method override. Finally, a special "done" flag is emitted when the task runner stops. This will allow the frontend to easily determine when the runner has stopped. * progress on settings middleware * separated middleware, added more action chains for fetching shipping rates * fixed rates proptypes * small naming fix * Update Naming This commit updates the naming of the runner type to be more consistent with the rest of the package. Instead of ShippingRate, the name is now ShippingRates. * Add Support for Starting the ShippingRatesRunner This commit updates the managers, as well as the runner scripts to create an instances of the ShippingRatesRunner based on an incoming type. This allows TaskManagers to start ShippingRatesRunners. The change is backwards compatible and passing no specific type will by default spawn a TaskRunner. This means that the current usage in the Frontend is unaffected. * Move Runners to a Separate Folder This commit restructures the task-runner source code structure to group runners together in a separate folder. This keeps the top level clean if/when more runners are added. * Export Task Runner Types This commit updates the exported object of the task-runner package so the frontend can reference the specific type it wants to spawn by variable instead of by value. * Fix Run Loop Bug This commit fixes a typo where the run loop was not invoked. * Update Task Status when emitting message This commit updates the emit task event to update the contexts end status with the message. This allows custom messages to be reused when reaching an end-runner state (aborted, errored, finished). When handling captchas, the context status is cleared to show the generic end state message. * Switch to use end runner states instead of generic Stopped This commit updates the task-runner sub processes to use a distinct end-runner state (Aborted, Errored, Finished) instead of using the generic Stopped event. This provides more context to the specific end state, but also allows the "done" payload to be properly emitted in all cases. * Fixes to existing test failures * test progress, broke some others in the progress... * settings actions tests * added shipping manager component tests * pulled in srr changes * rm unnecessary comments * some test fixes * split out shipping reducer * small change * revert init state for rates * added migration pattern * added random size default in preload * revert type parameter * fixed a lot of tests.. * test fixes, code coverage increase * fix lint, fix scss * fix lint * task list reducer test coverage increase * shipping rates component tests * test coverage improvements * increase test coverage * more code coverage increased * bug fixes with srr * rm unused file * test fix * added profile reducer * fixed test * small code coverage increased * small bug fix with copy task * pr changes, test updates * Update packages/frontend/src/state/migrators/v0.2.0/index.js Co-Authored-By: walmat * lint fix * filtered out shipping rate runner status messages * task event registration handler split into multiple handlers * added selectedProfile and currentProfile reducer fixes, and implemented task runner using the shipping rate * test fix progress * lint fix * fix deregister function * migration changes * prevented srr from handling restocks * Add Setup and Cleanup actions for Shipping Rates This commit adds private setup/cleanup actions for fetching shipping rates so the reducer can correctly enable/disable the shipping rates button when there is a run in progress * Add Shipping Status Flag This commit updates the shipping manager definitions to add a status flag that will be updated by the setup and cleanup settings actions. A migrator (v0.2.1) was added to initialize the status flag if it wasn't previously set. Tests were added for the migrator. * Update Settings Reducer to handle actions This commit updates the settings reducer to handle the setup/cleanup shipping actions. Tests have been added to ensure proper function. * Disable Fetch Shipping Rates Button when in progress This commit updates the shipping manager component to disable/reenable the fetch button when the shipping manager is running. This should prevent multiple runs of the shipping manager from happening. Tests were added to ensure the component renders properly. * styling updates, code coverage, and render bug fixes * bug fix w/ srr skipping fetching rates step * added price field in * srr caching rates bug fixed * rm debug statements * fixed shipping site username/password resetting bug * fixed invalid proptype error * fixed graceful shutdown of srr * added some more code coverage * on stop srr * Implement Cancel feature for SRR This commit updates the preload script to add a cancellation feature the shipping rates runner. This cancellation allows the srr to be stopped in the task manager if it is no longer needed. * Update Stop Shipping to Cleanup Shipping State This commit updates the preload script to notify the caller of the stop shipping request whether or not the cleanup shipping action should be dispatched. The settings actions are updated to handle this and cleanup the shipping redux state accordingly. * Address PR Comment The constructor for the Shipping Rates Runner has been updated to explicitly reference parameters. This should prevent initialization bugs where too many/too few parameters are passed and the type parameter gets misplaced in the parameter list. * code coverage increased --- .prettierrc.js | 2 +- .../frontend/lib/common/bridge/mainPreload.js | 125 ++- packages/frontend/lib/common/typeDef.js | 27 - packages/frontend/lib/task/adapter.js | 6 +- packages/frontend/package.json | 8 +- packages/frontend/src/__tests__/app.test.jsx | 73 ++ .../__tests__/constants/getAllSizes.test.js | 40 + .../src/__tests__/navbar/navbar.test.jsx | 73 +- .../__tests__/profiles/loadProfile.test.jsx | 34 +- .../profiles/locationFields.test.jsx | 120 +++ .../src/__tests__/profiles/profiles.test.jsx | 4 - .../__tests__/profiles/shippingRates.test.jsx | 518 +++++++++++ .../src/__tests__/settings/defaults.test.jsx | 34 +- .../src/__tests__/settings/settings.test.jsx | 174 +--- .../settings/shippingManager.test.jsx | 331 +++++++ .../src/__tests__/settings/webhooks.test.jsx | 74 +- .../state/actions/profileActions.test.js | 36 + .../state/actions/settingsActions.test.js | 215 ++++- .../state/actions/taskActions.test.js | 44 +- ...proxyAttributeValidationMiddleware.test.js | 108 +++ ...tingsAttributeValidationMiddleware.test.js | 193 +++- ...gFormAttributeValidationMiddleware.test.js | 502 ++++++++++ .../__tests__/state/migrators/v0.2.0.test.js | 116 +++ .../__tests__/state/migrators/v0.2.1.test.js | 44 + .../profile/currentProfileReducer.test.js | 832 +++++++++++++++++ .../profile/profileListReducer.test.js | 256 +++++- .../reducers/profile/profileReducer.test.js | 181 +++- .../reducers/profile/ratesReducer.test.js | 294 ++++++ .../profile/selectedProfileReducer.test.js | 620 ++++++++++++- .../reducers/settings/settingsReducer.test.js | 671 +++++++++++++- .../reducers/settings/shippingReducer.test.js | 315 +++++++ .../reducers/task/newTaskReducer.test.js | 257 +++++- .../reducers/task/taskListReducer.test.js | 855 +++++++++++++++++- .../state/reducers/task/taskReducer.test.js | 61 +- .../src/__tests__/tasks/tasks.test.jsx | 46 +- packages/frontend/src/app.css | 21 +- packages/frontend/src/app.jsx | 18 +- packages/frontend/src/app.scss | 21 +- packages/frontend/src/navbar/_mixins.scss | 16 + packages/frontend/src/navbar/navbar.css | 61 +- packages/frontend/src/navbar/navbar.jsx | 45 +- packages/frontend/src/navbar/navbar.scss | 50 +- packages/frontend/src/profiles/_payment.scss | 1 + packages/frontend/src/profiles/_profiles.scss | 6 +- packages/frontend/src/profiles/_rates.scss | 71 ++ packages/frontend/src/profiles/profiles.css | 101 ++- packages/frontend/src/profiles/profiles.jsx | 21 +- packages/frontend/src/profiles/profiles.scss | 1 + .../frontend/src/profiles/shippingRates.jsx | 206 +++++ packages/frontend/src/settings/_settings.scss | 19 +- .../src/settings/_shippingManager.scss | 108 +++ packages/frontend/src/settings/defaults.jsx | 23 +- packages/frontend/src/settings/settings.css | 197 +++- packages/frontend/src/settings/settings.jsx | 173 +--- packages/frontend/src/settings/settings.scss | 1 + .../frontend/src/settings/shippingManager.jsx | 332 +++++++ packages/frontend/src/settings/webhooks.jsx | 64 +- packages/frontend/src/state/actions.js | 2 + .../state/actions/profiles/profileActions.js | 46 +- .../state/actions/settings/settingsActions.js | 83 +- .../src/state/actions/tasks/taskActions.js | 69 +- packages/frontend/src/state/configureStore.js | 6 +- .../frontend/src/state/initial/profiles.js | 3 +- .../frontend/src/state/initial/settings.js | 4 +- .../profileAttributeValidationMiddleware.js | 6 +- .../proxyAttributeValidationMiddleware.js | 27 + .../settingsAttributeValidationMiddleware.js | 30 +- ...ippingFormAttributeValidationMiddleware.js | 34 + packages/frontend/src/state/migrators.js | 4 + .../src/state/migrators/v0.2.0/index.js | 94 ++ .../src/state/migrators/v0.2.0/state.js | 70 ++ .../src/state/migrators/v0.2.1/index.js | 37 + .../src/state/migrators/v0.2.1/state.js | 15 + .../reducers/profiles/profileListReducer.js | 40 +- .../state/reducers/profiles/profileReducer.js | 118 ++- .../state/reducers/profiles/ratesReducer.js | 28 + .../reducers/settings/settingsReducer.js | 56 +- .../reducers/settings/shippingReducer.js | 75 ++ .../src/state/reducers/tasks/taskReducer.js | 13 + packages/frontend/src/tasks/_tasks.scss | 33 +- packages/frontend/src/tasks/tasks.css | 31 +- packages/frontend/src/tasks/tasks.jsx | 65 +- .../utils/definitions/profileDefinitions.js | 2 + .../src/utils/definitions/profiles/profile.js | 6 + .../src/utils/definitions/profiles/rates.js | 22 + .../utils/definitions/settings/settings.js | 2 + .../definitions/settings/settingsErrors.js | 2 + .../definitions/settings/shippingManager.js | 29 + .../settings/shippingManagerErrors.js | 12 + .../utils/definitions/settingsDefinitions.js | 4 + .../frontend/src/utils/parseProductType.js | 54 ++ packages/frontend/src/utils/validation.js | 2 + ...idators.js => proxyAttributeValidators.js} | 5 +- .../validation/settingsAttributeValidators.js | 17 + .../shippingFormAttributeValidators.js | 53 ++ .../src/shopify/_test/testMonitor.js | 157 ---- .../src/shopify/classes/checkout.js | 69 +- .../src/shopify/classes/checkouts/api.js | 39 +- .../src/shopify/classes/checkouts/frontend.js | 28 +- .../src/shopify/classes/monitor.js | 8 +- .../src/shopify/classes/utils/constants.js | 9 + packages/task-runner/src/shopify/index.js | 8 +- .../managers/splitContextTaskManager.js | 4 +- .../src/shopify/managers/taskManager.js | 37 +- .../src/shopify/runnerScripts/base.js | 16 +- .../shopify/runners/shippingRatesRunner.js | 45 + .../src/shopify/{ => runners}/taskRunner.js | 94 +- 107 files changed, 9565 insertions(+), 993 deletions(-) delete mode 100755 packages/frontend/lib/common/typeDef.js create mode 100644 packages/frontend/src/__tests__/profiles/shippingRates.test.jsx create mode 100644 packages/frontend/src/__tests__/settings/shippingManager.test.jsx create mode 100644 packages/frontend/src/__tests__/state/middleware/settings/proxyAttributeValidationMiddleware.test.js create mode 100644 packages/frontend/src/__tests__/state/middleware/settings/shippingFormAttributeValidationMiddleware.test.js create mode 100644 packages/frontend/src/__tests__/state/migrators/v0.2.0.test.js create mode 100644 packages/frontend/src/__tests__/state/migrators/v0.2.1.test.js create mode 100644 packages/frontend/src/__tests__/state/reducers/profile/ratesReducer.test.js create mode 100644 packages/frontend/src/__tests__/state/reducers/settings/shippingReducer.test.js create mode 100644 packages/frontend/src/navbar/_mixins.scss create mode 100644 packages/frontend/src/profiles/_rates.scss create mode 100644 packages/frontend/src/profiles/shippingRates.jsx create mode 100644 packages/frontend/src/settings/_shippingManager.scss create mode 100644 packages/frontend/src/settings/shippingManager.jsx create mode 100644 packages/frontend/src/state/middleware/settings/proxyAttributeValidationMiddleware.js create mode 100644 packages/frontend/src/state/middleware/settings/shippingFormAttributeValidationMiddleware.js create mode 100644 packages/frontend/src/state/migrators/v0.2.0/index.js create mode 100644 packages/frontend/src/state/migrators/v0.2.0/state.js create mode 100644 packages/frontend/src/state/migrators/v0.2.1/index.js create mode 100644 packages/frontend/src/state/migrators/v0.2.1/state.js create mode 100644 packages/frontend/src/state/reducers/profiles/ratesReducer.js create mode 100644 packages/frontend/src/state/reducers/settings/shippingReducer.js create mode 100644 packages/frontend/src/utils/definitions/profiles/rates.js create mode 100644 packages/frontend/src/utils/definitions/settings/shippingManager.js create mode 100644 packages/frontend/src/utils/definitions/settings/shippingManagerErrors.js create mode 100644 packages/frontend/src/utils/parseProductType.js rename packages/frontend/src/utils/validation/{settingsProxyAttributeValidators.js => proxyAttributeValidators.js} (88%) create mode 100644 packages/frontend/src/utils/validation/settingsAttributeValidators.js create mode 100644 packages/frontend/src/utils/validation/shippingFormAttributeValidators.js delete mode 100644 packages/task-runner/src/shopify/_test/testMonitor.js create mode 100644 packages/task-runner/src/shopify/runners/shippingRatesRunner.js rename packages/task-runner/src/shopify/{ => runners}/taskRunner.js (90%) diff --git a/.prettierrc.js b/.prettierrc.js index fc3a1cdd..63d3e7cd 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -13,7 +13,7 @@ module.exports = { files: ["*.js", "*.jsx", "*.es", "*.es6", "*.mjs"], options: { printWidth: 100, - parser: "babylon" + parser: "babel" } }, { diff --git a/packages/frontend/lib/common/bridge/mainPreload.js b/packages/frontend/lib/common/bridge/mainPreload.js index 833faa7d..9444ac02 100644 --- a/packages/frontend/lib/common/bridge/mainPreload.js +++ b/packages/frontend/lib/common/bridge/mainPreload.js @@ -1,5 +1,6 @@ // eslint-disable-next-line import/no-extraneous-dependencies const { ipcRenderer } = require('electron'); +const { TaskRunnerTypes } = require('@nebula/task-runner').shopify; const IPCKeys = require('../constants'); const nebulaEnv = require('../../_electron/env'); @@ -7,6 +8,10 @@ const { base, util } = require('./index'); nebulaEnv.setUpEnvironment(); +let srrRequest = null; +let handlers = []; +const taskEventHandler = (...params) => handlers.forEach(h => h(...params)); + const _checkForUpdates = () => { util.sendEvent(IPCKeys.RequestCheckForUpdates); }; @@ -33,37 +38,53 @@ const _launchCaptchaHarvester = opts => { * Sends a listener for task events to launcher.js */ const _registerForTaskEvents = handler => { - util.sendEvent(IPCKeys.RequestRegisterTaskEventHandler); - ipcRenderer.once(IPCKeys.RequestRegisterTaskEventHandler, (event, eventKey) => { - // Check and make sure we have a key to listen on - if (eventKey) { - util.handleEvent(eventKey, handler); - } else { - console.error('Unable to Register for Task Events!'); - } - }); + if (handlers.length > 0) { + handlers.push(handler); + } else { + util.sendEvent(IPCKeys.RequestRegisterTaskEventHandler); + ipcRenderer.once(IPCKeys.RequestRegisterTaskEventHandler, (event, eventKey) => { + // Check and make sure we have a key to listen on + if (eventKey) { + handlers.push(handler); + util.handleEvent(eventKey, taskEventHandler); + } else { + console.error('Unable to Register for Task Events!'); + } + }); + } }; /** * Removes a listener for task events to launcher.js */ const _deregisterForTaskEvents = handler => { - util.sendEvent(IPCKeys.RequestDeregisterTaskEventHandler); - ipcRenderer.once(IPCKeys.RequestDeregisterTaskEventHandler, (event, eventKey) => { - // Check and make sure we have a key to deregister from - if (eventKey) { - util.removeEvent(eventKey, handler); - } else { - console.error('Unable to Deregister from Task Events!'); - } - }); + if (handlers.length === 1) { + util.sendEvent(IPCKeys.RequestDeregisterTaskEventHandler); + ipcRenderer.once(IPCKeys.RequestDeregisterTaskEventHandler, (event, eventKey) => { + // Check and make sure we have a key to deregister from + if (eventKey) { + util.removeEvent(eventKey, taskEventHandler); + handlers = []; + } else { + console.error('Unable to Deregister from Task Events!'); + } + }); + } + handlers = handlers.filter(h => h !== handler); +}; + +/** + * Removes all listeners if the window was closed + */ +window.onbeforeunload = () => { + handlers.forEach(h => _deregisterForTaskEvents(h)); }; /** * Sends task(s) that should be started to launcher.js */ -const _startTasks = tasks => { - util.sendEvent(IPCKeys.RequestStartTasks, tasks); +const _startTasks = (tasks, options) => { + util.sendEvent(IPCKeys.RequestStartTasks, tasks, options); }; /** @@ -73,6 +94,68 @@ const _stopTasks = tasks => { util.sendEvent(IPCKeys.RequestStopTasks, tasks); }; +const _startShippingRatesRunner = task => { + const request = { + task: { ...task, id: 1000, sizes: ['Random'] }, + cancel: () => {}, + promise: null, + }; + + if (srrRequest) { + return Promise.reject(new Error('Shipping Rates Runner has already been started!')); + } + + request.promise = new Promise((resolve, reject) => { + const response = {}; + + // Define srr message handler to retrive data + const srrMessageHandler = (_, id, payload) => { + // Only respond to specific type + if (payload.type === TaskRunnerTypes.ShippingRates) { + // Runner type is exposed from the task-runner package + response.rates = payload.rates || response.rates; // update rates if it exists + response.selectedRate = payload.selected || response.selectedRate; // update selected if it exists + + if (payload.done) { + // SRR is done + _deregisterForTaskEvents(srrMessageHandler); + if (!response.rates || !response.selectedRate) { + // Reject since we don't have the required data + reject(new Error('Data was not provided!')); + } else { + // Resolve since we have the required data + resolve(response); + } + srrRequest = null; + } + } + }; + + // Define cancel method for request + request.cancel = () => { + _deregisterForTaskEvents(srrMessageHandler); + _stopTasks(request.task); + srrRequest = null; + reject(new Error('Runner was cancelled')); + }; + + srrRequest = request; + _registerForTaskEvents(srrMessageHandler); + _startTasks(request.task, { type: TaskRunnerTypes.ShippingRates }); + }); + + return request.promise; +}; + +const _stopShippingRatesRunner = () => { + if (!srrRequest) { + return Promise.reject(new Error('No SRR Running')); + } + srrRequest.cancel(); + srrRequest = null; + return Promise.resolve(); +}; + /** * Sends proxies(s) that should be add to launcher.js */ @@ -113,6 +196,8 @@ process.once('loaded', () => { checkForUpdates: _checkForUpdates, launchCaptchaHarvester: _launchCaptchaHarvester, setTheme: _setTheme, + startShippingRatesRunner: _startShippingRatesRunner, + stopShippingRatesRunner: _stopShippingRatesRunner, closeAllCaptchaWindows: _closeAllCaptchaWindows, deactivate: _deactivate, registerForTaskEvents: _registerForTaskEvents, diff --git a/packages/frontend/lib/common/typeDef.js b/packages/frontend/lib/common/typeDef.js deleted file mode 100755 index 88b1caae..00000000 --- a/packages/frontend/lib/common/typeDef.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @external {EventEmitter} https://github.com/atom/electron/blob/master/docs/api/web-contents.md - */ - -/** - * @external {ipcMain} https://github.com/atom/electron/blob/master/docs/api/ipc-main.md - */ - -/** - * @external {ipcRenderer} https://github.com/atom/electron/blob/master/docs/api/ipc-renderer.md - */ - -/** - * @external {shell} https://github.com/atom/electron/blob/master/docs/api/shell.md - */ - -/** - * @external {IPCEvent} https://github.com/atom/electron/blob/master/docs/api/ipc-main.md - */ - -/** - * @external {EventEmitter} https://nodejs.org/api/events.html - */ - -/** - * @external {Buffer} https://nodejs.org/api/buffer.html - */ diff --git a/packages/frontend/lib/task/adapter.js b/packages/frontend/lib/task/adapter.js index e2f7eef3..b0e74c71 100644 --- a/packages/frontend/lib/task/adapter.js +++ b/packages/frontend/lib/task/adapter.js @@ -87,11 +87,11 @@ class TaskManagerAdapter { this._taskManager.harvestCaptchaToken(runnerId, token); } - _onStartTasksRequest(_, tasks) { + _onStartTasksRequest(_, tasks, options) { if (tasks instanceof Array) { - this._taskManager.startAll(tasks); + this._taskManager.startAll(tasks, options); } else { - this._taskManager.start(tasks); + this._taskManager.start(tasks, options); } } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e0a5d9bd..97ed8030 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -115,9 +115,15 @@ "!src/tasks/old/*.{js,jsx}", "!src/server/old/*.{js,jsx}" ], + "coveragePathIgnorePatterns": [ + "/node_modules/", + ".eslintrc.js", + "setupTests.js" + ], "testPathIgnorePatterns": [ "/node_modules/", - ".eslintrc.js" + ".eslintrc.js", + "setupTests.js" ] } } diff --git a/packages/frontend/src/__tests__/app.test.jsx b/packages/frontend/src/__tests__/app.test.jsx index b2d61ccb..e0e211d7 100644 --- a/packages/frontend/src/__tests__/app.test.jsx +++ b/packages/frontend/src/__tests__/app.test.jsx @@ -11,6 +11,7 @@ import Profiles from '../profiles/profiles'; import Server from '../server/server'; import Settings from '../settings/settings'; import { ROUTES, globalActions } from '../state/actions'; +import { THEMES, mapThemeToColor, mapToNextTheme } from '../constants/themes'; import getByTestId from '../__testUtils__/getByTestId'; @@ -32,11 +33,83 @@ describe('Top Level App', () => { expect(wrapper.find('#container-wrapper')).toHaveLength(1); expect(getByTestId(wrapper, 'App.button.close')).toHaveLength(1); expect(getByTestId(wrapper, 'App.button.deactivate')).toHaveLength(1); + expect(getByTestId(wrapper, 'App.button.theme')).toHaveLength(1); getByTestId(wrapper, 'App.button.deactivate').simulate('keyPress'); expect(wrapper.instance().props.store.getState).toHaveBeenCalled(); wrapper.unmount(); }); + describe('Theme Button', () => { + let Bridge; + + afterEach(() => { + if (Bridge && window.Bridge) { + delete window.Bridge; + } + }); + + it('should render with correct props', () => { + const wrapper = appProvider(); + const themeButton = getByTestId(wrapper, 'App.button.theme'); + expect(themeButton.prop('className')).toBe('theme-icon'); + expect(themeButton.prop('role')).toBe('button'); + expect(themeButton.prop('title')).toBe('theme'); + expect(themeButton.prop('onKeyPress')).toBeDefined(); + expect(themeButton.prop('onClick')).toBeDefined(); + }); + + describe("should not call window bridge method if it isn't defined", () => { + test('when current theme is not in the theme map', () => { + const onKeyPress = jest.fn(); + const wrapper = appProvider({ onKeyPress }); + const { store } = wrapper.instance().props; + const initialTheme = 'RANDOM_TEST_STRING'; + const themeButton = getByTestId(wrapper, 'App.button.theme'); + themeButton.simulate('click'); + themeButton.simulate('keyPress'); + expect(onKeyPress).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalled(); + expect(store.dispatch.mock.calls[0][0]).toEqual( + globalActions.setTheme(mapToNextTheme[initialTheme] || THEMES.LIGHT), + ); + expect(store.theme).toEqual(mapToNextTheme[initialTheme]); + }); + + test('when current theme is in the theme map', () => { + const onKeyPress = jest.fn(); + const wrapper = appProvider({ onKeyPress }); + const { store } = wrapper.instance().props; + const { theme } = store; + const themeButton = getByTestId(wrapper, 'App.button.theme'); + themeButton.simulate('click'); + themeButton.simulate('keyPress'); + expect(onKeyPress).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalled(); + expect(store.dispatch.mock.calls[0][0]).toEqual( + globalActions.setTheme(mapToNextTheme[theme] || THEMES.LIGHT), + ); + expect(store.theme).toEqual(mapToNextTheme[theme]); + }); + }); + + test('should call window.Bridge.setTheme if it is defined', () => { + const wrapper = appProvider(); + const { store } = wrapper.instance().props; + const { theme } = store; + const nextTheme = mapToNextTheme[theme] || THEMES.LIGHT; + const backgroundColor = mapThemeToColor[nextTheme]; + const themeButton = getByTestId(wrapper, 'App.button.theme'); + Bridge = { + setTheme: jest.fn(), + }; + window.Bridge = Bridge; + themeButton.simulate('click'); + expect(store.dispatch).toHaveBeenCalled(); + expect(store.dispatch.mock.calls[0][0]).toEqual(globalActions.setTheme(nextTheme)); + expect(Bridge.setTheme).toHaveBeenCalledWith({ backgroundColor }); + }); + }); + describe('Deactivate Button', () => { let Bridge; diff --git a/packages/frontend/src/__tests__/constants/getAllSizes.test.js b/packages/frontend/src/__tests__/constants/getAllSizes.test.js index 3c251857..8b6e813c 100644 --- a/packages/frontend/src/__tests__/constants/getAllSizes.test.js +++ b/packages/frontend/src/__tests__/constants/getAllSizes.test.js @@ -181,4 +181,44 @@ describe('getAllSizes', () => { }); }); }); + + test("should build the correct sizes for US/UK Men's category", () => { + const category = "US/UK Men's"; + + const expected = expectedSizes[2].options.filter( + s => s.label !== 'Random' && s.label !== 'Full Size Run', + ); + const actual = buildSizesForCategory(category); + expect(actual).toEqual(expected); + }); + + test('should build the correct sizes for Clothing category', () => { + const category = 'Clothing'; + + const expected = expectedSizes[1].options.filter( + s => s.label !== 'Random' && s.label !== 'Full Size Run', + ); + const actual = buildSizesForCategory(category); + expect(actual).toEqual(expected); + }); + + test("should build the correct sizes for EU Men's category", () => { + const category = "EU Men's"; + + const expected = expectedSizes[3].options.filter( + s => s.label !== 'Random' && s.label !== 'Full Size Run', + ); + const actual = buildSizesForCategory(category); + expect(actual).toEqual(expected); + }); + + test('should build the correct sizes for Generic category', () => { + const category = 'Generic'; + + const expected = expectedSizes[0].options.filter( + s => s.label !== 'Random' && s.label !== 'Full Size Run', + ); + const actual = buildSizesForCategory(category); + expect(actual).toEqual(expected); + }); }); diff --git a/packages/frontend/src/__tests__/navbar/navbar.test.jsx b/packages/frontend/src/__tests__/navbar/navbar.test.jsx index a0ea2e0d..358c7613 100644 --- a/packages/frontend/src/__tests__/navbar/navbar.test.jsx +++ b/packages/frontend/src/__tests__/navbar/navbar.test.jsx @@ -12,7 +12,6 @@ import { ROUTES, NAVBAR_ACTIONS } from '../../state/actions'; const initialNavbarState = initialState.navbar; describe('', () => { - let Bridge; let history; let props; @@ -26,6 +25,7 @@ describe('', () => { , @@ -39,6 +39,7 @@ describe('', () => { props = { history, navbar: { ...initialNavbarState }, + theme: initialState.theme, onRoute: jest.fn(), onKeyPress: jest.fn(), }; @@ -48,37 +49,95 @@ describe('', () => { delete global.window.Bridge; }); - describe('should render name and version correctly', () => { - test('when window Bridge is defined', () => { + describe('when window Bridge is defined', () => { + let Bridge; + let wrapper; + + beforeEach(() => { Bridge = { + closeAllCaptchaWindows: jest.fn(), + launchCaptchaHarvester: jest.fn(), getAppData: jest.fn(() => ({ name: 'Nebula Orion', version: '1.0.0' })), }; global.window.Bridge = Bridge; - const wrapper = renderShallowWithProps(); + wrapper = renderShallowWithProps(); + }); + + afterEach(() => { + delete global.window.Bridge; + }); + + test('should render name and version properly', () => { expect(Bridge.getAppData).toHaveBeenCalled(); const appName = wrapper.find('.navbar__text--app-name').text(); const version = wrapper.find('.navbar__text--app-version').text(); expect(appName).toEqual('Nebula Orion'); expect(version).toEqual('1.0.0'); }); - test('when window Bridge is undefined', () => { - const wrapper = renderShallowWithProps(); + + test('launch captcha button calls correct function', () => { + const button = wrapper.find('.navbar__button--open-captcha'); + button.simulate('click'); + expect(Bridge.launchCaptchaHarvester).toHaveBeenCalled(); + }); + + test('close all harvester button calls correct function', () => { + const button = wrapper.find('.navbar__button--close-captcha'); + button.simulate('click'); + expect(Bridge.closeAllCaptchaWindows).toHaveBeenCalled(); + }); + }); + + describe('when window.Bridge is undefined', () => { + let consoleSpy; + let wrapper; + + beforeEach(() => { + wrapper = renderShallowWithProps(); + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + delete global.window.Bridge; + consoleSpy.mockRestore(); + }); + + test('should render name and version properly', () => { const appName = wrapper.find('.navbar__text--app-name').text(); const version = wrapper.find('.navbar__text--app-version').text(); expect(appName).toEqual('Nebula Orion'); expect(version).toEqual(''); }); + + test('launch harvester button displays error', () => { + const button = wrapper.find('.navbar__button--open-captcha'); + button.simulate('click'); + expect(consoleSpy).toHaveBeenCalled(); + }); + + test('close harvester button displays error', () => { + const button = wrapper.find('.navbar__button--close-captcha'); + button.simulate('click'); + expect(consoleSpy).toHaveBeenCalled(); + }); }); it('should render with required props', () => { const onRoute = jest.fn(); const wrapper = shallow( - , + , ); expect(wrapper.find(NavbarPrimitive)).toBeDefined(); expect(wrapper.find(Bodymovin)).toBeDefined(); expect(wrapper.find('.active')).toBeDefined(); expect(wrapper.find('.active').prop('onKeyPress')()).toBeUndefined(); + expect(wrapper.find('.navbar__button--open-captcha')).toHaveLength(1); + expect(wrapper.find('.navbar__button--close-captcha')).toHaveLength(1); }); describe('should render with only one active icon', () => { diff --git a/packages/frontend/src/__tests__/profiles/loadProfile.test.jsx b/packages/frontend/src/__tests__/profiles/loadProfile.test.jsx index 40e24070..69258171 100644 --- a/packages/frontend/src/__tests__/profiles/loadProfile.test.jsx +++ b/packages/frontend/src/__tests__/profiles/loadProfile.test.jsx @@ -2,7 +2,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { LoadProfilePrimitive, mapDispatchToProps } from '../../profiles/loadProfile'; +import { + LoadProfilePrimitive, + mapStateToProps, + mapDispatchToProps, +} from '../../profiles/loadProfile'; import { profileActions } from '../../state/actions'; import initialProfileStates from '../../state/initial/profiles'; import { initialState } from '../../state/migrators'; @@ -54,7 +58,7 @@ describe('', () => { profileName: `profile${id}`, })), selectedProfile: { - ...initialProfileStates, + ...initialProfileStates.profile, id: 1, profileName: 'profile1', }, @@ -79,7 +83,7 @@ describe('', () => { profileName: `profile${id}`, })), selectedProfile: { - ...initialProfileStates, + ...initialProfileStates.profile, id: 1, profileName: 'profile1', }, @@ -99,7 +103,7 @@ describe('', () => { profileName: `profile${id}`, })), selectedProfile: { - ...initialProfileStates, + ...initialProfileStates.profile, id: 1, profileName: 'profile1', }, @@ -114,7 +118,7 @@ describe('', () => { test('deleting a profile', () => { const customProps = { selectedProfile: { - ...initialProfileStates, + ...initialProfileStates.profile, id: 1, profileName: 'profile1', }, @@ -129,6 +133,26 @@ describe('', () => { }); }); + test('map state to props returns correct structure', () => { + const state = { + profiles: [1, 2, 3].map(id => ({ + ...initialProfileStates.profile, + id, + profileName: `profile${id}`, + })), + selectedProfile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'profile1', + }, + theme: initialState.theme, + }; + const actual = mapStateToProps(state); + expect(actual.profiles).toEqual(state.profiles); + expect(actual.theme).toEqual(state.theme); + expect(actual.selectedProfile).toEqual(state.selectedProfile); + }); + test('map dispatch to props returns correct structure', () => { const dispatch = jest.fn(); const tempProfile = { diff --git a/packages/frontend/src/__tests__/profiles/locationFields.test.jsx b/packages/frontend/src/__tests__/profiles/locationFields.test.jsx index 4c6a03f4..0d366171 100644 --- a/packages/frontend/src/__tests__/profiles/locationFields.test.jsx +++ b/packages/frontend/src/__tests__/profiles/locationFields.test.jsx @@ -96,6 +96,126 @@ describe('', () => { ).toBeTruthy(); }); + it('should render shipping row with billing matches shipping button not clicked', () => { + const wrapper = shallow( + {}} + disabled + id="shipping" + value={{ ...initialProfileStates.location }} + currentProfile={{ ...initialProfileStates.profile }} + />, + ); + + expect( + wrapper.find('.shipping-profiles-location__input-group--first-name').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--last-name').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--address-one').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--address-two').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--city').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--province').prop('isDisabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--zip-code').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--country').prop('isDisabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--phone').prop('disabled'), + ).toBeTruthy(); + expect(wrapper.find('.profiles__fields--matches')).toHaveLength(1); + expect(wrapper.find('.profiles__fields--matches').prop('title')).toEqual( + "Billing Doesn't Match Shipping", + ); + }); + + it('should render shipping row with billing matches shipping button not clicked', () => { + const wrapper = shallow( + {}} + onClickBillingMatchesShipping={() => {}} + disabled + id="shipping" + value={{ ...initialProfileStates.location }} + currentProfile={{ ...initialProfileStates.profile, billingMatchesShipping: true }} + />, + ); + + expect( + wrapper.find('.shipping-profiles-location__input-group--first-name').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--last-name').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--address-one').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--address-two').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--city').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--province').prop('isDisabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--zip-code').prop('disabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--country').prop('isDisabled'), + ).toBeTruthy(); + expect( + wrapper.find('.shipping-profiles-location__input-group--phone').prop('disabled'), + ).toBeTruthy(); + const BMS = wrapper.find('.profiles__fields--matches'); + expect(BMS).toHaveLength(1); + expect(BMS.prop('title')).toEqual('Billing Matches Shipping'); + BMS.simulate('click'); + const expectedAction = profileActions.edit( + null, + PROFILE_FIELDS.TOGGLE_BILLING_MATCHES_SHIPPING, + '', + ); + const dispatch = jest.fn(); + const actual = mapDispatchToProps(dispatch, {}); + expect(actual.onClickBillingMatchesShipping).toBeDefined(); + actual.onClickBillingMatchesShipping(); + expect(dispatch).toHaveBeenCalledWith(expectedAction); + }); + + it('should render no province options for countries that have none', () => { + const wrapper = shallow( + {}} + disabled + id="test" + value={{ ...initialProfileStates.location, country: null }} + />, + ); + + expect( + wrapper.find('.test-profiles-location__input-group--province').prop('isDisabled'), + ).toBeTruthy(); + expect(wrapper.find('.test-profiles-location__input-group--province').prop('options')).toEqual( + undefined, + ); + }); + describe('should render correct values for', () => { const testFieldValue = (id, field, value1, value2, disabled) => { const input = { diff --git a/packages/frontend/src/__tests__/profiles/profiles.test.jsx b/packages/frontend/src/__tests__/profiles/profiles.test.jsx index 5f836f6c..4240bf83 100644 --- a/packages/frontend/src/__tests__/profiles/profiles.test.jsx +++ b/packages/frontend/src/__tests__/profiles/profiles.test.jsx @@ -281,9 +281,6 @@ describe('', () => { currentProfile: { ...initialProfileStates.profile, }, - selectedProfile: { - ...initialProfileStates.profile, - }, other: 'stuff', that: "should't", be: 'in', @@ -292,7 +289,6 @@ describe('', () => { const expected = { profiles: state.profiles, currentProfile: state.currentProfile, - selectedProfile: state.selectedProfile, }; const actual = mapStateToProps(state); expect(actual).toEqual(expected); diff --git a/packages/frontend/src/__tests__/profiles/shippingRates.test.jsx b/packages/frontend/src/__tests__/profiles/shippingRates.test.jsx new file mode 100644 index 00000000..d1edec71 --- /dev/null +++ b/packages/frontend/src/__tests__/profiles/shippingRates.test.jsx @@ -0,0 +1,518 @@ +/* global describe it expect beforeEach jest test */ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { + ShippingRatesPrimitive, + mapStateToProps, + mapDispatchToProps, +} from '../../profiles/shippingRates'; +import { profileActions, PROFILE_FIELDS } from '../../state/actions'; +import initialProfileStates from '../../state/initial/profiles'; +import { initialState } from '../../state/migrators'; +import { RATES_FIELDS } from '../../state/actions/profiles/profileActions'; + +describe('', () => { + let defaultProps; + + const renderShallowWithProps = customProps => { + const renderProps = { + ...defaultProps, + ...customProps, + }; + return shallow( + , + ); + }; + + beforeEach(() => { + defaultProps = { + theme: initialState.theme, + value: { + ...initialProfileStates.profile, + rates: [...initialProfileStates.rates], + }, + onChange: () => {}, + onDeleteShippingRate: () => {}, + }; + }); + + it('should render with required props', () => { + const wrapper = renderShallowWithProps(); + expect(wrapper).toBeDefined(); + expect(wrapper.find('.profiles-rates__input-group--site')).toHaveLength(0); + expect(wrapper.find('.profiles-rates__input-group--name')).toHaveLength(0); + expect(wrapper.find('.profiles-rates__input-group--rate')).toHaveLength(0); + expect(wrapper.find('.profiles-rates__input-group--delete')).toHaveLength(0); + }); + + it('should render with custom props', () => { + const customProps = { + value: { + ...initialProfileStates.profile, + rates: [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + ], + }, + }; + const wrapper = renderShallowWithProps(customProps); + expect(wrapper).toBeDefined(); + expect(wrapper.find('.profiles-rates__input-group--rate')).toHaveLength(1); + expect(wrapper.find('.profiles-rates__input-group--site')).toHaveLength(1); + expect(wrapper.find('.profiles-rates__input-group--name')).toHaveLength(1); + expect(wrapper.find('.profiles-rates__input-group--delete')).toHaveLength(1); + }); + + describe('should render rate fields', () => { + test('when no selectedSite is chosen', () => { + const customProps = { + value: { + ...initialProfileStates.profile, + selectedSite: null, + rates: [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + ], + }, + }; + const wrapper = renderShallowWithProps(customProps); + expect(wrapper).toBeDefined(); + const rate = wrapper.find('.profiles-rates__input-group--rate'); + const site = wrapper.find('.profiles-rates__input-group--site'); + const name = wrapper.find('.profiles-rates__input-group--name'); + const deleteButton = wrapper.find('.profiles-rates__input-group--delete'); + + expect(rate).toHaveLength(1); + expect(site).toHaveLength(1); + expect(name).toHaveLength(1); + expect(deleteButton).toHaveLength(1); + expect(site.prop('value')).toBe(null); + expect(name.prop('value')).toBe(null); + expect(rate.prop('value')).toBe(''); + }); + + test('when selectedSite is chosen and no rate has been chosen', () => { + const customProps = { + value: { + ...initialProfileStates.profile, + selectedSite: { + label: 'Kith', + value: 'https://kith.com', + }, + rates: [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + ], + }, + }; + const wrapper = renderShallowWithProps(customProps); + expect(wrapper).toBeDefined(); + const rate = wrapper.find('.profiles-rates__input-group--rate'); + const site = wrapper.find('.profiles-rates__input-group--site'); + const name = wrapper.find('.profiles-rates__input-group--name'); + const deleteButton = wrapper.find('.profiles-rates__input-group--delete'); + + expect(rate).toHaveLength(1); + expect(site).toHaveLength(1); + expect(name).toHaveLength(1); + expect(deleteButton).toHaveLength(1); + expect(site.prop('value')).toEqual({ + label: 'Kith', + value: 'https://kith.com', + }); + expect(name.prop('value')).toBe(null); + expect(rate.prop('value')).toBe(''); + }); + + test('when both selectedSite and selectedRate are chosen', () => { + const customProps = { + value: { + ...initialProfileStates.profile, + selectedSite: { + label: 'Kith', + value: 'https://kith.com', + }, + rates: [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + ], + }, + }; + const wrapper = renderShallowWithProps(customProps); + expect(wrapper).toBeDefined(); + const rate = wrapper.find('.profiles-rates__input-group--rate'); + const site = wrapper.find('.profiles-rates__input-group--site'); + const name = wrapper.find('.profiles-rates__input-group--name'); + const deleteButton = wrapper.find('.profiles-rates__input-group--delete'); + + expect(rate).toHaveLength(1); + expect(site).toHaveLength(1); + expect(name).toHaveLength(1); + expect(deleteButton).toHaveLength(1); + expect(site.prop('value')).toEqual({ + label: 'Kith', + value: 'https://kith.com', + }); + expect(name.prop('value')).toEqual({ + label: '5-7 Business Days', + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }); + expect(rate.prop('value')).toEqual('shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00'); + }); + }); + + describe('should call correct event handler when', () => { + test('selecting site', () => { + const customProps = { + value: { + ...initialProfileStates.profile, + selectedSite: null, + rates: [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + label: '5-7 Business Days', + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + ], + }, + onChange: jest.fn(), + }; + + const wrapper = renderShallowWithProps(customProps); + const site = wrapper.find('.profiles-rates__input-group--site'); + site.simulate('change', { + name: 'Kith', + url: 'https://kith.com', + }); + expect(customProps.onChange).toHaveBeenCalledWith( + { + field: RATES_FIELDS.SITE, + value: { + name: 'Kith', + url: 'https://kith.com', + }, + }, + PROFILE_FIELDS.EDIT_SELECTED_SITE, + ); + }); + + test('default', () => { + const customProps = { + value: { + ...initialProfileStates.profile, + selectedSite: { + label: 'Kith', + value: 'https://kith.com', + }, + rates: [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + label: '5-7 Business Days', + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + ], + }, + onChange: jest.fn(), + }; + + const wrapper = renderShallowWithProps(customProps); + const name = wrapper.find('.profiles-rates__input-group--name'); + name.simulate('change', { + label: '5-7 Business Days', + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }); + expect(customProps.onChange).toHaveBeenCalledWith( + { + field: RATES_FIELDS.RATE, + value: { + site: { + label: 'Kith', + value: 'https://kith.com', + }, + rate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + }, + PROFILE_FIELDS.EDIT_RATES, + ); + }); + + test('deleting when there is no selectedSite', () => { + const customProps = { + value: { + ...initialProfileStates.profile, + selectedSite: null, + rates: [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + ], + }, + onDeleteShippingRate: jest.fn(), + }; + + const wrapper = renderShallowWithProps(customProps); + const deleteButton = wrapper.find('.profiles-rates__input-group--delete'); + deleteButton.simulate('click'); + expect(customProps.onDeleteShippingRate).not.toHaveBeenCalled(); + }); + + test('deleting when there is a selected rate', () => { + const customProps = { + value: { + ...initialProfileStates.profile, + selectedSite: { + label: 'Kith', + value: 'https://kith.com', + }, + rates: [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + label: '5-7 Business Days', + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + ], + }, + onDeleteShippingRate: jest.fn(), + }; + + const wrapper = renderShallowWithProps(customProps); + const deleteButton = wrapper.find('.profiles-rates__input-group--delete'); + deleteButton.simulate('click'); + expect(customProps.onDeleteShippingRate).toHaveBeenCalledWith( + { + label: 'Kith', + value: 'https://kith.com', + }, + { + label: '5-7 Business Days', + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ); + }); + + test('deleting when there is not a selected rate', () => { + const customProps = { + value: { + ...initialProfileStates.profile, + selectedSite: { + label: 'Kith', + value: 'https://kith.com', + }, + rates: [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + ], + }, + onDeleteShippingRate: jest.fn(), + }; + + const wrapper = renderShallowWithProps(customProps); + const deleteButton = wrapper.find('.profiles-rates__input-group--delete'); + deleteButton.simulate('click'); + expect(customProps.onDeleteShippingRate).not.toHaveBeenCalled(); + }); + }); + + test('map state to props returns correct structure', () => { + const profile = { + ...initialProfileStates.profile, + payment: { + ...initialProfileStates.payment, + email: 'test@email.com', + }, + }; + const actual = mapStateToProps(initialState, { profileToEdit: profile }); + expect(actual.value).toEqual(profile); + expect(actual.theme).toEqual(initialState.theme); + }); + + test('map dispatch to props returns correct structure', () => { + const dispatch = jest.fn(); + const tempProfile = { + ...initialProfileStates.profile, + id: 1, + editId: 1, + }; + const expectedActions = [ + profileActions.edit( + tempProfile.id, + PROFILE_FIELDS.EDIT_SELECTED_SITE, + { + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + label: '5-7 Business Days', + }, + RATES_FIELDS.SITE, + ), + profileActions.deleteRate( + { + label: 'Kith', + value: 'https://kith.com', + apiKey: '08430b96c47dd2ac8e17e305db3b71e8', + auth: false, + supported: true, + }, + { + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + label: '5-7 Business Days', + }, + ), + ]; + const actual = mapDispatchToProps(dispatch, { + profileToEdit: { + ...initialProfileStates.profile, + id: 1, + editId: 1, + }, + }); + actual.onChange( + { + field: RATES_FIELDS.SITE, + value: { + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + label: '5-7 Business Days', + }, + }, + PROFILE_FIELDS.EDIT_SELECTED_SITE, + ); + actual.onDeleteShippingRate( + { + label: 'Kith', + value: 'https://kith.com', + apiKey: '08430b96c47dd2ac8e17e305db3b71e8', + auth: false, + supported: true, + }, + { + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + label: '5-7 Business Days', + }, + ); + + expect(dispatch).toHaveBeenCalledTimes(2); + expectedActions.forEach((action, n) => { + expect(dispatch).toHaveBeenNthCalledWith( + n + 1, + typeof action !== 'function' ? action : expect.any(Function), + ); + }); + }); +}); diff --git a/packages/frontend/src/__tests__/settings/defaults.test.jsx b/packages/frontend/src/__tests__/settings/defaults.test.jsx index 43fcc917..164c8f74 100644 --- a/packages/frontend/src/__tests__/settings/defaults.test.jsx +++ b/packages/frontend/src/__tests__/settings/defaults.test.jsx @@ -23,7 +23,7 @@ describe('', () => { onSaveDefaults={renderProps.onSaveDefaults} onClearDefaults={renderProps.onClearDefaults} profiles={renderProps.profiles} - settings={renderProps.settings} + defaults={renderProps.defaults} onKeyPress={renderProps.onKeyPress} theme={renderProps.theme} errors={renderProps.errors} @@ -38,8 +38,8 @@ describe('', () => { { ...initialProfileStates.profile, id: 2, profileName: 'profile2' }, { ...initialProfileStates.profile, id: 3, profileName: 'profile3' }, ], - settings: { - ...initialSettingsStates.settings, + defaults: { + ...initialSettingsStates.settings.defaults, }, errors: { ...initialSettingsStates.settingsErrors.defaults, @@ -64,12 +64,9 @@ describe('', () => { it('renders with non-default props', () => { const customProps = { - settings: { - ...initialSettingsStates.settings, - defaults: { - profile: { ...initialProfileStates.profile, id: 1, profileName: 'profile1' }, - sizes: ['4', '4.5', '5'], - }, + defaults: { + profile: { ...initialProfileStates.profile, id: 1, profileName: 'profile1' }, + sizes: ['4', '4.5', '5'], }, }; const wrapper = renderShallowWithProps(customProps); @@ -150,12 +147,10 @@ describe('', () => { describe('handles', () => { test('saving defaults', () => { const customProps = { - settings: { - ...initialSettingsStates.settings, - defaults: { - profile: { ...initialProfileStates.profile, id: 1, profileName: 'profile1' }, - sizes: [{ value: '4', label: '4.0' }], - }, + ...initialSettingsStates.settings, + defaults: { + profile: { ...initialProfileStates.profile, id: 1, profileName: 'profile1' }, + sizes: [{ value: '4', label: '4.0' }], }, onSaveDefaults: jest.fn(), onKeyPress: jest.fn(), @@ -165,7 +160,7 @@ describe('', () => { saveButton.simulate('keyPress'); expect(customProps.onKeyPress).toHaveBeenCalled(); saveButton.simulate('click'); - expect(customProps.onSaveDefaults).toHaveBeenCalledWith(customProps.settings.defaults); + expect(customProps.onSaveDefaults).toHaveBeenCalledWith(customProps.defaults); }); test('clearing defaults', () => { @@ -208,7 +203,7 @@ describe('', () => { }; const expected = { profiles: state.profiles, - settings: state.settings, + defaults: state.settings.defaults, errors: state.settings.errors, theme: state.theme, }; @@ -242,6 +237,9 @@ describe('', () => { sizes: [], }), ); - expect(dispatch).toHaveBeenNthCalledWith(3, settingsActions.clear(SETTINGS_FIELDS.CLEAR)); + expect(dispatch).toHaveBeenNthCalledWith( + 3, + settingsActions.clearDefaults(SETTINGS_FIELDS.CLEAR_DEFAULTS), + ); }); }); diff --git a/packages/frontend/src/__tests__/settings/settings.test.jsx b/packages/frontend/src/__tests__/settings/settings.test.jsx index 18ee9cc6..8c9f49cd 100644 --- a/packages/frontend/src/__tests__/settings/settings.test.jsx +++ b/packages/frontend/src/__tests__/settings/settings.test.jsx @@ -1,179 +1,13 @@ /* global describe it expect beforeEach afterEach jest test */ import React from 'react'; import { shallow } from 'enzyme'; - -import { SettingsPrimitive, mapStateToProps, mapDispatchToProps } from '../../settings/settings'; -import { SETTINGS_FIELDS, settingsActions } from '../../state/actions'; -import initialSettingsStates from '../../state/initial/settings'; -import { initialState } from '../../state/migrators'; +import { SettingsPrimitive } from '../../settings/settings'; describe('', () => { - let defaultProps; - - const renderShallowWithProps = customProps => { - const renderProps = { - ...defaultProps, - ...customProps, - }; - return shallow( - , - ); - }; - - beforeEach(() => { - defaultProps = { - onSettingsChange: () => {}, - settings: { - ...initialSettingsStates.settings, - }, - errors: { - ...initialSettingsStates.settingsErrors, - }, - theme: { - ...initialState.theme, - }, - }; - }); + const renderShallowWithProps = () => shallow(); - it('renders with required props', () => { + it('renders as pure component', () => { const wrapper = renderShallowWithProps(); - expect(wrapper.find('.settings__button--open-captcha')).toHaveLength(1); - expect(wrapper.find('.settings__button--close-captcha')).toHaveLength(1); - expect(wrapper.find('.settings__input-group--monitor-delay')).toHaveLength(1); - expect(wrapper.find('.settings__input-group--error-delay')).toHaveLength(1); - }); - - it('renders with non-default props', () => { - const customProps = { - settings: { - ...initialSettingsStates.settings, - }, - }; - const wrapper = renderShallowWithProps(customProps); - expect(wrapper.find('.settings__button--open-captcha')).toHaveLength(1); - expect(wrapper.find('.settings__button--close-captcha')).toHaveLength(1); - expect(wrapper.find('.settings__input-group--monitor-delay')).toHaveLength(1); - expect(wrapper.find('.settings__input-group--error-delay')).toHaveLength(1); - }); - - describe('calls correct handler when editing', () => { - test('error delay', () => { - const customProps = { - onSettingsChange: jest.fn(), - }; - const wrapper = renderShallowWithProps(customProps); - const errorInput = wrapper.find('.settings__input-group--error-delay'); - expect(errorInput.prop('value')).toBe(1500); - expect(errorInput.prop('onChange')).toBeDefined(); - - errorInput.simulate('change', { target: { value: '1000' } }); - expect(customProps.onSettingsChange).toHaveBeenCalledWith({ - field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, - value: '1000', - }); - }); - - test('monitor delay', () => { - const customProps = { - onSettingsChange: jest.fn(), - }; - const wrapper = renderShallowWithProps(customProps); - const monitorInput = wrapper.find('.settings__input-group--monitor-delay'); - expect(monitorInput.prop('value')).toBe(1500); - expect(monitorInput.prop('onChange')).toBeDefined(); - - monitorInput.simulate('change', { target: { value: '1000' } }); - expect(customProps.onSettingsChange).toHaveBeenCalledWith({ - field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, - value: '1000', - }); - }); - }); - - describe('when window.Bridge is available', () => { - let Bridge; - let wrapper; - - beforeEach(() => { - Bridge = { - closeAllCaptchaWindows: jest.fn(), - launchCaptchaHarvester: jest.fn(), - }; - global.window.Bridge = Bridge; - wrapper = renderShallowWithProps(); - }); - - afterEach(() => { - delete global.window.Bridge; - }); - - test('launch captcha button calls correct function', () => { - const button = wrapper.find('.settings__button--open-captcha'); - button.simulate('click'); - expect(Bridge.launchCaptchaHarvester).toHaveBeenCalled(); - }); - - test('close all harvester button calls correct function', () => { - const button = wrapper.find('.settings__button--close-captcha'); - button.simulate('click'); - expect(Bridge.closeAllCaptchaWindows).toHaveBeenCalled(); - }); - }); - - describe('when window.Bridge is unavailable', () => { - let wrapper; - let consoleSpy; - - beforeEach(() => { - wrapper = renderShallowWithProps(); - consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - - afterEach(() => { - consoleSpy.mockRestore(); - }); - - test('launch harvester button displays error', () => { - const button = wrapper.find('.settings__button--open-captcha'); - button.simulate('click'); - expect(consoleSpy).toHaveBeenCalled(); - }); - }); - - test('map state to props returns the correct structure', () => { - const state = { - settings: { - ...initialSettingsStates.settings, - }, - theme: { - ...initialState.theme, - }, - extra: 'fields', - that: "aren't included", - }; - const expected = { - settings: state.settings, - theme: state.theme, - errors: state.settings.errors, - }; - expect(mapStateToProps(state)).toEqual(expected); - }); - - test('map dispatch to props returns the correct structure', () => { - const dispatch = jest.fn(); - const actual = mapDispatchToProps(dispatch); - actual.onSettingsChange({ - field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, - value: 1000, - }); - expect(dispatch).toHaveBeenNthCalledWith( - 1, - settingsActions.edit(SETTINGS_FIELDS.EDIT_ERROR_DELAY, 1000), - ); + expect(wrapper.find('.container.settings')).toHaveLength(1); }); }); diff --git a/packages/frontend/src/__tests__/settings/shippingManager.test.jsx b/packages/frontend/src/__tests__/settings/shippingManager.test.jsx new file mode 100644 index 00000000..a3cc3729 --- /dev/null +++ b/packages/frontend/src/__tests__/settings/shippingManager.test.jsx @@ -0,0 +1,331 @@ +/* global describe it expect beforeEach jest test */ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { + ShippingManagerPrimitive, + mapStateToProps, + mapDispatchToProps, +} from '../../settings/shippingManager'; +import { SETTINGS_FIELDS, settingsActions } from '../../state/actions'; +import { initialState } from '../../state/migrators'; +import initialSettingsStates from '../../state/initial/settings'; +import initialProfileStates from '../../state/initial/profiles'; +import getAllSupportedSitesSorted from '../../constants/getAllSites'; + +import getByTestId from '../../__testUtils__/getByTestId'; + +describe('', () => { + let defaultProps; + + const getWrapper = customProps => { + const renderProps = { + ...defaultProps, + ...customProps, + }; + return shallow( + , + ); + }; + + const renderShallowWithProps = customProps => getWrapper(customProps); + + beforeEach(() => { + defaultProps = { + theme: initialState.theme, + profiles: [ + { ...initialProfileStates.profile, id: 1, profileName: 'profile1' }, + { ...initialProfileStates.profile, id: 2, profileName: 'profile2' }, + { ...initialProfileStates.profile, id: 3, profileName: 'profile3' }, + ], + shipping: initialSettingsStates.settings.shipping, + errors: initialSettingsStates.settings.shipping.errors, + onSettingsChange: () => {}, + onFetchShippingMethods: () => {}, + onClearShippingFields: () => {}, + onKeyPress: () => {}, + }; + }); + + it('should render with required props', () => { + const wrapper = renderShallowWithProps(); + expect(wrapper.find('.settings--shipping-manager__input-group--product')).toHaveLength(1); + expect(wrapper.find('.settings--shipping-manager__input-group--name')).toHaveLength(1); + expect(wrapper.find('.settings--shipping-manager__input-group--profile')).toHaveLength(1); + expect(wrapper.find('.settings--shipping-manager__input-group--site')).toHaveLength(1); + expect(wrapper.find('.settings--shipping-manager__input-group--username')).toHaveLength(1); + expect(wrapper.find('.settings--shipping-manager__input-group--password')).toHaveLength(1); + }); + + it('renders with non-default props', () => { + const customProps = { + shipping: { + ...initialSettingsStates.shipping, + profile: { ...initialProfileStates.profile, id: 1, profileName: 'profile1' }, + name: 'test', + }, + }; + const wrapper = renderShallowWithProps(customProps); + expect(wrapper.find('.settings--shipping-manager__input-group--product')).toHaveLength(1); + expect(wrapper.find('.settings--shipping-manager__input-group--name')).toHaveLength(1); + expect(wrapper.find('.settings--shipping-manager__input-group--profile')).toHaveLength(1); + expect(wrapper.find('.settings--shipping-manager__input-group--site')).toHaveLength(1); + expect(wrapper.find('.settings--shipping-manager__input-group--profile').prop('value')).toEqual( + { + value: 1, + label: 'profile1', + }, + ); + expect(wrapper.find('.settings--shipping-manager__input-group--name').prop('value')).toEqual( + 'test', + ); + wrapper.find('.settings--shipping-manager__input-group--fetch').simulate('keyPress'); + }); + + describe('calls correct handler when editing', () => { + test('shipping product', () => { + const customProps = { + onSettingsChange: jest.fn(), + }; + const wrapper = renderShallowWithProps(customProps); + const productSelector = wrapper.find('.settings--shipping-manager__input-group--product'); + expect(productSelector.prop('value')).toEqual(''); + expect(productSelector.prop('onChange')).toBeDefined(); + + productSelector.simulate('change', { target: { value: '+test' } }); + expect(customProps.onSettingsChange).toHaveBeenCalledWith({ + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: '+test', + }); + }); + + test('shipping rate name', () => { + const customProps = { + onSettingsChange: jest.fn(), + }; + const wrapper = renderShallowWithProps(customProps); + const nameSelector = wrapper.find('.settings--shipping-manager__input-group--name'); + expect(nameSelector.prop('value')).toEqual(''); + expect(nameSelector.prop('onChange')).toBeDefined(); + + nameSelector.simulate('change', { target: { value: 'test' } }); + expect(customProps.onSettingsChange).toHaveBeenCalledWith({ + field: SETTINGS_FIELDS.EDIT_SHIPPING_RATE_NAME, + value: 'test', + }); + }); + + test('shipping profile', () => { + const customProps = { + onSettingsChange: jest.fn(), + }; + const wrapper = renderShallowWithProps(customProps); + const profileSelector = wrapper.find('.settings--shipping-manager__input-group--profile'); + expect(profileSelector.prop('value')).toBeNull(); + expect(profileSelector.prop('onChange')).toBeDefined(); + expect(profileSelector.prop('options')).toEqual([ + { value: 1, label: 'profile1' }, + { value: 2, label: 'profile2' }, + { value: 3, label: 'profile3' }, + ]); + + profileSelector.simulate('change', { value: 1 }); + expect(customProps.onSettingsChange).toHaveBeenCalledWith({ + field: SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE, + value: defaultProps.profiles[0], + }); + + customProps.onSettingsChange.mockClear(); + profileSelector.simulate('change', { value: 4 }); + expect(customProps.onSettingsChange).toHaveBeenCalledWith({ + field: SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE, + value: undefined, + }); + }); + + test('shipping site', () => { + const customProps = { + onSettingsChange: jest.fn(), + }; + const wrapper = renderShallowWithProps(customProps); + const siteSelector = wrapper.find('.settings--shipping-manager__input-group--site'); + expect(siteSelector.prop('value')).toBeNull(); + expect(siteSelector.prop('onChange')).toBeDefined(); + expect(siteSelector.prop('options')).toEqual(getAllSupportedSitesSorted()); + + siteSelector.simulate('change', { + label: 'Nebula Bots', + value: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + localCheckout: false, + special: false, + auth: false, + }); + + expect(customProps.onSettingsChange).toHaveBeenCalledWith({ + field: SETTINGS_FIELDS.EDIT_SHIPPING_SITE, + value: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + localCheckout: false, + special: false, + auth: false, + }, + }); + }); + + test('shipping username', () => { + const customProps = { + onSettingsChange: jest.fn(), + }; + const wrapper = renderShallowWithProps(customProps); + const usernameSelector = wrapper.find('.settings--shipping-manager__input-group--username'); + expect(usernameSelector.prop('value')).toEqual(''); + expect(usernameSelector.prop('onChange')).toBeDefined(); + + usernameSelector.simulate('change', { target: { value: 'test' } }); + expect(customProps.onSettingsChange).toHaveBeenCalledWith({ + field: SETTINGS_FIELDS.EDIT_SHIPPING_USERNAME, + value: 'test', + }); + }); + + test('shipping password', () => { + const customProps = { + onSettingsChange: jest.fn(), + }; + const wrapper = renderShallowWithProps(customProps); + const passwordSelector = wrapper.find('.settings--shipping-manager__input-group--password'); + expect(passwordSelector.prop('value')).toEqual(''); + expect(passwordSelector.prop('onChange')).toBeDefined(); + + passwordSelector.simulate('change', { target: { value: 'test' } }); + expect(customProps.onSettingsChange).toHaveBeenCalledWith({ + field: SETTINGS_FIELDS.EDIT_SHIPPING_PASSWORD, + value: 'test', + }); + }); + }); + + describe('fetch shipping button', () => { + test('renders properly when status is idle', () => { + const wrapper = renderShallowWithProps(); + const fetchButton = getByTestId(wrapper, 'ShippingManager.button.fetch'); + expect(fetchButton.prop('disabled')).toBeFalsy(); + }); + + test('renders properly when status is inprogress', () => { + const shipping = { ...initialSettingsStates.shipping }; + shipping.status = 'inprogress'; + const wrapper = renderShallowWithProps({ shipping }); + const fetchButton = getByTestId(wrapper, 'ShippingManager.button.fetch'); + expect(fetchButton.prop('disabled')).toBeTruthy(); + }); + }); + + test('map state to props returns the correct structure', () => { + const state = { + profiles: [ + { ...initialProfileStates.profile, id: 1, profileName: 'profile1' }, + { ...initialProfileStates.profile, id: 2, profileName: 'profile2' }, + { ...initialProfileStates.profile, id: 3, profileName: 'profile3' }, + ], + settings: { + ...initialSettingsStates, + shipping: { + ...initialSettingsStates.shipping, + product: { + ...initialSettingsStates.shipping.product, + raw: '+test', + }, + name: 'test', + profile: { ...initialProfileStates.profile, id: 1, profileName: 'profile1' }, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + localCheckout: false, + special: false, + auth: false, + }, + username: '', + password: '', + errors: { + ...initialSettingsStates.shippingErrors, + }, + }, + errors: { + ...initialSettingsStates.settingsErrors, + }, + }, + theme: { + ...initialState.theme, + }, + extra: 'fields', + that: "aren't included", + }; + const expected = { + profiles: state.profiles, + shipping: state.settings.shipping, + errors: state.settings.shipping.errors, + theme: state.theme, + }; + expect(mapStateToProps(state)).toEqual(expected); + }); + + test('map dispatch to props should return correct structure', () => { + const dispatch = jest.fn(); + const task = { + ...initialSettingsStates.shipping, + product: { + ...initialSettingsStates.shipping.product, + raw: '+test', + pos_keywords: ['test'], + }, + site: { + ...initialSettingsStates.shipping.site, + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + localCheckout: false, + special: false, + auth: false, + }, + profile: { + ...initialSettingsStates.shipping.profile, + id: 1, + profileName: 'test', + }, + }; + + const expectedActions = [ + settingsActions.edit(SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, '+test'), + settingsActions.fetch(task), + settingsActions.clearShipping(SETTINGS_FIELDS.CLEAR_SHIPPING_FIELDS), + ]; + + const actual = mapDispatchToProps(dispatch); + actual.onSettingsChange({ + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: '+test', + }); + actual.onFetchShippingMethods(task); + actual.onClearShippingFields(SETTINGS_FIELDS.CLEAR_SHIPPING_FIELDS); + expectedActions.forEach((action, n) => { + expect(dispatch).toHaveBeenNthCalledWith( + n + 1, + typeof action !== 'function' ? action : expect.any(Function), + ); + }); + }); +}); diff --git a/packages/frontend/src/__tests__/settings/webhooks.test.jsx b/packages/frontend/src/__tests__/settings/webhooks.test.jsx index d9a07fc9..675a7105 100644 --- a/packages/frontend/src/__tests__/settings/webhooks.test.jsx +++ b/packages/frontend/src/__tests__/settings/webhooks.test.jsx @@ -19,7 +19,8 @@ describe('', () => { onSettingsChange={renderProps.onSettingsChange} onTestDiscord={renderProps.onTestDiscord} onTestSlack={renderProps.onTestSlack} - settings={renderProps.settings} + discord={renderProps.discord} + slack={renderProps.slack} onKeyPress={renderProps.onKeyPress} errors={renderProps.errors} />, @@ -28,9 +29,8 @@ describe('', () => { beforeEach(() => { defaultProps = { - settings: { - ...initialSettingsStates.settings, - }, + discord: initialSettingsStates.settings.discord, + slack: initialSettingsStates.settings.slack, errors: { ...initialSettingsStates.settingsErrors.defaults, }, @@ -49,19 +49,22 @@ describe('', () => { it('renders with non-default props', () => { const customProps = { - settings: { - ...initialSettingsStates.settings, - discord: 'discordTest', - slack: 'slackTest', - }, + discord: 'discordTest', + slack: 'slackTest', + onKeyPress: jest.fn(), }; const wrapper = renderShallowWithProps(customProps); expect(wrapper.find('.settings__input-group--webhook__discord')).toHaveLength(1); + expect(wrapper.find('.settings__input-group--button-discord')).toHaveLength(1); expect(wrapper.find('.settings__input-group--webhook__slack')).toHaveLength(1); + expect(wrapper.find('.settings__input-group--button-slack')).toHaveLength(1); expect(wrapper.find('.settings__input-group--webhook__discord').prop('value')).toBe( 'discordTest', ); expect(wrapper.find('.settings__input-group--webhook__slack').prop('value')).toBe('slackTest'); + wrapper.find('.settings__input-group--button-discord').simulate('keyPress'); + wrapper.find('.settings__input-group--button-slack').simulate('keyPress'); + expect(customProps.onKeyPress).toHaveBeenCalled(); }); describe('calls correct handler when editing', () => { @@ -98,19 +101,54 @@ describe('', () => { }); }); + describe('calls correct onClick handler when testing webhooks', () => { + test('discord', () => { + const customProps = { + onTestDiscord: jest.fn(), + discord: 'test', + }; + const wrapper = renderShallowWithProps(customProps); + const discordInput = wrapper.find('.settings__input-group--webhook__discord'); + const discordButton = wrapper.find('.settings__input-group--button-discord'); + expect(discordInput.prop('value')).toBe('test'); + expect(discordInput.prop('onChange')).toBeDefined(); + expect(discordButton.prop('onClick')).toBeDefined(); + + discordButton.simulate('click'); + expect(customProps.onTestDiscord).toHaveBeenCalledWith('test'); + }); + + test('slack', () => { + const customProps = { + onTestSlack: jest.fn(), + slack: 'test', + }; + const wrapper = renderShallowWithProps(customProps); + const slackInput = wrapper.find('.settings__input-group--webhook__slack'); + const slackButton = wrapper.find('.settings__input-group--button-slack'); + expect(slackInput.prop('value')).toBe('test'); + expect(slackInput.prop('onChange')).toBeDefined(); + expect(slackButton.prop('onClick')).toBeDefined(); + + slackButton.simulate('click'); + expect(customProps.onTestSlack).toHaveBeenCalledWith('test'); + }); + }); + test('map state to props returns the correct structure', () => { const state = { settings: { ...initialSettingsStates.settings, }, + discord: initialSettingsStates.settings.discord, + slack: initialSettingsStates.settings.slack, extra: 'fields', that: "aren't included", }; const expected = { - profiles: state.profiles, - settings: state.settings, + discord: state.discord, + slack: state.slack, errors: state.settings.errors, - theme: state.theme, }; expect(mapStateToProps(state)).toEqual(expected); }); @@ -122,11 +160,21 @@ describe('', () => { field: SETTINGS_FIELDS.EDIT_SLACK, value: 'test', }); + actual.onSettingsChange({ + field: SETTINGS_FIELDS.EDIT_DISCORD, + value: 'test', + }); actual.onTestSlack('test'); + actual.onTestDiscord('test'); expect(dispatch).toHaveBeenNthCalledWith( 1, settingsActions.edit(SETTINGS_FIELDS.EDIT_SLACK, 'test'), ); - expect(dispatch).toHaveBeenNthCalledWith(2, settingsActions.test('test', 'slack')); + expect(dispatch).toHaveBeenNthCalledWith( + 2, + settingsActions.edit(SETTINGS_FIELDS.EDIT_DISCORD, 'test'), + ); + expect(dispatch).toHaveBeenNthCalledWith(3, settingsActions.test('test', 'slack')); + expect(dispatch).toHaveBeenNthCalledWith(4, settingsActions.test('test', 'discord')); }); }); diff --git a/packages/frontend/src/__tests__/state/actions/profileActions.test.js b/packages/frontend/src/__tests__/state/actions/profileActions.test.js index ee9e19cc..b9e18a0a 100644 --- a/packages/frontend/src/__tests__/state/actions/profileActions.test.js +++ b/packages/frontend/src/__tests__/state/actions/profileActions.test.js @@ -37,12 +37,36 @@ describe('profile actions', () => { await asyncProfileTests(action, expectedActions); }); + it('should create an error action when adding an invalid profile', async () => { + const action = profileActions.add(null); + const expectedActions = [ + { + type: PROFILE_ACTIONS.ERROR, + action: PROFILE_ACTIONS.ADD, + error: new Error('Invalid profile!'), + }, + ]; + await asyncProfileTests(action, expectedActions); + }); + it('should create an action to remove a profile', async () => { const action = profileActions.remove(42); const expectedActions = [{ type: PROFILE_ACTIONS.REMOVE, id: 42 }]; await asyncProfileTests(action, expectedActions); }); + it('should create an error action when removing an invalid profile', async () => { + const action = profileActions.remove(null); + const expectedActions = [ + { + type: PROFILE_ACTIONS.ERROR, + action: PROFILE_ACTIONS.REMOVE, + error: new Error('Invalid profile!'), + }, + ]; + await asyncProfileTests(action, expectedActions); + }); + it('should create an action to edit a profile', () => { const action = profileActions.edit(23, 'test_field', 'test_value', 'test_subField'); const expectedActions = [ @@ -84,6 +108,18 @@ describe('profile actions', () => { await asyncProfileTests(action, expectedActions); }); + it('should create an error action when updating an invalid profile', async () => { + const action = profileActions.update(null); + const expectedActions = [ + { + type: PROFILE_ACTIONS.ERROR, + action: PROFILE_ACTIONS.UPDATE, + error: new Error('Invalid profile!'), + }, + ]; + await asyncProfileTests(action, expectedActions); + }); + it('should create an action to handle a profile error', () => { const action = profileActions.error(PROFILE_ACTIONS.ADD, 'error_message'); const expectedActions = [ diff --git a/packages/frontend/src/__tests__/state/actions/settingsActions.test.js b/packages/frontend/src/__tests__/state/actions/settingsActions.test.js index 4090f375..e32f76d6 100644 --- a/packages/frontend/src/__tests__/state/actions/settingsActions.test.js +++ b/packages/frontend/src/__tests__/state/actions/settingsActions.test.js @@ -1,15 +1,22 @@ /* global describe it expect beforeEach */ import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; import * as actions from '../../../state/actions'; import { initialState } from '../../../state/migrators'; +import initialProfileStates from '../../../state/initial/profiles'; +import initialSettingsStates from '../../../state/initial/settings'; const { settingsActions, SETTINGS_ACTIONS } = actions; -const _createMockStore = configureMockStore(); +const _createMockStore = configureMockStore([thunk]); describe('settings actions', () => { let mockStore; + beforeEach(() => { + mockStore = _createMockStore(initialState); + }); + const settingsTests = (action, expectedActions) => { mockStore.dispatch(action); const actualActions = mockStore.getActions(); @@ -17,8 +24,206 @@ describe('settings actions', () => { expect(actualActions).toEqual(expectedActions); }; - beforeEach(() => { - mockStore = _createMockStore(initialState); + const asyncSettingsTests = async (action, expectedActions) => { + await mockStore.dispatch(action); + const actualActions = mockStore.getActions(); + expect(actualActions.length).toBe(expectedActions.length); + expect(actualActions).toEqual(expectedActions); + }; + + describe('fetch shipping', () => { + describe('when window.Bridge is defined', () => { + afterEach(() => { + if (global.window.Bridge) { + delete global.window.Bridge; + } + }); + + it('should dispatch a successful action when shipping form is valid', async () => { + const Bridge = { + startShippingRatesRunner: jest.fn(() => ({ + rates: [], + selectedRate: 'test', + })), + }; + global.window.Bridge = Bridge; + const action = settingsActions.fetch({ + ...initialSettingsStates.shipping, + name: 'test', + product: { + ...initialSettingsStates.shipping.product, + raw: '+test', + pos_keywords: ['test'], + }, + profile: { ...initialProfileStates.profile, id: 1, profileName: 'test' }, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + auth: false, + supported: true, + }, + username: '', + password: '', + }); + const expectedActions = [ + { + type: SETTINGS_ACTIONS.SETUP_SHIPPING, + }, + { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + rates: [], + selectedRate: 'test', + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + auth: false, + supported: true, + }, + }, + }, + { + type: SETTINGS_ACTIONS.CLEANUP_SHIPPING, + success: true, + }, + ]; + await asyncSettingsTests(action, expectedActions); + }); + + it('should dispatch a error action when shipping form is invalid', async () => { + const Bridge = { + startShippingRatesRunner: jest.fn(() => ({ + shippingRates: [], + selectedRate: 'test', + })), + }; + global.window.Bridge = Bridge; + const action = settingsActions.fetch({ + ...initialSettingsStates.shipping, + }); + const expectedActions = [ + { + type: SETTINGS_ACTIONS.SETUP_SHIPPING, + }, + { + type: SETTINGS_ACTIONS.ERROR, + action: SETTINGS_ACTIONS.FETCH_SHIPPING, + error: expect.any(Error), + }, + { + type: SETTINGS_ACTIONS.CLEANUP_SHIPPING, + success: false, + }, + ]; + await asyncSettingsTests(action, expectedActions); + }); + }); + + describe('when window.Bridge is undefined', () => { + it('should dispatch an error action when shipping form is valid', async () => { + const action = settingsActions.fetch({ + ...initialSettingsStates.shipping, + name: 'test', + product: { ...initialSettingsStates.shipping.product, raw: '+test' }, + profile: { ...initialProfileStates.profile, id: 1, profileName: 'test' }, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + auth: false, + supported: true, + }, + username: '', + password: '', + }); + const expectedActions = [ + { + type: SETTINGS_ACTIONS.SETUP_SHIPPING, + }, + { + type: SETTINGS_ACTIONS.ERROR, + action: SETTINGS_ACTIONS.FETCH_SHIPPING, + error: expect.any(Error), + }, + { + type: SETTINGS_ACTIONS.CLEANUP_SHIPPING, + success: false, + }, + ]; + await asyncSettingsTests(action, expectedActions); + }); + + it('should dispatch an error action when shipping in invalid', async () => { + const action = settingsActions.fetch({ + ...initialSettingsStates.shipping, + product: { + raw: 'wrong keywords format', + }, + }); + const expectedActions = [ + { + type: SETTINGS_ACTIONS.SETUP_SHIPPING, + }, + { + type: SETTINGS_ACTIONS.ERROR, + action: SETTINGS_ACTIONS.FETCH_SHIPPING, + error: expect.any(Error), + }, + { + type: SETTINGS_ACTIONS.CLEANUP_SHIPPING, + success: false, + }, + ]; + await asyncSettingsTests(action, expectedActions); + }); + }); + }); + + describe('stop shipping', () => { + describe('when window.Bridge is defined', () => { + afterEach(() => { + if (global.window.Bridge) { + delete global.window.Bridge; + } + }); + + it('should dispatch stop shipping runner', async () => { + const Bridge = { + stopShippingRatesRunner: jest.fn(), + }; + global.window.Bridge = Bridge; + + const action = settingsActions.stop(); + const expectedActions = [ + { + type: SETTINGS_ACTIONS.STOP_SHIPPING, + }, + ]; + await asyncSettingsTests(action, expectedActions); + }); + }); + + describe('when window.Bridge is undefined', () => { + afterEach(() => { + if (global.window.Bridge) { + delete global.window.Bridge; + } + }); + + it('should dispatch stop shipping runner', async () => { + const action = settingsActions.stop(); + const expectedActions = [ + { + type: SETTINGS_ACTIONS.CLEANUP_SHIPPING, + success: true, + }, + ]; + await asyncSettingsTests(action, expectedActions); + }); + }); }); it('should create an action to edit settings', () => { @@ -41,8 +246,8 @@ describe('settings actions', () => { }); it('should create an action to clear defaults', () => { - const action = settingsActions.clear(); - const expectedActions = [{ type: SETTINGS_ACTIONS.CLEAR }]; + const action = settingsActions.clearDefaults(); + const expectedActions = [{ type: SETTINGS_ACTIONS.CLEAR_DEFAULTS }]; settingsTests(action, expectedActions); }); }); diff --git a/packages/frontend/src/__tests__/state/actions/taskActions.test.js b/packages/frontend/src/__tests__/state/actions/taskActions.test.js index 5140f2c1..0c6e37b0 100644 --- a/packages/frontend/src/__tests__/state/actions/taskActions.test.js +++ b/packages/frontend/src/__tests__/state/actions/taskActions.test.js @@ -263,6 +263,48 @@ describe('task actions', () => { }); }); + describe('should handle copy task action', () => { + test('with valid task data', async () => { + const task = { + ...initialTaskState, + product: { + raw: '+good, +keywords', + }, + edits: { + product: {}, + sizes: [], + username: 'testing', + password: 'testing', + profile: {}, + }, + }; + const action = taskActions.copy(task); + const expectedActions = [ + { + type: TASK_ACTIONS.COPY, + response: { + task: { + ...task, + }, + }, + }, + ]; + await asyncTaskTests(action, expectedActions); + }); + + test('with no task', async () => { + const action = taskActions.copy(undefined); + const expectedActions = [ + { + type: TASK_ACTIONS.ERROR, + action: TASK_ACTIONS.COPY, + error: expect.any(Error), + }, + ]; + await asyncTaskTests(action, expectedActions); + }); + }); + it('should create an action to select a task', () => { const action = taskActions.select('task_object'); const expectedActions = [ @@ -512,7 +554,7 @@ describe('task actions', () => { }, ]; await asyncTaskTests(action, expectedActions); - expect(Bridge.startTasks).toHaveBeenCalledWith(task); + expect(Bridge.startTasks).toHaveBeenCalledWith(task, {}); expect(Bridge.addProxies).toHaveBeenCalled(); delete global.window.Bridge; }); diff --git a/packages/frontend/src/__tests__/state/middleware/settings/proxyAttributeValidationMiddleware.test.js b/packages/frontend/src/__tests__/state/middleware/settings/proxyAttributeValidationMiddleware.test.js new file mode 100644 index 00000000..6d02a7b8 --- /dev/null +++ b/packages/frontend/src/__tests__/state/middleware/settings/proxyAttributeValidationMiddleware.test.js @@ -0,0 +1,108 @@ +/* global describe expect it test jest */ +import proxyAttributeValidationMiddleware from '../../../../state/middleware/settings/proxyAttributeValidationMiddleware'; +import { SETTINGS_ACTIONS, SETTINGS_FIELDS } from '../../../../state/actions'; +import initialSettingsStates from '../../../../state/initial/settings'; + +describe('proxy attribute validatation middleware', () => { + const create = () => { + const store = { + getState: jest.fn(() => {}), + dispatch: jest.fn(), + }; + const next = jest.fn(); + + const invoke = action => proxyAttributeValidationMiddleware(store)(next)(action); + + return { store, next, invoke }; + }; + + it('should pass through thunks', () => { + const { next, invoke } = create(); + const thunk = jest.fn(); + invoke(thunk); + expect(thunk).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(thunk); + }); + + it('should pass through actions without type', () => { + const { store, next, invoke } = create(); + const action = {}; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + }); + + it("should pass through actions that aren't a settings edit type", () => { + const { store, next, invoke } = create(); + const action = { type: 'NOT_A_SETTINGS_ACTION' }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + it('should not respond to invalid fields', () => { + const { store, next, invoke } = create(); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: 'INVALID_FIELD', + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + describe('for edit proxies', () => { + it('should not pass an errors object if proxies are valid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { ...initialSettingsStates.settingsErrors }; + delete expectedErrors.proxies; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_PROXIES, + value: ['123.123.123.123:8080', '123.123.123.123:8080:user:pass'], + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.proxies).not.toBeDefined(); + }); + + it('should pass an errors object if some proxies are invalid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_PROXIES, + value: [ + '123.123.123.123:8080', + '123.123.123.123:8080:user:pass', + 'invalid', + '123.123.123.123:8080:', + '123.123.123.123:8080:user:', + '123.123.123.123:8080:user:pass:invalid', + ], + }; + const expectedAction = { + ...action, + errors: { + ...initialSettingsStates.settingsErrors, + proxies: [2, 3, 4, 5], + }, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(expectedAction); + expect(store.getState).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/frontend/src/__tests__/state/middleware/settings/settingsAttributeValidationMiddleware.test.js b/packages/frontend/src/__tests__/state/middleware/settings/settingsAttributeValidationMiddleware.test.js index c76b7254..02e5244a 100644 --- a/packages/frontend/src/__tests__/state/middleware/settings/settingsAttributeValidationMiddleware.test.js +++ b/packages/frontend/src/__tests__/state/middleware/settings/settingsAttributeValidationMiddleware.test.js @@ -42,6 +42,121 @@ describe('settings attribute validatation middleware', () => { expect(nextAction.errors).not.toBeDefined(); }); + describe('should pass through actions that edit field', () => { + test('proxies', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_PROXIES }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('error delay', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_ERROR_DELAY }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('monitor delay', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('default profile', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_DEFAULT_PROFILE }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('default sizes', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_DEFAULT_SIZES }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('shipping product', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('shipping rate name', () => { + const { store, next, invoke } = create(); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_RATE_NAME, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('shipping profile', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('shipping site', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_SHIPPING_SITE }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('shipping username', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_SHIPPING_USERNAME }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('shipping password', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_SHIPPING_PASSWORD }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + }); + it('should not respond to invalid fields', () => { const { store, next, invoke } = create(); const action = { @@ -55,49 +170,91 @@ describe('settings attribute validatation middleware', () => { expect(nextAction.errors).not.toBeDefined(); }); - describe('for edit proxies', () => { - it('should not pass an errors object if proxies are valid', () => { + describe('for edit discord', () => { + it('should not pass an errors object if input is valid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settingsErrors, + discord: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_DISCORD, + value: + 'https://discordapp.com/api/webhooks/492205269942796298/H0giZl0oansmwORuW4ifx-fwKWbcVPXR23FMoWkgrBfIqQErIKBiNQznQIHQuj-EPXic', + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.discord).toEqual(false); + }); + + it('should pass an errors object if input is invalid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_DISCORD, + value: 'invalid', + }; + const expectedAction = { + ...action, + errors: { + ...initialSettingsStates.settingsErrors, + discord: true, + }, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(expectedAction); + expect(store.getState).toHaveBeenCalled(); + }); + }); + + describe('for edit slack', () => { + it('should not pass an errors object if input is valid', () => { const { store, next, invoke } = create(); store.getState = jest.fn(() => ({ settings: initialSettingsStates.settings, })); - const expectedErrors = { ...initialSettingsStates.settingsErrors }; - delete expectedErrors.proxies; + const expectedErrors = { + ...initialSettingsStates.settingsErrors, + slack: false, + }; const action = { type: SETTINGS_ACTIONS.EDIT, - field: SETTINGS_FIELDS.EDIT_PROXIES, - value: ['123.123.123.123:8080', '123.123.123.123:8080:user:pass'], + field: SETTINGS_FIELDS.EDIT_SLACK, + value: 'https://hooks.slack.com/services/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', errors: expectedErrors, }; invoke(action); expect(next).toHaveBeenCalledWith(action); expect(store.getState).toHaveBeenCalled(); const nextAction = next.mock.calls[0][0]; - expect(nextAction.errors.proxies).not.toBeDefined(); + expect(nextAction.errors.slack).toEqual(false); }); - it('should pass an errors object if some proxies are invalid', () => { + it('should pass an errors object if input is invalid', () => { const { store, next, invoke } = create(); store.getState = jest.fn(() => ({ settings: initialSettingsStates.settings, })); const action = { type: SETTINGS_ACTIONS.EDIT, - field: SETTINGS_FIELDS.EDIT_PROXIES, - value: [ - '123.123.123.123:8080', - '123.123.123.123:8080:user:pass', - 'invalid', - '123.123.123.123:8080:', - '123.123.123.123:8080:user:', - '123.123.123.123:8080:user:pass:invalid', - ], + field: SETTINGS_FIELDS.EDIT_SLACK, + value: 'invalid', }; const expectedAction = { ...action, errors: { ...initialSettingsStates.settingsErrors, - proxies: [2, 3, 4, 5], + slack: true, }, }; invoke(action); diff --git a/packages/frontend/src/__tests__/state/middleware/settings/shippingFormAttributeValidationMiddleware.test.js b/packages/frontend/src/__tests__/state/middleware/settings/shippingFormAttributeValidationMiddleware.test.js new file mode 100644 index 00000000..33cc4aa8 --- /dev/null +++ b/packages/frontend/src/__tests__/state/middleware/settings/shippingFormAttributeValidationMiddleware.test.js @@ -0,0 +1,502 @@ +/* global describe expect it test jest */ +import shippingFormAttributeValidationMiddleware from '../../../../state/middleware/settings/shippingFormAttributeValidationMiddleware'; +import { SETTINGS_ACTIONS, SETTINGS_FIELDS } from '../../../../state/actions'; +import initialSettingsStates from '../../../../state/initial/settings'; +import initialProfileStates from '../../../../state/initial/profiles'; + +describe('settings attribute validatation middleware', () => { + const create = () => { + const store = { + getState: jest.fn(() => {}), + dispatch: jest.fn(), + }; + const next = jest.fn(); + + const invoke = action => shippingFormAttributeValidationMiddleware(store)(next)(action); + + return { store, next, invoke }; + }; + + it('should pass through thunks', () => { + const { next, invoke } = create(); + const thunk = jest.fn(); + invoke(thunk); + expect(thunk).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(thunk); + }); + + it('should pass through actions without type', () => { + const { store, next, invoke } = create(); + const action = {}; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + }); + + it("should pass through actions that aren't a settings edit type", () => { + const { store, next, invoke } = create(); + const action = { type: 'NOT_A_SETTINGS_ACTION' }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + describe('should pass through actions that edit field', () => { + test('proxies', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_PROXIES }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('error delay', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_ERROR_DELAY }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('monitor delay', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('default profile', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_DEFAULT_PROFILE }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('default sizes', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_DEFAULT_SIZES }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('discord', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_DISCORD }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + test('slack', () => { + const { store, next, invoke } = create(); + const action = { type: SETTINGS_ACTIONS.EDIT, field: SETTINGS_FIELDS.EDIT_SLACK }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + }); + + it('should not respond to invalid fields', () => { + const { store, next, invoke } = create(); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: 'INVALID_FIELD', + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).not.toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors).not.toBeDefined(); + }); + + describe('shipping form product', () => { + it('should not pass an errors object if input is valid keywords', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settings.shipping.errors, + product: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: '+test', + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.product).toEqual(false); + }); + + it('should not pass an errors object if input is an object and valid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settings.shipping.errors, + product: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: { + raw: '+test', + }, + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.product).toEqual(false); + }); + + it('should not pass an errors object if input is a valid variant', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settings.shipping.errors, + product: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: 1234123141241, + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.product).toEqual(false); + }); + + it('should not pass an errors object if input is a valid url', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settings.shipping.errors, + product: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: 'https://example.com', + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.product).toEqual(false); + }); + + it('should pass an errors object if input is undefined', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: undefined, + }; + const expectedAction = { + ...action, + errors: { + ...initialSettingsStates.settings.shipping.errors, + product: true, + }, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(expectedAction); + expect(store.getState).toHaveBeenCalled(); + }); + + it('should pass an errors object if input is not valid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: 'test', + }; + const expectedAction = { + ...action, + errors: { + ...initialSettingsStates.settings.shipping.errors, + product: true, + }, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(expectedAction); + expect(store.getState).toHaveBeenCalled(); + }); + }); + + describe('shipping form rate name', () => { + it('should not pass an errors object if input is valid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settings.shipping.errors, + name: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_RATE_NAME, + value: 'test name', + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.name).toEqual(false); + }); + + it('should pass an errors object if input is invalid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_RATE_NAME, + value: '', + }; + const expectedAction = { + ...action, + errors: { + ...initialSettingsStates.settings.shipping.errors, + name: true, + }, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(expectedAction); + expect(store.getState).toHaveBeenCalled(); + }); + }); + + describe('shipping form profile', () => { + it('should not pass an errors object if input is valid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settings.shipping.errors, + profile: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE, + value: { ...initialProfileStates.profile, id: 1, profileName: 'test' }, + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.profile).toEqual(false); + }); + + it('should pass an errors object if input is invalid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE, + value: 'invalid', + }; + const expectedAction = { + ...action, + errors: { + ...initialSettingsStates.settings.shipping.errors, + profile: true, + }, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(expectedAction); + expect(store.getState).toHaveBeenCalled(); + }); + }); + + describe('shipping form site', () => { + it('should not pass an errors object if input is valid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settings.shipping.errors, + site: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_SITE, + value: { + name: 'Kith', + url: 'https://kith.com', + apiKey: '08430b96c47dd2ac8e17e305db3b71e8', + auth: false, + supported: true, + }, + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.site).toEqual(false); + }); + + it('should pass an errors object if input is invalid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_SITE, + value: 'invalid', + }; + const expectedAction = { + ...action, + errors: { + ...initialSettingsStates.settings.shipping.errors, + site: true, + }, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(expectedAction); + expect(store.getState).toHaveBeenCalled(); + }); + }); + + describe('shipping form username', () => { + it('should not pass an errors object if input is valid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settings.shipping.errors, + username: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_USERNAME, + value: 'test', + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.username).toEqual(false); + }); + + it('should pass an errors object if input is invalid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_SITE, + value: '', + }; + const expectedAction = { + ...action, + errors: { + ...initialSettingsStates.settings.shipping.errors, + site: true, + }, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(expectedAction); + expect(store.getState).toHaveBeenCalled(); + }); + }); + + describe('shipping form password', () => { + it('should not pass an errors object if input is valid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const expectedErrors = { + ...initialSettingsStates.settings.shipping.errors, + password: false, + }; + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PASSWORD, + value: 'test', + errors: expectedErrors, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(action); + expect(store.getState).toHaveBeenCalled(); + const nextAction = next.mock.calls[0][0]; + expect(nextAction.errors.password).toEqual(false); + }); + + it('should pass an errors object if input is invalid', () => { + const { store, next, invoke } = create(); + store.getState = jest.fn(() => ({ + settings: initialSettingsStates.settings, + })); + const action = { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PASSWORD, + value: '', + }; + const expectedAction = { + ...action, + errors: { + ...initialSettingsStates.settings.shipping.errors, + password: true, + }, + }; + invoke(action); + expect(next).toHaveBeenCalledWith(expectedAction); + expect(store.getState).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/frontend/src/__tests__/state/migrators/v0.2.0.test.js b/packages/frontend/src/__tests__/state/migrators/v0.2.0.test.js new file mode 100644 index 00000000..117f6790 --- /dev/null +++ b/packages/frontend/src/__tests__/state/migrators/v0.2.0.test.js @@ -0,0 +1,116 @@ +import migrator from '../../../state/migrators/v0.2.0'; +import initialState from '../../../state/migrators/v0.2.0/state'; +import prevState from '../../../state/migrators/v0.1.1/state'; + +describe('v0.2.0 migrator', () => { + const initialShippingRatesState = []; + + const initialShippingManagerErrorState = { + profile: null, + name: null, + site: null, + product: null, + username: null, + password: null, + }; + + const addShippingRatesToProfile = { + ...prevState.currentProfile, + rates: initialShippingRatesState, + selectedSite: null, + }; + + const initialShippingManagerState = { + name: '', + profile: addShippingRatesToProfile, + site: { + name: null, + url: null, + supported: null, + apiKey: null, + auth: null, + }, + product: { + raw: '', + variant: null, + pos_keywords: null, + neg_keywords: null, + url: null, + }, + username: '', + password: '', + errors: initialShippingManagerErrorState, + }; + + const updateTask = ({ profile, edits, ...rest }) => ({ + ...rest, + profile: addShippingRatesToProfile, + edits: { + ...edits, + profile: edits.profile ? addShippingRatesToProfile : edits.profile, + }, + }); + + test('should return initial state if no state is given', () => { + const migrated = migrator(); + expect(migrated).toEqual(initialState); + }); + + test('should update lower versions', () => { + const initial = { + ...initialState, + version: '0.1.1', + }; + const migrated = migrator(initial); + expect(migrated).toEqual(initialState); + }); + + test('should not update higher versions', () => { + const start = { + ...initialState, + version: '0.2.1', + }; + const migrated = migrator(start); + expect(migrated).toEqual(start); + }); + + test('should add shipping rates, selected site, and initial state for shipping manager', () => { + const editedTask = { + ...prevState.newTask, + edits: { + ...prevState.newTask.edits, + profile: prevState.currentProfile, + }, + }; + const start = { + ...prevState, + profiles: [prevState.currentProfile], + tasks: [editedTask], + }; + const expected = { + ...initialState, + profiles: [addShippingRatesToProfile], + currentProfile: addShippingRatesToProfile, + selectedProfile: addShippingRatesToProfile, + tasks: [updateTask(editedTask)], + newTask: updateTask(initialState.newTask), + selectedTask: updateTask(initialState.selectedTask), + settings: { + ...initialState.settings, + shipping: { + ...initialShippingManagerState, + }, + defaults: { + ...initialState.settings.defaults, + profile: addShippingRatesToProfile, + edits: { + ...initialState.settings.defaults.edits, + profile: addShippingRatesToProfile, + }, + }, + }, + }; + const migrated = migrator(start); + expect(migrated).toEqual(expected); + }); +}); diff --git a/packages/frontend/src/__tests__/state/migrators/v0.2.1.test.js b/packages/frontend/src/__tests__/state/migrators/v0.2.1.test.js new file mode 100644 index 00000000..18f7a867 --- /dev/null +++ b/packages/frontend/src/__tests__/state/migrators/v0.2.1.test.js @@ -0,0 +1,44 @@ +/* global describe it test expect jest */ +import migrator from '../../../state/migrators/v0.2.1'; +import initialState from '../../../state/migrators/v0.2.1/state'; +import prevState from '../../../state/migrators/v0.2.0/state'; + +describe('v0.2.1 migrator', () => { + it('should return initial state if no state is given', () => { + const migrated = migrator(); + expect(migrated).toEqual(initialState); + }); + + it('should update lower versions', () => { + const start = { + ...initialState, + version: '0.2.0', + }; + const migrated = migrator(start); + expect(migrated).toEqual(initialState); + }); + + it('should not update higher versions', () => { + const start = { + ...initialState, + version: '0.3.0', + }; + const migrated = migrator(start); + expect(migrated).toEqual(start); + }); + + it('should add shipping status if not present', () => { + const start = { ...prevState }; + const migrated = migrator(start); + expect(migrated.settings.shipping.status).toBe('idle'); + expect(migrated).toEqual(initialState); + }); + + it('should not change shipping status if it is present', () => { + const start = { ...initialState }; + start.settings.shipping.status = 'inprogress'; + const migrated = migrator(start); + expect(migrated.settings.shipping.status).toBe('inprogress'); + expect(migrated).toEqual(start); + }); +}); diff --git a/packages/frontend/src/__tests__/state/reducers/profile/currentProfileReducer.test.js b/packages/frontend/src/__tests__/state/reducers/profile/currentProfileReducer.test.js index 0ef4f8b1..e0d52fa8 100644 --- a/packages/frontend/src/__tests__/state/reducers/profile/currentProfileReducer.test.js +++ b/packages/frontend/src/__tests__/state/reducers/profile/currentProfileReducer.test.js @@ -2,6 +2,7 @@ import { currentProfileReducer } from '../../../../state/reducers/profiles/profileReducer'; import initialProfileStates from '../../../../state/initial/profiles'; import { PROFILE_ACTIONS, PAYMENT_FIELDS, PROFILE_FIELDS } from '../../../../state/actions'; +import { SETTINGS_ACTIONS } from '../../../../state/actions/settings/settingsActions'; describe('current profile reducer', () => { it('should return initial state', () => { @@ -308,6 +309,837 @@ describe('current profile reducer', () => { }); }); + describe('should handle delete rate action', () => { + test('when site and rate are not given', () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: PROFILE_ACTIONS.DELETE_RATE, + }); + expect(actual).toEqual(initial); + }); + + test('when selectedRate is provided rate', () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + { + name: '6-10 Business Days', + rate: 'shopify-UPS%20GROUND%20(6-10%20business%20days)-5.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '6-10 Business Days', + rate: 'shopify-UPS%20GROUND%20(6-10%20business%20days)-5.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: PROFILE_ACTIONS.DELETE_RATE, + site: { + label: 'Kith', + value: 'https://kith.com', + }, + rate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }); + expect(actual).toEqual(expected); + }); + + test('when rate is last rate in list', () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + selectedSite: null, + rates: [ + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: PROFILE_ACTIONS.DELETE_RATE, + site: { + label: 'Kith', + value: 'https://kith.com', + }, + rate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }); + expect(actual).toEqual(expected); + }); + }); + + describe('should handle fetch shipping actions', () => { + test('when there are errors', () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + errors: {}, + }); + expect(actual).toEqual(initial); + }); + + test('when there are no rates returned', () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + rates: undefined, + selectedRate: {}, + }, + }); + expect(actual).toEqual(initial); + }); + + test('when there is no selectedRate returned', () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + rates: [], + selectedRate: undefined, + }, + }); + expect(actual).toEqual(initial); + }); + + test('when the current profile is not the fetched profile', () => { + const initial = { + ...initialProfileStates.profile, + editId: 2, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + rates: [], + selectedRate: {}, + }, + }); + expect(actual).toEqual(initial); + }); + + test('when the rates object is the first for the given site', () => { + const initial = { + ...initialProfileStates.profile, + editId: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + editId: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '', + supported: true, + auth: false, + }, + rates: [ + { + title: 'Free Shipping', + id: 'test', + }, + ], + selectedRate: { + title: 'Free Shipping', + id: 'test', + }, + }, + }); + expect(actual).toEqual(expected); + }); + + describe('when the rates object is not the first for the given site', () => { + test('should filter out duplicate entries', () => { + const initial = { + ...initialProfileStates.profile, + editId: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + editId: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '', + supported: true, + auth: false, + }, + rates: [ + { + title: 'Free Shipping', + id: 'test', + }, + ], + selectedRate: { + title: 'Free Shipping', + id: 'test', + }, + }, + }); + expect(actual).toEqual(expected); + }); + + test('should add new entries', () => { + const initial = { + ...initialProfileStates.profile, + editId: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + editId: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + { + name: 'Not Free Shipping', + rate: 'test-rate', + }, + ], + selectedRate: { + name: 'Not Free Shipping', + rate: 'test-rate', + }, + }, + ], + }; + + const actual = currentProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '', + supported: true, + auth: false, + }, + rates: [ + { + title: 'Free Shipping', + id: 'test', + }, + { + title: 'Not Free Shipping', + id: 'test-rate', + }, + ], + selectedRate: { + title: 'Not Free Shipping', + id: 'test-rate', + }, + }, + }); + expect(actual).toEqual(expected); + }); + }); + }); + describe('should not respond to', () => { const _testNoopResponse = type => { const actual = currentProfileReducer(initialProfileStates.profile, { diff --git a/packages/frontend/src/__tests__/state/reducers/profile/profileListReducer.test.js b/packages/frontend/src/__tests__/state/reducers/profile/profileListReducer.test.js index 567b7059..f1b5ceaf 100644 --- a/packages/frontend/src/__tests__/state/reducers/profile/profileListReducer.test.js +++ b/packages/frontend/src/__tests__/state/reducers/profile/profileListReducer.test.js @@ -1,7 +1,7 @@ /* global describe it expect test jest */ import profileListReducer from '../../../../state/reducers/profiles/profileListReducer'; import initialProfileStates from '../../../../state/initial/profiles'; -import { PROFILE_ACTIONS, PROFILE_FIELDS } from '../../../../state/actions'; +import { PROFILE_ACTIONS, SETTINGS_ACTIONS, PROFILE_FIELDS } from '../../../../state/actions'; describe('profile list reducer', () => { it('should return initial state', () => { @@ -376,6 +376,260 @@ describe('profile list reducer', () => { }); }); + describe('should handle fetch shipping', () => { + test('when invalid action is passed', () => { + const start = [ + { + ...initialProfileStates.profile, + id: 1, + profileName: 'testing', + }, + ]; + const actual = profileListReducer(start, {}); + expect(actual).toEqual(start); + }); + + test('when action that contains errors is passed', () => { + const start = [ + { + ...initialProfileStates.profile, + id: 1, + profileName: 'testing', + }, + ]; + const actual = profileListReducer(start, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: {}, + rates: [], + selectedRate: {}, + }, + errors: {}, + }); + expect(actual).toEqual(start); + }); + + test('when malformed action is passed', () => { + const start = [ + { + ...initialProfileStates.profile, + id: 1, + profileName: 'testing', + }, + ]; + const actual = profileListReducer(start, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: {}, + rates: undefined, + selectedRate: {}, + }, + }); + expect(actual).toEqual(start); + }); + + test('when profile is removed mid-thunk', () => { + const start = [ + { + ...initialProfileStates.profile, + id: 2, + profileName: 'testing', + }, + ]; + + const actual = profileListReducer(start, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, // index 1 was there before, now it's not + site: {}, + rates: [], + selectedRate: {}, + }, + }); + expect(actual).toEqual(start); + }); + + test('when profile rates for site is not found', () => { + const start = [ + { + ...initialProfileStates.profile, + id: 1, + profileName: 'testing', + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }, + ]; + + const expected = [ + { + ...initialProfileStates.profile, + id: 1, + profileName: 'testing', + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }, + ]; + + const actual = profileListReducer(start, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '', + supported: true, + auth: false, + }, + rates: [ + { + title: 'Free Shipping', + id: 'test', + }, + ], + selectedRate: { + title: 'Free Shipping', + id: 'test', + }, + }, + }); + expect(actual).toEqual(expected); + }); + + test('when profile rates for site is found', () => { + const start = [ + { + ...initialProfileStates.profile, + id: 1, + profileName: 'testing', + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }, + ]; + + const expected = [ + { + ...initialProfileStates.profile, + id: 1, + profileName: 'testing', + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + { + name: 'Not Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Not Free Shipping', + rate: 'test', + }, + }, + ], + }, + ]; + + const actual = profileListReducer(start, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: { + name: 'Kith', + url: 'https://kith.com', + apiKey: '', + supported: true, + auth: false, + }, + rates: [ + { + title: 'Not Free Shipping', + id: 'test', + }, + ], + selectedRate: { + title: 'Not Free Shipping', + id: 'test', + }, + }, + }); + expect(actual).toEqual(expected); + }); + }); + it('should handle error', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); profileListReducer(initialProfileStates.list, { diff --git a/packages/frontend/src/__tests__/state/reducers/profile/profileReducer.test.js b/packages/frontend/src/__tests__/state/reducers/profile/profileReducer.test.js index 08ae1ad7..4379081e 100644 --- a/packages/frontend/src/__tests__/state/reducers/profile/profileReducer.test.js +++ b/packages/frontend/src/__tests__/state/reducers/profile/profileReducer.test.js @@ -3,12 +3,14 @@ import { profileReducer } from '../../../../state/reducers/profiles/profileReduc import initialProfileStates from '../../../../state/initial/profiles'; import { mapProfileFieldToKey, + mapRateFieldToKey, mapLocationFieldToKey, mapPaymentFieldToKey, PROFILE_ACTIONS, LOCATION_FIELDS, PAYMENT_FIELDS, PROFILE_FIELDS, + RATES_FIELDS, } from '../../../../state/actions'; describe('profile reducer', () => { @@ -24,7 +26,9 @@ describe('profile reducer', () => { ...initialProfileStates.profile, [mapProfileFieldToKey[field]]: { ...initialFieldState, - [mapLocationFieldToKey[subField] || mapPaymentFieldToKey[subField]]: + [mapLocationFieldToKey[subField] || + mapRateFieldToKey[subField] || + mapPaymentFieldToKey[subField]]: subField === LOCATION_FIELDS.PROVINCE ? value.province : value, }, }; @@ -355,6 +359,181 @@ describe('profile reducer', () => { }); }); + describe('rates', () => { + test(`when valid site selection`, () => { + const initial = { + ...initialProfileStates.profile, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + const actual = profileReducer(initial, { + type: PROFILE_ACTIONS.EDIT, + field: PROFILE_FIELDS.EDIT_SELECTED_SITE, + subField: RATES_FIELDS.SITE, + value: { + name: 'Kith', + url: 'https://kith.com', + }, + }); + expect(actual).toEqual(expected); + }); + + test(`when valid rate selection`, () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + const actual = profileReducer(initial, { + type: PROFILE_ACTIONS.EDIT, + field: PROFILE_FIELDS.EDIT_RATES, + subField: RATES_FIELDS.RATE, + value: { + site: { + value: 'https://kith.com', + label: 'Kith', + }, + rate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + }); + expect(actual).toEqual(expected); + }); + }); + test('billing matches shipping action', () => { const start = { ...initialProfileStates.profile, diff --git a/packages/frontend/src/__tests__/state/reducers/profile/ratesReducer.test.js b/packages/frontend/src/__tests__/state/reducers/profile/ratesReducer.test.js new file mode 100644 index 00000000..d29f099e --- /dev/null +++ b/packages/frontend/src/__tests__/state/reducers/profile/ratesReducer.test.js @@ -0,0 +1,294 @@ +/* global describe it expect */ +import ratesReducer from '../../../../state/reducers/profiles/ratesReducer'; +import initialProfileStates from '../../../../state/initial/profiles'; +import { RATES_FIELDS } from '../../../../state/actions'; + +describe('rates reducer', () => { + it('should return initial state', () => { + const actual = ratesReducer(undefined, {}); + expect(actual).toEqual(initialProfileStates.rates); + }); + + describe('should handle rate action', () => { + test('when action is undefined', () => { + const initial = [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ]; + + const actual = ratesReducer(initial, undefined); + + expect(actual).toEqual(initial); + }); + + test('when action type is undefined', () => { + const initial = [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ]; + + const actual = ratesReducer(initial, { + type: undefined, + }); + + expect(actual).toEqual(initial); + }); + + test('when action is valid object but no site object is found', () => { + const initial = [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ]; + + const actual = ratesReducer(initial, { + type: RATES_FIELDS.RATE, + site: { + value: 'https://nebulabots.com', + label: 'Nebula Bots', + }, + }); + + expect(actual).toEqual(initial); + }); + + test('when action is valid object and site object is found', () => { + const initial = [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ]; + + const expected = [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ]; + + const actual = ratesReducer(initial, { + type: RATES_FIELDS.RATE, + site: { + value: 'https://kith.com', + label: 'Nebula Bots', + }, + rate: { + label: '5-7 Business Days', + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }); + + expect(actual).toEqual(expected); + }); + }); + + describe('should handle default action', () => { + test('when mapping to field is properly found', () => { + const initial = [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ]; + + const actual = ratesReducer(initial, { + type: RATES_FIELDS.NAME, + value: { + label: '5-7 Business Days', + value: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }); + + expect(actual).toEqual(initial); + }); + + test('when mapping to field is not found', () => { + const initial = [ + ...initialProfileStates.rates, + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ]; + + const actual = ratesReducer(initial, { + type: 'INVALID', + }); + + expect(actual).toEqual(initial); + }); + }); + + it('should not respond to invalid actions', () => { + const actual = ratesReducer(initialProfileStates.rates, { + type: 'INVALID', + }); + expect(actual).toEqual(initialProfileStates.rates); + }); +}); diff --git a/packages/frontend/src/__tests__/state/reducers/profile/selectedProfileReducer.test.js b/packages/frontend/src/__tests__/state/reducers/profile/selectedProfileReducer.test.js index 3ea91300..3f51506d 100644 --- a/packages/frontend/src/__tests__/state/reducers/profile/selectedProfileReducer.test.js +++ b/packages/frontend/src/__tests__/state/reducers/profile/selectedProfileReducer.test.js @@ -1,7 +1,7 @@ /* global describe it expect test */ import { selectedProfileReducer } from '../../../../state/reducers/profiles/profileReducer'; import initialProfileStates from '../../../../state/initial/profiles'; -import { PROFILE_ACTIONS } from '../../../../state/actions'; +import { PROFILE_ACTIONS, SETTINGS_ACTIONS } from '../../../../state/actions'; describe('selected profile reducer', () => { it('should return initial state', () => { @@ -177,6 +177,624 @@ describe('selected profile reducer', () => { }); }); + describe('should handle fetch shipping actions', () => { + test('when there are errors', () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = selectedProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + errors: {}, + }); + expect(actual).toEqual(initial); + }); + + test('when there are no rates returned', () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = selectedProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + rates: undefined, + selectedRate: {}, + }, + }); + expect(actual).toEqual(initial); + }); + + test('when there is no selectedRate returned', () => { + const initial = { + ...initialProfileStates.profile, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = selectedProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + rates: [], + selectedRate: undefined, + }, + }); + expect(actual).toEqual(initial); + }); + + test('when the current profile is not the fetched profile', () => { + const initial = { + ...initialProfileStates.profile, + id: 2, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: null, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: null, + }, + ], + }; + + const actual = selectedProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + rates: [], + selectedRate: {}, + }, + }); + expect(actual).toEqual(initial); + }); + + test('when the rates object is the first for the given site', () => { + const initial = { + ...initialProfileStates.profile, + id: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + id: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }; + + const actual = selectedProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '', + supported: true, + auth: false, + }, + rates: [ + { + title: 'Free Shipping', + id: 'test', + }, + ], + selectedRate: { + title: 'Free Shipping', + id: 'test', + }, + }, + }); + expect(actual).toEqual(expected); + }); + + describe('when the rates object is not the first for the given site', () => { + test('should filter out duplicate entries', () => { + const initial = { + ...initialProfileStates.profile, + id: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + id: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }; + + const actual = selectedProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '', + supported: true, + auth: false, + }, + rates: [ + { + title: 'Free Shipping', + id: 'test', + }, + ], + selectedRate: { + title: 'Free Shipping', + id: 'test', + }, + }, + }); + expect(actual).toEqual(expected); + }); + + test('should add new entries', () => { + const initial = { + ...initialProfileStates.profile, + id: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + ], + selectedRate: { + name: 'Free Shipping', + rate: 'test', + }, + }, + ], + }; + + const expected = { + ...initialProfileStates.profile, + id: 1, + selectedSite: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + site: { + name: 'Kith', + url: 'https://kith.com', + }, + rates: [ + { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + ], + selectedRate: { + name: '5-7 Business Days', + rate: 'shopify-UPS%20GROUND%20(5-7%20business%20days)-10.00', + }, + }, + { + site: { + name: '12 AM RUN', + url: 'https://12amrun.com', + }, + rates: [ + { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + ], + selectedRate: { + name: 'Small Goods Shipping', + rate: 'shopify-Small%20Goods%20Shipping-7.00', + }, + }, + { + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + }, + rates: [ + { + name: 'Free Shipping', + rate: 'test', + }, + { + name: 'Not Free Shipping', + rate: 'test-rate', + }, + ], + selectedRate: { + name: 'Not Free Shipping', + rate: 'test-rate', + }, + }, + ], + }; + + const actual = selectedProfileReducer(initial, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + id: 1, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '', + supported: true, + auth: false, + }, + rates: [ + { + title: 'Free Shipping', + id: 'test', + }, + { + title: 'Not Free Shipping', + id: 'test-rate', + }, + ], + selectedRate: { + title: 'Not Free Shipping', + id: 'test-rate', + }, + }, + }); + expect(actual).toEqual(expected); + }); + }); + }); + describe('should not respond to', () => { const _testNoopResponse = type => { const actual = selectedProfileReducer(initialProfileStates.profile, { diff --git a/packages/frontend/src/__tests__/state/reducers/settings/settingsReducer.test.js b/packages/frontend/src/__tests__/state/reducers/settings/settingsReducer.test.js index bab62927..4e60f0b8 100644 --- a/packages/frontend/src/__tests__/state/reducers/settings/settingsReducer.test.js +++ b/packages/frontend/src/__tests__/state/reducers/settings/settingsReducer.test.js @@ -1,6 +1,6 @@ /* global describe it test expect beforeAll */ import settingsReducer from '../../../../state/reducers/settings/settingsReducer'; -import { SETTINGS_ACTIONS, SETTINGS_FIELDS } from '../../../../state/actions'; +import { SETTINGS_ACTIONS, SETTINGS_FIELDS, PROFILE_ACTIONS } from '../../../../state/actions'; import initialSettingsStates from '../../../../state/initial/settings'; describe('settings reducer', () => { @@ -172,88 +172,547 @@ describe('settings reducer', () => { }); }); - test('discord settings action', () => { + describe('discord settings action', () => { + test('should save field edit', () => { + const expected = { + ...initialSettingsStates.settings, + discord: 'discord_test', + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_DISCORD, + value: 'discord_test', + }); + expect(actual).toEqual(expected); + }); + + test('should update webhook when window.Bridge is defined', () => { + const Bridge = { + updateHook: jest.fn(), + }; + global.window.Bridge = Bridge; + const expected = { + ...initialSettingsStates.settings, + discord: 'test', + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_DISCORD, + value: 'test', + }); + expect(actual).toEqual(expected); + expect(Bridge.updateHook).toHaveBeenCalled(); + delete global.window.Bridge; + }); + + test('should not update webhook when window.Bridge is undefined', () => { + const Bridge = { + updateHook: jest.fn(), + }; + const expected = { + ...initialSettingsStates.settings, + discord: 'test', + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_DISCORD, + value: 'test', + }); + expect(actual).toEqual(expected); + expect(Bridge.updateHook).not.toHaveBeenCalled(); + }); + }); + + describe('slack settings action', () => { + test('should save field edit', () => { + const expected = { + ...initialSettingsStates.settings, + slack: 'slack', + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SLACK, + value: 'slack', + }); + expect(actual).toEqual(expected); + }); + + test('should update webhook when window.Bridge is defined', () => { + const Bridge = { + updateHook: jest.fn(), + }; + global.window.Bridge = Bridge; + const expected = { + ...initialSettingsStates.settings, + slack: 'test', + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SLACK, + value: 'test', + }); + expect(actual).toEqual(expected); + expect(Bridge.updateHook).toHaveBeenCalled(); + delete global.window.Bridge; + }); + + test('should not update webhook when window.Bridge is undefined', () => { + const Bridge = { + updateHook: jest.fn(), + }; + const expected = { + ...initialSettingsStates.settings, + slack: 'test', + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SLACK, + value: 'test', + }); + expect(actual).toEqual(expected); + expect(Bridge.updateHook).not.toHaveBeenCalled(); + }); + }); + + describe('monitor delay action', () => { + test('when value is numerical non-null', () => { + const expected = { + ...initialSettingsStates.settings, + monitorDelay: 1500, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: '1500', + }); + expect(actual).toEqual(expected); + }); + + test('when value is empty', () => { + const expected = { + ...initialSettingsStates.settings, + monitorDelay: 0, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: '', + }); + expect(actual).toEqual(expected); + }); + + test('when value is null', () => { + const expected = { + ...initialSettingsStates.settings, + monitorDelay: 0, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: null, + }); + expect(actual).toEqual(expected); + }); + + test('when value is non-numerical', () => { + const expected = { + ...initialSettingsStates.settings, + monitorDelay: 1500, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: 'test', + }); + expect(actual).toEqual(expected); + }); + + test('when window.Bridge is defined', () => { + const Bridge = { + changeDelay: jest.fn(), + }; + global.window.Bridge = Bridge; + const expected = { + ...initialSettingsStates.settings, + monitorDelay: 2500, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: 2500, + }); + expect(actual).toEqual(expected); + expect(Bridge.changeDelay).toHaveBeenCalled(); + delete global.window.Bridge; + }); + + test('when window.Bridge is undefined', () => { + const Bridge = { + changeDelay: jest.fn(), + }; + const expected = { + ...initialSettingsStates.settings, + monitorDelay: 2500, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: 2500, + }); + expect(actual).toEqual(expected); + expect(Bridge.changeDelay).not.toHaveBeenCalled(); + }); + }); + + describe('error delay action', () => { + test('when value is numerical non-null', () => { + const expected = { + ...initialSettingsStates.settings, + errorDelay: 1500, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: '1500', + }); + expect(actual).toEqual(expected); + }); + + test('when value is empty', () => { + const expected = { + ...initialSettingsStates.settings, + errorDelay: 0, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: '', + }); + expect(actual).toEqual(expected); + }); + + test('when value is null', () => { + const expected = { + ...initialSettingsStates.settings, + errorDelay: 0, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: null, + }); + expect(actual).toEqual(expected); + }); + + test('when value is non-numerical', () => { + const expected = { + ...initialSettingsStates.settings, + errorDelay: 1500, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: 'test', + }); + expect(actual).toEqual(expected); + }); + + test('when window.Bridge is defined', () => { + const Bridge = { + changeDelay: jest.fn(), + }; + global.window.Bridge = Bridge; + const expected = { + ...initialSettingsStates.settings, + errorDelay: 2500, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: 2500, + }); + expect(actual).toEqual(expected); + expect(Bridge.changeDelay).toHaveBeenCalled(); + delete global.window.Bridge; + }); + + test('when window.Bridge is undefined', () => { + const Bridge = { + changeDelay: jest.fn(), + }; + const expected = { + ...initialSettingsStates.settings, + errorDelay: 2500, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: 2500, + }); + expect(actual).toEqual(expected); + expect(Bridge.changeDelay).not.toHaveBeenCalled(); + }); + }); + + describe('should respond to test webhook action', () => { + describe('discord', () => { + test('when window.Bridge is defined', () => { + const Bridge = { + sendWebhookTestMessage: jest.fn(), + }; + global.window.Bridge = Bridge; + settingsReducer(undefined, { + type: SETTINGS_ACTIONS.TEST, + hook: 'test', + test_hook_type: 'discord', + }); + expect(Bridge.sendWebhookTestMessage).toHaveBeenCalled(); + delete global.window.Bridge; + }); + + test('when window.Bridge is undefined', () => { + const Bridge = { + sendWebhookTestMessage: jest.fn(), + }; + settingsReducer(undefined, { + type: SETTINGS_ACTIONS.TEST, + hook: 'test', + test_hook_type: 'discord', + }); + expect(Bridge.sendWebhookTestMessage).not.toHaveBeenCalled(); + }); + }); + + describe('slack', () => { + test('when window.Bridge is defined', () => { + const Bridge = { + sendWebhookTestMessage: jest.fn(), + }; + global.window.Bridge = Bridge; + settingsReducer(undefined, { + type: SETTINGS_ACTIONS.TEST, + hook: 'test', + test_hook_type: 'slack', + }); + expect(Bridge.sendWebhookTestMessage).toHaveBeenCalled(); + delete global.window.Bridge; + }); + + test('when window.Bridge is undefined', () => { + const Bridge = { + sendWebhookTestMessage: jest.fn(), + }; + settingsReducer(undefined, { + type: SETTINGS_ACTIONS.TEST, + hook: 'test', + test_hook_type: 'slack', + }); + expect(Bridge.sendWebhookTestMessage).not.toHaveBeenCalled(); + }); + }); + }); + + test('should handle clear shipping action', () => { + const start = { + ...initialSettingsStates.settings, + shipping: { + ...initialSettingsStates.shipping, + profile: { + ...initialSettingsStates.shipping.profile, + profileName: 'test', + }, + }, + }; + const expected = { + ...initialSettingsStates.settings, + shipping: { + ...initialSettingsStates.shipping, + profile: { + ...initialSettingsStates.shipping.profile, + }, + }, + }; + const actual = settingsReducer(start, { + type: SETTINGS_ACTIONS.CLEAR_SHIPPING, + }); + expect(actual).toEqual(expected); + }); + + test('should handle error action', () => { + const expected = { + ...initialSettingsStates.settings, + shipping: { + ...initialSettingsStates.shipping, + profile: { + ...initialSettingsStates.shipping.profile, + }, + }, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.ERROR, + action: 'test', + error: 'test', + }); + expect(actual).toEqual(expected); + }); + + test('default profile settings action', () => { const expected = { ...initialSettingsStates.settings, - discord: 'discord_test', + defaults: { + ...initialSettingsStates.defaults, + profile: { + ...initialSettingsStates.defaults.profile, + profileName: 'test', + }, + useProfile: true, + }, }; const actual = settingsReducer(undefined, { type: SETTINGS_ACTIONS.EDIT, - field: SETTINGS_FIELDS.EDIT_DISCORD, - value: 'discord_test', + field: SETTINGS_FIELDS.EDIT_DEFAULT_PROFILE, + value: { + ...initialSettingsStates.defaults.profile, + profileName: 'test', + }, }); expect(actual).toEqual(expected); }); - test('slack settings action', () => { + test('default sizes settings action', () => { const expected = { ...initialSettingsStates.settings, - slack: 'slack', + defaults: { + ...initialSettingsStates.defaults, + sizes: ['4'], + useSizes: true, + }, }; const actual = settingsReducer(undefined, { type: SETTINGS_ACTIONS.EDIT, - field: SETTINGS_FIELDS.EDIT_SLACK, - value: 'slack', + field: SETTINGS_FIELDS.EDIT_DEFAULT_SIZES, + value: ['4'], }); expect(actual).toEqual(expected); }); - test('monitor delay action', () => { + test('shipping product', () => { const expected = { ...initialSettingsStates.settings, - monitorDelay: 1500, + shipping: { + ...initialSettingsStates.shipping, + product: { + ...initialSettingsStates.shipping.product, + raw: '+test', + }, + }, }; const actual = settingsReducer(undefined, { type: SETTINGS_ACTIONS.EDIT, - field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, - value: '1500', + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: '+test', }); expect(actual).toEqual(expected); }); - test('error delay action', () => { + test('shipping rate name', () => { const expected = { ...initialSettingsStates.settings, - errorDelay: 1500, + shipping: { + ...initialSettingsStates.shipping, + name: 'test', + }, }; const actual = settingsReducer(undefined, { type: SETTINGS_ACTIONS.EDIT, - field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, - value: '1500', + field: SETTINGS_FIELDS.EDIT_SHIPPING_RATE_NAME, + value: 'test', }); expect(actual).toEqual(expected); }); - test('default profile settings action', () => { + test('shipping profile', () => { const expected = { ...initialSettingsStates.settings, - defaults: { - ...initialSettingsStates.defaults, - profile: { profileName: 'test' }, - useProfile: true, + shipping: { + ...initialSettingsStates.shipping, + profile: { + ...initialSettingsStates.shipping.profile, + id: 1, + profileName: 'test', + }, }, }; const actual = settingsReducer(undefined, { type: SETTINGS_ACTIONS.EDIT, - field: SETTINGS_FIELDS.EDIT_DEFAULT_PROFILE, - value: { profileName: 'test' }, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE, + value: { + ...initialSettingsStates.shipping.profile, + id: 1, + profileName: 'test', + }, }); expect(actual).toEqual(expected); }); - test('default sizes settings action', () => { + test('shipping site', () => { const expected = { ...initialSettingsStates.settings, - defaults: { - ...initialSettingsStates.defaults, - sizes: ['4'], - useSizes: true, + shipping: { + ...initialSettingsStates.shipping, + site: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + localCheckout: false, + special: false, + auth: false, + }, }, }; const actual = settingsReducer(undefined, { type: SETTINGS_ACTIONS.EDIT, - field: SETTINGS_FIELDS.EDIT_DEFAULT_SIZES, - value: ['4'], + field: SETTINGS_FIELDS.EDIT_SHIPPING_SITE, + value: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + auth: false, + localCheckout: false, + special: false, + }, + }); + expect(actual).toEqual(expected); + }); + + test('shipping username', () => { + const expected = { + ...initialSettingsStates.settings, + shipping: { + ...initialSettingsStates.shipping, + username: 'test', + }, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_USERNAME, + value: 'test', + }); + expect(actual).toEqual(expected); + }); + + test('shipping password', () => { + const expected = { + ...initialSettingsStates.settings, + shipping: { + ...initialSettingsStates.shipping, + password: 'test', + }, + }; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PASSWORD, + value: 'test', }); expect(actual).toEqual(expected); }); @@ -288,11 +747,159 @@ describe('settings reducer', () => { }, }; const expected = initialSettingsStates.settings; - const actual = settingsReducer(start, { type: SETTINGS_ACTIONS.CLEAR }); + const actual = settingsReducer(start, { type: SETTINGS_ACTIONS.CLEAR_DEFAULTS }); expect(actual).toEqual(expected); expect(actual).not.toEqual(start); }); + describe('should handle fetch shipping action', () => { + test('when action has errors', () => { + const expected = initialSettingsStates.settings; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: {}, + errors: {}, + }); + expect(actual).toEqual(expected); + }); + + test('when action has no errors but no response', () => { + const expected = initialSettingsStates.settings; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + }); + expect(actual).toEqual(expected); + }); + + test('when action has no errors, response, rates, but no selectedRate', () => { + const expected = initialSettingsStates.settings; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + rates: [], + }, + }); + expect(actual).toEqual(expected); + }); + + test('when action has no errors, response, selectedRate, but no rates (somehow?)', () => { + const expected = initialSettingsStates.settings; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + selectedRate: [], + }, + }); + expect(actual).toEqual(expected); + }); + + test('when action has no errors, response, selectedRate, and rates', () => { + // TODO: once we implement shipping rates reducer chain logic.. + const expected = initialSettingsStates.settings; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.FETCH_SHIPPING, + response: { + rates: [], + selectedRate: {}, + }, + }); + expect(actual).toEqual(expected); + }); + }); + + describe('should handle setup shipping action', () => { + test('when status is already in progress', () => { + const start = { ...initialSettingsStates.settings }; + start.shipping.status = 'inprogress'; + const actual = settingsReducer(start, { + type: SETTINGS_ACTIONS.SETUP_SHIPPING, + }); + expect(actual).toEqual(start); + }); + + test('when status is idle', () => { + const expected = { ...initialSettingsStates.settings }; + expected.shipping.status = 'inprogress'; + const actual = settingsReducer(undefined, { + type: SETTINGS_ACTIONS.SETUP_SHIPPING, + }); + expect(actual).toEqual(expected); + }); + }); + + describe('should handle cleanup shipping action', () => { + test('when status is already idle', () => { + const start = { ...initialSettingsStates.settings }; + start.shipping.status = 'idle'; + const actual = settingsReducer(start, { + type: SETTINGS_ACTIONS.CLEANUP_SHIPPING, + status: true, + }); + expect(actual).toEqual(start); + }); + + test('when status is in progress', () => { + const start = { ...initialSettingsStates.settings }; + start.shipping.status = 'inprogress'; + const actual = settingsReducer(start, { + type: SETTINGS_ACTIONS.CLEANUP_SHIPPING, + status: true, + }); + start.shipping.status = 'idle'; + expect(actual).toEqual(start); + }); + }); + + describe('should handle remove profile action', () => { + it('when no action id is present', () => { + const actual = settingsReducer(initialSettingsStates.settings, { + type: PROFILE_ACTIONS.REMOVE, + id: undefined, + }); + expect(actual).toEqual(initialSettingsStates.settings); + }); + + it('when shipping profile matches removed profile', () => { + const initial = { + ...initialSettingsStates.settings, + shipping: { + ...initialSettingsStates.shipping, + profile: { + ...initialSettingsStates.shipping.profile, + id: 1, + profileName: 'test', + }, + }, + }; + + const actual = settingsReducer(initial, { + type: PROFILE_ACTIONS.REMOVE, + id: 1, + }); + expect(actual).toEqual(initialSettingsStates.settings); + }); + + it('when shipping profile does not match removed profile', () => { + const initial = { + ...initialSettingsStates.settings, + shipping: { + ...initialSettingsStates.shipping, + profile: { + ...initialSettingsStates.shipping.profile, + id: 2, + profileName: 'test', + }, + }, + }; + + const actual = settingsReducer(initial, { + type: PROFILE_ACTIONS.REMOVE, + id: 1, + }); + expect(actual).toEqual(initial); + }); + }); + describe('should add errors to state from', () => { let expected; diff --git a/packages/frontend/src/__tests__/state/reducers/settings/shippingReducer.test.js b/packages/frontend/src/__tests__/state/reducers/settings/shippingReducer.test.js new file mode 100644 index 00000000..8e06db2a --- /dev/null +++ b/packages/frontend/src/__tests__/state/reducers/settings/shippingReducer.test.js @@ -0,0 +1,315 @@ +/* global describe it test expect beforeAll */ +import shippingReducer from '../../../../state/reducers/settings/shippingReducer'; +import { SETTINGS_ACTIONS, SETTINGS_FIELDS } from '../../../../state/actions'; +import initialSettingsStates from '../../../../state/initial/settings'; +import initialProfileStates from '../../../../state/initial/profiles'; + +describe('settings reducer', () => { + it('should return initial state', () => { + const expected = initialSettingsStates.shipping; + const actual = shippingReducer(undefined, {}); + expect(actual).toEqual(expected); + }); + + describe('should handle edit', () => { + describe('shipping product', () => { + test('when no value is given', () => { + const expected = { + ...initialSettingsStates.shipping, + product: { + ...initialSettingsStates.shipping.product, + raw: '', + }, + errors: { + ...initialSettingsStates.shipping.errors, + product: true, + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: undefined, + errors: { + ...initialSettingsStates.shipping.errors, + product: true, + }, + }); + expect(actual).toEqual(expected); + }); + + test('when value is keywords', () => { + const expected = { + ...initialSettingsStates.shipping, + product: { + ...initialSettingsStates.shipping.product, + raw: '+test', + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: '+test', + errors: {}, + }); + expect(actual).toEqual(expected); + }); + + test('when value is non-valid URL', () => { + const expected = { + ...initialSettingsStates.shipping, + product: { + ...initialSettingsStates.shipping.product, + raw: 'https://', + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: 'https://', + errors: {}, + }); + expect(actual).toEqual(expected); + }); + + test('when value is valid URL in site list', () => { + const expected = { + ...initialSettingsStates.shipping, + product: { + ...initialSettingsStates.shipping.product, + raw: 'https://nebulabots.com/products/test', + }, + site: { + url: 'https://nebulabots.com', + name: 'Nebula Bots', + apiKey: '6526a5b5393b6316a64853cfe091841c', + special: false, + auth: false, + }, + username: '', + password: '', + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: 'https://nebulabots.com/products/test', + errors: {}, + }); + expect(actual).toEqual(expected); + }); + + test('when value is valid URL not in site list', () => { + const expected = { + ...initialSettingsStates.shipping, + product: { + ...initialSettingsStates.shipping.product, + raw: 'https://google.com', + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: 'https://google.com', + errors: {}, + }); + expect(actual).toEqual(expected); + }); + }); + + describe('shipping rate name', () => { + test('should handle edit', () => { + const expected = { + ...initialSettingsStates.shipping, + name: 'test', + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_RATE_NAME, + value: 'test', + }); + expect(actual).toEqual(expected); + }); + + test('should handle errors', () => { + const expected = { + ...initialSettingsStates.shipping, + name: '', + errors: { + ...initialSettingsStates.shipping.errors, + name: true, + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_RATE_NAME, + value: '', + errors: { + ...initialSettingsStates.shipping.errors, + name: true, + }, + }); + expect(actual).toEqual(expected); + }); + }); + + describe('shipping rate profile', () => { + test('should handle edit', () => { + const expected = { + ...initialSettingsStates.shipping, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'test', + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE, + value: { ...initialProfileStates.profile, id: 1, profileName: 'test' }, + }); + expect(actual).toEqual(expected); + }); + + test('should handle errors', () => { + const expected = { + ...initialSettingsStates.shipping, + profile: {}, + errors: { + ...initialSettingsStates.shipping.errors, + profile: true, + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE, + value: {}, + errors: { + ...initialSettingsStates.shipping.errors, + profile: true, + }, + }); + expect(actual).toEqual(expected); + }); + }); + + describe('shipping rate site', () => { + test('should handle edit', () => { + const expected = { + ...initialSettingsStates.shipping, + site: { + ...initialProfileStates.site, + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + auth: false, + supported: true, + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_SITE, + value: { + name: 'Nebula Bots', + url: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + auth: false, + supported: true, + }, + }); + expect(actual).toEqual(expected); + }); + + test('should handle errors', () => { + const expected = { + ...initialSettingsStates.shipping, + site: {}, + errors: { + ...initialSettingsStates.shipping.errors, + site: true, + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_SITE, + value: {}, + errors: { + ...initialSettingsStates.shipping.errors, + site: true, + }, + }); + expect(actual).toEqual(expected); + }); + }); + }); + + describe('shipping rate username', () => { + test('should handle edit', () => { + const expected = { + ...initialSettingsStates.shipping, + username: 'test', + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_USERNAME, + value: 'test', + }); + expect(actual).toEqual(expected); + }); + + test('should handle errors', () => { + const expected = { + ...initialSettingsStates.shipping, + username: '', + errors: { + ...initialSettingsStates.shipping.errors, + username: true, + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_USERNAME, + value: '', + errors: { + ...initialSettingsStates.shipping.errors, + username: true, + }, + }); + expect(actual).toEqual(expected); + }); + }); + + describe('shipping rate password', () => { + test('should handle edit', () => { + const expected = { + ...initialSettingsStates.shipping, + password: 'test', + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PASSWORD, + value: 'test', + }); + expect(actual).toEqual(expected); + }); + + test('should handle errors', () => { + const expected = { + ...initialSettingsStates.shipping, + password: '', + errors: { + ...initialSettingsStates.shipping.errors, + password: true, + }, + }; + const actual = shippingReducer(undefined, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PASSWORD, + value: '', + errors: { + ...initialSettingsStates.shipping.errors, + password: true, + }, + }); + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/packages/frontend/src/__tests__/state/reducers/task/newTaskReducer.test.js b/packages/frontend/src/__tests__/state/reducers/task/newTaskReducer.test.js index 84bdd46d..0f520a5a 100644 --- a/packages/frontend/src/__tests__/state/reducers/task/newTaskReducer.test.js +++ b/packages/frontend/src/__tests__/state/reducers/task/newTaskReducer.test.js @@ -1,7 +1,9 @@ /* global describe expect it test jest */ import { newTaskReducer } from '../../../../state/reducers/tasks/taskReducer'; import initialTaskStates from '../../../../state/initial/tasks'; +import initialProfileStates from '../../../../state/initial/profiles'; import { + PROFILE_ACTIONS, TASK_ACTIONS, TASK_FIELDS, SETTINGS_ACTIONS, @@ -29,36 +31,98 @@ describe('new task reducer', () => { expect(actual).toEqual(expected); }); - test('when updating error delay', () => { - const expected = { - ...initialTaskStates.task, - monitorDelay: 1500, - errorDelay: 5000, - }; + describe('when updating error delay', () => { + test('with no action value', () => { + const expected = { + ...initialTaskStates.task, + errorDelay: 0, + }; - const actual = newTaskReducer(initialTaskStates.task, { - type: SETTINGS_ACTIONS.EDIT, - id: null, - field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, - value: 5000, + const actual = newTaskReducer(initialTaskStates.task, { + type: SETTINGS_ACTIONS.EDIT, + id: null, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: undefined, + }); + expect(actual).toEqual(expected); + }); + + test('with action value being non-numerical', () => { + const expected = { + ...initialTaskStates.task, + errorDelay: 1500, + }; + + const actual = newTaskReducer(initialTaskStates.task, { + type: SETTINGS_ACTIONS.EDIT, + id: null, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: 'test', + }); + expect(actual).toEqual(expected); + }); + + test('with action value being numerical', () => { + const expected = { + ...initialTaskStates.task, + errorDelay: 5000, + }; + + const actual = newTaskReducer(initialTaskStates.task, { + type: SETTINGS_ACTIONS.EDIT, + id: null, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: 5000, + }); + expect(actual).toEqual(expected); }); - expect(actual).toEqual(expected); }); - test('when updating monitor delay', () => { - const expected = { - ...initialTaskStates.task, - monitorDelay: 5000, - errorDelay: 1500, - }; + describe('when updating monitor delay', () => { + test('with no action value', () => { + const expected = { + ...initialTaskStates.task, + monitorDelay: 0, + }; - const actual = newTaskReducer(initialTaskStates.task, { - type: SETTINGS_ACTIONS.EDIT, - id: null, - field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, - value: 5000, + const actual = newTaskReducer(initialTaskStates.task, { + type: SETTINGS_ACTIONS.EDIT, + id: null, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: undefined, + }); + expect(actual).toEqual(expected); + }); + + test('with action value being non-numerical', () => { + const expected = { + ...initialTaskStates.task, + monitorDelay: 1500, + }; + + const actual = newTaskReducer(initialTaskStates.task, { + type: SETTINGS_ACTIONS.EDIT, + id: null, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: 'test', + }); + expect(actual).toEqual(expected); + }); + + test('with action value being numerical', () => { + const expected = { + ...initialTaskStates.task, + monitorDelay: 5000, + }; + + const actual = newTaskReducer(initialTaskStates.task, { + type: SETTINGS_ACTIONS.EDIT, + id: null, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: 5000, + }); + expect(actual).toEqual(expected); }); - expect(actual).toEqual(expected); }); test('when updating discord webhook', () => { @@ -76,6 +140,38 @@ describe('new task reducer', () => { expect(actual).toEqual(expected); }); + describe('should not respond to edits on', () => { + test('proxies', () => { + const actual = newTaskReducer(initialTaskStates.task, { + type: SETTINGS_ACTIONS.EDIT, + id: null, + field: SETTINGS_FIELDS.EDIT_PROXIES, + value: 'test', + }); + expect(actual).toEqual(initialTaskStates.task); + }); + + test('defaults', () => { + const actual = newTaskReducer(initialTaskStates.task, { + type: SETTINGS_ACTIONS.EDIT, + id: null, + field: SETTINGS_FIELDS.EDIT_DEFAULT_SIZES, + value: ['5'], + }); + expect(actual).toEqual(initialTaskStates.task); + }); + + test('shippings ', () => { + const actual = newTaskReducer(initialTaskStates.task, { + type: SETTINGS_ACTIONS.EDIT, + id: null, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: '+test', + }); + expect(actual).toEqual(initialTaskStates.task); + }); + }); + test('when updating slack webhook', () => { const expected = { ...initialTaskStates.task, @@ -130,6 +226,119 @@ describe('new task reducer', () => { }); }); + describe('should handle profile updates', () => { + test('when no profile is given', () => { + const actual = newTaskReducer(initialTaskStates.task, { + type: PROFILE_ACTIONS.UPDATE, + profile: undefined, + }); + expect(actual).toEqual(initialTaskStates.task); + }); + + test('when errors are given', () => { + const actual = newTaskReducer(initialTaskStates.task, { + type: PROFILE_ACTIONS.UPDATE, + profile: {}, + errors: {}, + }); + expect(actual).toEqual(initialTaskStates.task); + }); + + test('when selected profile is the updated profile', () => { + const initial = { + ...initialTaskStates.task, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'test', + }, + }; + + const expected = { + ...initialTaskStates.task, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'test change', + }, + }; + + const actual = newTaskReducer(initial, { + type: PROFILE_ACTIONS.UPDATE, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'test change', + }, + }); + expect(actual).toEqual(expected); + }); + + test('when selected profile is not the updated profile', () => { + const initial = { + ...initialTaskStates.task, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'test', + }, + }; + + const actual = newTaskReducer(initial, { + type: PROFILE_ACTIONS.UPDATE, + profile: { + ...initialProfileStates.profile, + id: 2, + profileName: 'test change', + }, + }); + expect(actual).toEqual(initial); + }); + }); + + describe('should handle profile removal', () => { + it('when no action id is present', () => { + const actual = newTaskReducer(initialTaskStates.task, { + type: PROFILE_ACTIONS.REMOVE, + id: undefined, + }); + expect(actual).toEqual(initialTaskStates.task); + }); + + it('when id matches the profile that is selected', () => { + const initial = { + ...initialTaskStates.task, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'test', + }, + }; + + const actual = newTaskReducer(initial, { + type: PROFILE_ACTIONS.REMOVE, + id: 1, + }); + expect(actual).toEqual(initialTaskStates.task); + }); + + it('when id does not match the profile that is selected', () => { + const initial = { + ...initialTaskStates.task, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'test', + }, + }; + + const actual = newTaskReducer(initial, { + type: PROFILE_ACTIONS.REMOVE, + id: 2, + }); + expect(actual).toEqual(initial); + }); + }); describe('should handle add', () => { describe('when action is valid', () => { test('when defaults are not given', () => { diff --git a/packages/frontend/src/__tests__/state/reducers/task/taskListReducer.test.js b/packages/frontend/src/__tests__/state/reducers/task/taskListReducer.test.js index a625951b..c81293f6 100644 --- a/packages/frontend/src/__tests__/state/reducers/task/taskListReducer.test.js +++ b/packages/frontend/src/__tests__/state/reducers/task/taskListReducer.test.js @@ -1,7 +1,14 @@ /* global describe expect it test beforeEach jest */ import taskListReducer from '../../../../state/reducers/tasks/taskListReducer'; import initialTaskStates from '../../../../state/initial/tasks'; -import { TASK_ACTIONS, TASK_FIELDS } from '../../../../state/actions'; +import initialProfileStates from '../../../../state/initial/profiles'; +import { + TASK_ACTIONS, + TASK_FIELDS, + SETTINGS_ACTIONS, + SETTINGS_FIELDS, + PROFILE_ACTIONS, +} from '../../../../state/actions'; describe('task list reducer', () => { it('should return initial state', () => { @@ -9,6 +16,747 @@ describe('task list reducer', () => { expect(actual).toEqual(initialTaskStates.list); }); + describe('should update existing tasks', () => { + describe('when editing settings field', () => { + test('discord', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + discord: 'test', + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + discord: 'test', + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_DISCORD, + value: 'test', + }); + + expect(actual).toEqual(expected); + }); + + test('slack', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + slack: 'test', + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + slack: 'test', + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SLACK, + value: 'test', + }); + + expect(actual).toEqual(expected); + }); + + describe('monitor delay', () => { + test('when value is greater than 0', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + monitorDelay: 1500, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + monitorDelay: 1500, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: 1500, + }); + + expect(actual).toEqual(expected); + }); + + test('when value is empty due to backspacing', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + monitorDelay: 0, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + monitorDelay: 0, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: '', + }); + + expect(actual).toEqual(expected); + }); + + test('when value is non-numerical', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + errorDelay: 1500, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + errorDelay: 1500, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + value: 'test', + }); + + expect(actual).toEqual(expected); + }); + }); + + describe('error delay', () => { + test('when value is greater than 0', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + errorDelay: 1500, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + errorDelay: 1500, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: 1500, + }); + + expect(actual).toEqual(expected); + }); + + test('when value is empty due to backspacing', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + errorDelay: 0, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + errorDelay: 0, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: '', + }); + + expect(actual).toEqual(expected); + }); + + test('when value is non-numerical', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + errorDelay: 1500, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + errorDelay: 1500, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_ERROR_DELAY, + value: 'test', + }); + + expect(actual).toEqual(expected); + }); + }); + + describe('no operation fields', () => { + test('default profile', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_DEFAULT_PROFILE, + value: { ...initialProfileStates.profile, id: 1, profileName: 'test' }, + }); + + expect(actual).toEqual(start); + }); + + test('default sizes', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_DEFAULT_SIZES, + value: ['Random'], + }); + + expect(actual).toEqual(start); + }); + + test('proxies', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_PROXIES, + value: '127.0.0.1:888', + }); + + expect(actual).toEqual(start); + }); + + test('shipping product', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT, + value: '+test', + }); + + expect(actual).toEqual(start); + }); + + test('shipping rate name', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_RATE_NAME, + value: 'test', + }); + + expect(actual).toEqual(start); + }); + + test('shipping profile', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE, + value: { ...initialProfileStates.profile, id: 1, profileName: 'test' }, + }); + + expect(actual).toEqual(start); + }); + + test('shipping profile', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_SITE, + value: { + label: 'Nebula Bots', + value: 'https://nebulabots.com', + apiKey: '6526a5b5393b6316a64853cfe091841c', + auth: false, + supported: true, + }, + }); + + expect(actual).toEqual(start); + }); + + test('shipping username', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_USERNAME, + value: 'test', + }); + + expect(actual).toEqual(start); + }); + + test('shipping password', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + }, + ]; + + const actual = taskListReducer(start, { + type: SETTINGS_ACTIONS.EDIT, + field: SETTINGS_FIELDS.EDIT_SHIPPING_PASSWORD, + value: 'test', + }); + + expect(actual).toEqual(start); + }); + }); + }); + + describe('when editing profile fields', () => { + test('adding a profile', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'not test', + }, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + profile: { + ...initialProfileStates.profile, + id: 2, + }, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'test', + }, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + profile: { + ...initialProfileStates.profile, + id: 2, + }, + }, + ]; + + const actual = taskListReducer(start, { + type: PROFILE_ACTIONS.ADD, + profile: { ...initialProfileStates.profile, id: 1, profileName: 'test' }, + }); + + expect(actual).toEqual(expected); + }); + + test('updating a profile', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'not test', + }, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + profile: { + ...initialProfileStates.profile, + id: 2, + }, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'test', + }, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + profile: { + ...initialProfileStates.profile, + id: 2, + }, + }, + ]; + + const actual = taskListReducer(start, { + type: PROFILE_ACTIONS.UPDATE, + profile: { ...initialProfileStates.profile, id: 1, profileName: 'test' }, + }); + + expect(actual).toEqual(expected); + }); + + test('no operation when no profile is passed along', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'not test', + }, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + profile: { + ...initialProfileStates.profile, + id: 2, + }, + }, + ]; + + const actual = taskListReducer(start, { + type: PROFILE_ACTIONS.ADD, + profile: null, + }); + + expect(actual).toEqual(start); + }); + + test('removing a profile with tasks using that profile', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + profile: { + ...initialProfileStates.profile, + id: 1, + profileName: 'not test', + }, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + profile: { + ...initialProfileStates.profile, + id: 2, + }, + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + profile: { + ...initialProfileStates.profile, + id: 2, + }, + }, + ]; + + const actual = taskListReducer(start, { + type: PROFILE_ACTIONS.REMOVE, + id: 1, + }); + + expect(actual).toEqual(expected); + }); + + test('removing a profile with tasks using that profile', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + profile: { + ...initialProfileStates.profile, + id: 2, + profileName: 'not test', + }, + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + profile: { + ...initialProfileStates.profile, + id: 2, + profileName: 'not test', + }, + }, + ]; + + const actual = taskListReducer(start, { + type: PROFILE_ACTIONS.REMOVE, + id: 1, + }); + + expect(actual).toEqual(start); + }); + }); + }); + describe('should handle add', () => { describe('when valid action is formed to', () => { let initialValues; @@ -253,6 +1001,111 @@ describe('task list reducer', () => { }); }); + describe('should handle copy', () => { + describe('when valid action is formed to', () => { + test('copy a specific task', () => { + const start = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + username: 'test1', + }, + { + ...initialTaskStates.task, + id: 'task2', + index: 2, + username: 'test2', + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + username: 'test3', + }, + ]; + + const expected = [ + { + ...initialTaskStates.task, + id: 'task1', + index: 1, + username: 'test1', + }, + { + ...initialTaskStates.task, + id: 'task2', + index: 2, + username: 'test2', + }, + { + ...initialTaskStates.task, + id: 'task3', + index: 3, + username: 'test3', + }, + { + ...initialTaskStates.task, + id: expect.any(String), + index: 4, + username: 'test2', + }, + ]; + const actual = taskListReducer(start, { + type: TASK_ACTIONS.COPY, + response: { + task: { + ...initialTaskStates.task, + id: 'task2', + index: 2, + username: 'test2', + }, + }, + }); + expect(actual).toEqual(expected); + }); + }); + + describe('when invalid action is formed because', () => { + const testNoop = payload => { + const start = [ + { + ...initialTaskStates.task, + id: 1, + }, + ]; + const expected = JSON.parse(JSON.stringify(start)); + const actual = taskListReducer(start, { + type: TASK_ACTIONS.COPY, + ...payload, + }); + expect(actual).toEqual(expected); + }; + + test('task is not given', () => { + testNoop({ + response: {}, + }); + }); + + test('when errors map exists', () => { + testNoop({ + errors: {}, + }); + }); + + test('response is null', () => { + testNoop({ + response: null, + }); + }); + + test('response is not given', () => { + testNoop({}); + }); + }); + }); + describe('should handle update', () => { describe('when valid action is formed to', () => { const testValid = edits => { diff --git a/packages/frontend/src/__tests__/state/reducers/task/taskReducer.test.js b/packages/frontend/src/__tests__/state/reducers/task/taskReducer.test.js index 313ac81e..732208ab 100644 --- a/packages/frontend/src/__tests__/state/reducers/task/taskReducer.test.js +++ b/packages/frontend/src/__tests__/state/reducers/task/taskReducer.test.js @@ -175,25 +175,48 @@ describe('task reducer', () => { checkGeneralFieldEdit(TASK_FIELDS.EDIT_PASSWORD, 'test'); }); - test('site', () => { - const start = { - ...initialTaskStates.task, - site: 'something else', - username: 'username', - password: 'password', - }; - const expected = { - ...initialTaskStates.task, - site: 'test', - username: null, - password: null, - }; - const actual = taskReducer(start, { - type: TASK_ACTIONS.EDIT, - field: TASK_FIELDS.EDIT_SITE, - value: 'test', + describe('site', () => { + test('when selected site is different than previous site', () => { + const start = { + ...initialTaskStates.task, + site: { + name: 'test', + }, + }; + const expected = { + ...initialTaskStates.task, + site: { + name: 'test', + }, + }; + const actual = taskReducer(start, { + type: TASK_ACTIONS.EDIT, + field: TASK_FIELDS.EDIT_SITE, + value: { name: 'test' }, + }); + expect(actual).toEqual(expected); + }); + + test('when selected site is the same as previous site', () => { + const start = { + ...initialTaskStates.task, + site: 'something else', + username: 'username', + password: 'password', + }; + const expected = { + ...initialTaskStates.task, + site: 'something else', + username: null, + password: null, + }; + const actual = taskReducer(start, { + type: TASK_ACTIONS.EDIT, + field: TASK_FIELDS.EDIT_SITE, + value: 'something else', + }); + expect(actual).toEqual(expected); }); - expect(actual).toEqual(expected); }); test('profile', () => { @@ -509,7 +532,7 @@ describe('task reducer', () => { checkExistingFieldEdit(TASK_FIELDS.EDIT_SITE, 'test', 1); }); - test('when existing site', () => { + test('when existing site is different than previous site', () => { checkExistingFieldEdit( TASK_FIELDS.EDIT_SITE, { diff --git a/packages/frontend/src/__tests__/tasks/tasks.test.jsx b/packages/frontend/src/__tests__/tasks/tasks.test.jsx index a70e43f0..390d1645 100644 --- a/packages/frontend/src/__tests__/tasks/tasks.test.jsx +++ b/packages/frontend/src/__tests__/tasks/tasks.test.jsx @@ -7,8 +7,11 @@ import CreateTask from '../../tasks/createTask'; import ViewTask from '../../tasks/viewTask'; import LogTask from '../../tasks/logTask'; import initialTaskStates from '../../state/initial/tasks'; +import initialSettingsStates from '../../state/initial/settings'; import getByTestId from '../../__testUtils__/getByTestId'; +import { SETTINGS_FIELDS } from '../../state/actions/settings/settingsActions'; +import { SETTINGS_ACTIONS } from '../../state/actions'; describe('', () => { let defaultProps; @@ -21,8 +24,11 @@ describe('', () => { return shallow( ', () => { newTask: { ...initialTaskStates.task, }, + monitorDelay: initialSettingsStates.settings.monitorDelay, + errorDelay: initialSettingsStates.settings.errorDelay, tasks: [], proxies: [], + onSettingsChange: () => {}, onDestroyTask: () => {}, onStartTask: () => {}, onStopTask: () => {}, @@ -66,6 +75,11 @@ describe('', () => { expect(getByTestId(wrapper, 'Tasks.bulkActionButton.start')).toHaveLength(1); expect(getByTestId(wrapper, 'Tasks.bulkActionButton.stop')).toHaveLength(1); expect(getByTestId(wrapper, 'Tasks.bulkActionButton.destroy')).toHaveLength(1); + expect(wrapper.find('.bulk-action-sidebar__monitor-delay')).toHaveLength(1); + expect(wrapper.find('.bulk-action-sidebar__monitor-delay').prop('value')).toEqual(1500); + expect(wrapper.find('.bulk-action-sidebar__error-delay')).toHaveLength(1); + expect(wrapper.find('.bulk-action-sidebar__error-delay').prop('value')).toEqual(1500); + getByTestId(wrapper, 'Tasks.bulkActionButton.start').simulate('keyPress'); }); @@ -212,6 +226,30 @@ describe('', () => { expect(customProps.onDestroyTask).not.toHaveBeenCalled(); }); }); + + describe('should call onSettingsChange when editing', () => { + test('monitor delay', () => { + const customProps = { + onSettingsChange: jest.fn(), + }; + const wrapper = renderShallowWithProps(customProps); + const monitorDelayInput = wrapper.find('.bulk-action-sidebar__monitor-delay'); + expect(monitorDelayInput).toHaveLength(1); + monitorDelayInput.simulate('change', { target: { value: 1500 } }); + expect(customProps.onSettingsChange).toHaveBeenCalled(); + }); + + test('error delay', () => { + const customProps = { + onSettingsChange: jest.fn(), + }; + const wrapper = renderShallowWithProps(customProps); + const errorDelayInput = wrapper.find('.bulk-action-sidebar__error-delay'); + expect(errorDelayInput).toHaveLength(1); + errorDelayInput.simulate('change', { target: { value: 1500 } }); + expect(customProps.onSettingsChange).toHaveBeenCalled(); + }); + }); }); test('map state to props returns correct structure', () => { @@ -254,12 +292,18 @@ describe('', () => { actual.onDestroyTask({}); actual.onStartTask({}, []); actual.onStopTask({}); + actual.onSettingsChange({ field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, value: 1500 }); // Since these actions generate a thunk, we can't test for // exact equality, only that a function (i.e. thunk) was // dispatched. - expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch).toHaveBeenCalledTimes(4); expect(dispatch).toHaveBeenNthCalledWith(1, expect.any(Function)); expect(dispatch).toHaveBeenNthCalledWith(2, expect.any(Function)); expect(dispatch).toHaveBeenNthCalledWith(3, expect.any(Function)); + expect(dispatch).toHaveBeenNthCalledWith(4, { + field: SETTINGS_FIELDS.EDIT_MONITOR_DELAY, + type: SETTINGS_ACTIONS.EDIT, + value: 1500, + }); }); }); diff --git a/packages/frontend/src/app.css b/packages/frontend/src/app.css index d0589ddd..3ac948bc 100644 --- a/packages/frontend/src/app.css +++ b/packages/frontend/src/app.css @@ -104,7 +104,6 @@ -moz-user-select: none; -ms-user-select: none; user-select: none; - cursor: default; text-rendering: optimizeLegibility; } * :focus { @@ -118,6 +117,13 @@ /* make scrollbar transparent */ } +svg, +button, +img, +a { + cursor: pointer !important; +} + html, body { width: 100%; @@ -162,6 +168,7 @@ select { left: 0; right: 0; height: 25px; + cursor: move !important; background: transparent; -webkit-user-select: none; -webkit-app-region: drag; @@ -178,13 +185,13 @@ select { -webkit-user-select: none; -webkit-app-region: no-drag; z-index: 1; - cursor: pointer; + cursor: pointer !important; } .close-area-1 > img { position: absolute; top: 6px; right: 6px; - cursor: pointer; + cursor: pointer !important; vertical-align: middle; width: 12px; height: 12px; @@ -200,13 +207,13 @@ select { -webkit-user-select: none; -webkit-app-region: no-drag; z-index: 1; - cursor: pointer; + cursor: pointer !important; } .close-area-2 > img { position: absolute; top: 6px; right: 6px; - cursor: pointer; + cursor: pointer !important; vertical-align: middle; width: 12px; height: 12px; @@ -221,11 +228,11 @@ select { -webkit-user-select: none; -webkit-app-region: no-drag; z-index: 1; - cursor: pointer; + cursor: pointer !important; } .theme-icon > img { position: absolute; - cursor: "pointer"; + cursor: pointer !important; vertical-align: middle; z-index: 1; width: 12px; diff --git a/packages/frontend/src/app.jsx b/packages/frontend/src/app.jsx index e7fbb021..975ca5f6 100644 --- a/packages/frontend/src/app.jsx +++ b/packages/frontend/src/app.jsx @@ -61,10 +61,9 @@ export class App extends PureComponent { window.removeEventListener('beforeunload', this._cleanupTaskEvents); } - // Next you can import it here and use it setTheme(store) { const { theme } = store.getState(); - const nextTheme = mapToNextTheme[theme] || THEMES.LIGHT; // assign a default theme in case an invalid theme is given + const nextTheme = mapToNextTheme[theme] || THEMES.LIGHT; store.dispatch(globalActions.setTheme(nextTheme)); if (window.Bridge) { const backgroundColor = mapThemeToColor[nextTheme]; @@ -75,7 +74,11 @@ export class App extends PureComponent { taskHandler(event, taskId, statusMessage) { const { store } = this.props; - store.dispatch(taskActions.status(taskId, statusMessage)); + const { type } = statusMessage; + // filter out shipping rate handler + if (type !== 'srr') { + store.dispatch(taskActions.status(taskId, statusMessage)); + } } _cleanupTaskEvents() { @@ -134,18 +137,21 @@ export class App extends PureComponent { className="theme-icon" role="button" tabIndex={0} - title={theme === THEMES.LIGHT ? 'night mode' : 'light mode'} + title="theme" onKeyPress={onKeyPress} onClick={() => this.setTheme(store)} draggable="false" + data-testid={addTestId('App.button.theme')} > {theme === THEMES.LIGHT ? renderSvgIcon(NightModeIcon, { - alt: 'theme', + alt: 'night mode', + 'data-testid': addTestId('App.button.theme.light-mode'), style: { marginTop: '5px', marginLeft: '5px' }, }) : renderSvgIcon(LightModeIcon, { - alt: 'theme', + alt: 'light mode', + 'data-testid': addTestId('App.button.theme.dark-mode'), style: { marginTop: '6px', marginLeft: '4px' }, })} diff --git a/packages/frontend/src/app.scss b/packages/frontend/src/app.scss index 9ed7365f..dd1dbbcb 100644 --- a/packages/frontend/src/app.scss +++ b/packages/frontend/src/app.scss @@ -10,7 +10,6 @@ -moz-user-select: none; -ms-user-select: none; user-select: none; - cursor: default; text-rendering: optimizeLegibility; :focus { @@ -24,6 +23,13 @@ background: transparent; /* make scrollbar transparent */ } +svg, +button, +img, +a { + cursor: pointer !important; +} + html, body { @include base($themes) { @@ -63,6 +69,7 @@ select { left: 0; right: 0; height: 25px; + cursor: move !important; background: transparent; -webkit-user-select: none; -webkit-app-region: drag; @@ -79,13 +86,13 @@ select { -webkit-user-select: none; -webkit-app-region: no-drag; z-index: 1; - cursor: pointer; + cursor: pointer !important; & > img { position: absolute; top: 6px; right: 6px; - cursor: pointer; + cursor: pointer !important; vertical-align: middle; width: 12px; height: 12px; @@ -102,13 +109,13 @@ select { -webkit-user-select: none; -webkit-app-region: no-drag; z-index: 1; - cursor: pointer; + cursor: pointer !important; & > img { position: absolute; top: 6px; right: 6px; - cursor: pointer; + cursor: pointer !important; vertical-align: middle; width: 12px; height: 12px; @@ -124,7 +131,7 @@ select { -webkit-user-select: none; -webkit-app-region: no-drag; z-index: 1; - cursor: pointer; + cursor: pointer !important; & > img { position: absolute; @@ -132,7 +139,7 @@ select { top: themed('iconPositionTop'); right: themed('iconPositionRight'); } - cursor: 'pointer'; + cursor: pointer !important; vertical-align: middle; z-index: 1; width: 12px; diff --git a/packages/frontend/src/navbar/_mixins.scss b/packages/frontend/src/navbar/_mixins.scss new file mode 100644 index 00000000..0b7e9203 --- /dev/null +++ b/packages/frontend/src/navbar/_mixins.scss @@ -0,0 +1,16 @@ +@import '../themes'; + +// Shared Mixins +@mixin image() { + cursor: pointer; + vertical-align: middle; +} + +@mixin navbar-font() { + font-family: AvenirNext-Medium; + font-size: 9px; + @include themify($themes) { + color: themed('textColor'); + } + letter-spacing: 0; +} diff --git a/packages/frontend/src/navbar/navbar.css b/packages/frontend/src/navbar/navbar.css index cb9d18fd..64ce6633 100644 --- a/packages/frontend/src/navbar/navbar.css +++ b/packages/frontend/src/navbar/navbar.css @@ -1,9 +1,34 @@ +/* + * Implementation of themes + */ /* * Implementation of themes */ .navbar__icon--settings, .navbar__icon--servers, .navbar__icon--profiles, .navbar__icon--tasks { cursor: pointer; } +.navbar__button--close-captcha, .navbar__button--open-captcha { + font-family: AvenirNext-Medium; + font-size: 9px; + letter-spacing: 0; + font-size: 12px; + width: 65px; + height: 29px; + cursor: pointer; + border: none; + border-radius: 3px; +} +.theme-light .navbar__button--close-captcha, .theme-light .navbar__button--open-captcha { + color: #161318; +} +.theme-dark .navbar__button--close-captcha, .theme-dark .navbar__button--open-captcha { + color: #efefef; +} + +.react-bodymovin-container > svg { + cursor: default !important; +} + .active { opacity: 0.8333; } @@ -32,8 +57,8 @@ margin-top: 15px !important; } .navbar__icons { - margin-top: 55px !important; - margin-bottom: 75px !important; + margin-top: 10px !important; + margin-bottom: 10px !important; } .navbar__icon--tasks { margin-left: 13px; @@ -48,10 +73,41 @@ width: 42px; height: 46px; } +.navbar__icon--servers > div > svg { + cursor: not-allowed !important; +} .navbar__icon--settings { + margin-left: -2px; width: 40px; height: 60px; } +.navbar__button--open-captcha { + margin-top: 19px; + width: 75px; +} +.theme-light .navbar__button--open-captcha { + background: #f0405e; + color: #f4f4f4; +} +.theme-dark .navbar__button--open-captcha { + background: #f0405e; + color: #f4f4f4; +} +.navbar__button--close-captcha { + margin-top: 19px; + width: 75px; +} +.theme-light .navbar__button--close-captcha { + background: #46adb4; + color: #f4f4f4; +} +.theme-dark .navbar__button--close-captcha { + background: #46adb4; + color: #f4f4f4; +} +.navbar__text--gap { + margin-top: 15px !important; +} .navbar__text--app-name { text-transform: capitalize; text-align: center; @@ -62,6 +118,7 @@ } .navbar__text--app-version { text-align: center; + cursor: pointer; opacity: 0.75; color: #f0405e !important; cursor: pointer; diff --git a/packages/frontend/src/navbar/navbar.jsx b/packages/frontend/src/navbar/navbar.jsx index 94244212..f2f618cb 100644 --- a/packages/frontend/src/navbar/navbar.jsx +++ b/packages/frontend/src/navbar/navbar.jsx @@ -12,6 +12,7 @@ import { ReactComponent as TasksIcon } from '../_assets/tasks.svg'; import { ReactComponent as ProfilesIcon } from '../_assets/profiles.svg'; import { ReactComponent as ServersIcon } from '../_assets/server-disabled.svg'; import { ReactComponent as SettingsIcon } from '../_assets/settings.svg'; +import { mapThemeToColor } from '../constants/themes'; import './navbar.css'; const bodymovinOptions = { @@ -63,6 +64,24 @@ export class NavbarPrimitive extends PureComponent { ); } + static openHarvesterWindow(theme) { + if (window.Bridge) { + window.Bridge.launchCaptchaHarvester({ backgroundColor: mapThemeToColor[theme] }); + } else { + // TODO - Show notification #77: https://github.com/walmat/nebula/issues/77 + console.error('Unable to launch harvester!'); + } + } + + static closeAllCaptchaWindows() { + if (window.Bridge) { + window.Bridge.closeAllCaptchaWindows(); + } else { + // TODO - Show notification #77: https://github.com/walmat/nebula/issues/77 + console.error('Unable to close all windows'); + } + } + constructor(props) { super(props); const classNameCalc = (...supportedRoutes) => route => @@ -115,6 +134,7 @@ export class NavbarPrimitive extends PureComponent { render() { const { name, version } = NavbarPrimitive._getAppData(); + const { theme } = this.props; return (
@@ -127,9 +147,27 @@ export class NavbarPrimitive extends PureComponent {
{this.renderNavbarIconRows()}
-
-
-
+
+ +
+
+ +
+
+
+

{name.replace('-', ' ')}

@@ -165,6 +203,7 @@ export class NavbarPrimitive extends PureComponent { NavbarPrimitive.propTypes = { history: PropTypes.objectOf(PropTypes.any).isRequired, navbar: PropTypes.objectOf(PropTypes.any).isRequired, + theme: PropTypes.string.isRequired, onRoute: PropTypes.func.isRequired, onKeyPress: PropTypes.func, }; diff --git a/packages/frontend/src/navbar/navbar.scss b/packages/frontend/src/navbar/navbar.scss index d19caf68..f521fb8d 100644 --- a/packages/frontend/src/navbar/navbar.scss +++ b/packages/frontend/src/navbar/navbar.scss @@ -1,3 +1,4 @@ +@import './mixins'; @import '../themes'; %icons-base { @@ -7,6 +8,20 @@ } } +%button-base { + @include navbar-font(); + font-size: 12px; + width: 65px; + height: 29px; + cursor: pointer; + border: none; + border-radius: 3px; +} + +.react-bodymovin-container > svg { + cursor: default !important; +} + .active { opacity: 0.8333; } @@ -32,8 +47,8 @@ } &__icons { - margin-top: 55px !important; - margin-bottom: 75px !important; + margin-top: 10px !important; + margin-bottom: 10px !important; } &__icon { @@ -52,15 +67,45 @@ @extend %icons-base; width: 42px; height: 46px; + // temporary while servers page is disabled + & > div > svg { + cursor: not-allowed !important; + } } &--settings { @extend %icons-base; + margin-left: -2px; width: 40px; height: 60px; } } + &__button { + &--open-captcha { + @extend %button-base; + @include themify($themes) { + background: themed('buttonColorPrimary'); + color: themed('buttonPrimaryText'); + } + margin-top: 19px; + width: 75px; + } + &--close-captcha { + @extend %button-base; + @include themify($themes) { + background: themed('buttonColorSecondary'); + color: themed('buttonPrimaryText'); + } + margin-top: 19px; + width: 75px; + } + } + &__text { + &--gap { + margin-top: 15px !important; + } + &--app-name { text-transform: capitalize; text-align: center; @@ -71,6 +116,7 @@ } &--app-version { text-align: center; + cursor: pointer; opacity: 0.75; color: #f0405e !important; cursor: pointer; diff --git a/packages/frontend/src/profiles/_payment.scss b/packages/frontend/src/profiles/_payment.scss index 96411dfc..c114ab93 100644 --- a/packages/frontend/src/profiles/_payment.scss +++ b/packages/frontend/src/profiles/_payment.scss @@ -14,6 +14,7 @@ &__input-group { border-radius: 3px; + margin-bottom: 12px; &--email { @extend %input_field; diff --git a/packages/frontend/src/profiles/_profiles.scss b/packages/frontend/src/profiles/_profiles.scss index c0762453..301b3969 100644 --- a/packages/frontend/src/profiles/_profiles.scss +++ b/packages/frontend/src/profiles/_profiles.scss @@ -9,6 +9,9 @@ &--input-fields { margin-top: 22px !important; } + &--save-row { + margin-top: 28px !important; + } .profiles__fields { &--matches { @@ -25,13 +28,10 @@ height: 29px; border: 1px solid; border-radius: 3px; - margin-left: -25px; - margin-top: -18px; } &--save { @extend %input_field; - margin-top: 15px; background: #f0405e; color: #efefef; font-size: 12px; diff --git a/packages/frontend/src/profiles/_rates.scss b/packages/frontend/src/profiles/_rates.scss new file mode 100644 index 00000000..beec6738 --- /dev/null +++ b/packages/frontend/src/profiles/_rates.scss @@ -0,0 +1,71 @@ +@import 'mixins'; +@import '../themes'; + +%input_field { + border: 1px solid; + margin: 9px 0; + margin-right: 15px; + height: 29px; +} + +.profiles-rates { + &__section-header { + max-height: 32px; + margin: 14px 0 !important; + } + + &__input-group { + border-radius: 3px; + margin-bottom: 28px; + + &--delete { + @include button-font(); + @include themify($themes) { + background: themed('buttonColorPrimary'); + color: themed('buttonPrimaryText'); + } + border-radius: 3px; + border: none; + width: 83px; + height: 29px; + cursor: pointer; + margin-top: 8px; + } + + &--clear { + @include button-font(); + @include themify($themes) { + background: themed('buttonColorSecondary'); + color: themed('buttonPrimaryText'); + } + border-radius: 3px; + border: none; + width: 83px; + height: 29px; + cursor: pointer; + margin-top: 8px; + } + + &--rate { + @extend %input_field; + margin-top: 17px !important; + width: 155px; + } + + &--price { + @extend %input_field; + margin-top: 17px !important; + width: 79px; + } + + &--site { + width: 117px; + height: 29px; + } + + &--name { + width: 117px; + height: 29px; + } + } +} diff --git a/packages/frontend/src/profiles/profiles.css b/packages/frontend/src/profiles/profiles.css index 862706bd..bcd54b87 100644 --- a/packages/frontend/src/profiles/profiles.css +++ b/packages/frontend/src/profiles/profiles.css @@ -175,7 +175,7 @@ /* * Implementation of themes */ -.profiles .profiles__fields--save, .profiles .profiles-payment__input-group--cvv, .profiles .profiles-payment__input-group--expiration, .profiles .profiles-payment__input-group--card-number, .profiles .profiles-payment__input-group--email, .profiles .shipping-profiles-location__input-group--phone, .profiles .shipping-profiles-location__input-group--zip-code, .profiles .shipping-profiles-location__input-group--city, .profiles .shipping-profiles-location__input-group--address-two, .profiles .shipping-profiles-location__input-group--address-one, .profiles .shipping-profiles-location__input-group--last-name, .profiles .shipping-profiles-location__input-group--first-name, .profiles .billing-profiles-location__input-group--phone, .profiles .billing-profiles-location__input-group--zip-code, .profiles .billing-profiles-location__input-group--city, .profiles .billing-profiles-location__input-group--address-two, .profiles .billing-profiles-location__input-group--address-one, .profiles .billing-profiles-location__input-group--last-name, .profiles .billing-profiles-location__input-group--first-name { +.profiles .profiles__fields--save, .profiles-rates__input-group--price, .profiles-rates__input-group--rate, .profiles .profiles-payment__input-group--cvv, .profiles .profiles-payment__input-group--expiration, .profiles .profiles-payment__input-group--card-number, .profiles .profiles-payment__input-group--email, .profiles .shipping-profiles-location__input-group--phone, .profiles .shipping-profiles-location__input-group--zip-code, .profiles .shipping-profiles-location__input-group--city, .profiles .shipping-profiles-location__input-group--address-two, .profiles .shipping-profiles-location__input-group--address-one, .profiles .shipping-profiles-location__input-group--last-name, .profiles .shipping-profiles-location__input-group--first-name, .profiles .billing-profiles-location__input-group--phone, .profiles .billing-profiles-location__input-group--zip-code, .profiles .billing-profiles-location__input-group--city, .profiles .billing-profiles-location__input-group--address-two, .profiles .billing-profiles-location__input-group--address-one, .profiles .billing-profiles-location__input-group--last-name, .profiles .billing-profiles-location__input-group--first-name { border: 1px solid; margin: 9px 0; margin-right: 15px; @@ -251,7 +251,7 @@ width: 147px; } -.profiles .profiles__fields--save, .profiles .profiles-payment__input-group--cvv, .profiles .profiles-payment__input-group--expiration, .profiles .profiles-payment__input-group--card-number, .profiles .profiles-payment__input-group--email, .profiles .billing-profiles-location__input-group--first-name, .profiles .billing-profiles-location__input-group--last-name, .profiles .billing-profiles-location__input-group--address-one, .profiles .billing-profiles-location__input-group--address-two, .profiles .billing-profiles-location__input-group--city, .profiles .billing-profiles-location__input-group--zip-code, .profiles .billing-profiles-location__input-group--phone, .profiles .shipping-profiles-location__input-group--first-name, .profiles .shipping-profiles-location__input-group--last-name, .profiles .shipping-profiles-location__input-group--address-one, .profiles .shipping-profiles-location__input-group--address-two, .profiles .shipping-profiles-location__input-group--city, .profiles .shipping-profiles-location__input-group--zip-code, .profiles .shipping-profiles-location__input-group--phone { +.profiles .profiles__fields--save, .profiles-rates__input-group--price, .profiles-rates__input-group--rate, .profiles .profiles-payment__input-group--cvv, .profiles .profiles-payment__input-group--expiration, .profiles .profiles-payment__input-group--card-number, .profiles .profiles-payment__input-group--email, .profiles .billing-profiles-location__input-group--first-name, .profiles .billing-profiles-location__input-group--last-name, .profiles .billing-profiles-location__input-group--address-one, .profiles .billing-profiles-location__input-group--address-two, .profiles .billing-profiles-location__input-group--city, .profiles .billing-profiles-location__input-group--zip-code, .profiles .billing-profiles-location__input-group--phone, .profiles .shipping-profiles-location__input-group--first-name, .profiles .shipping-profiles-location__input-group--last-name, .profiles .shipping-profiles-location__input-group--address-one, .profiles .shipping-profiles-location__input-group--address-two, .profiles .shipping-profiles-location__input-group--city, .profiles .shipping-profiles-location__input-group--zip-code, .profiles .shipping-profiles-location__input-group--phone { border: 1px solid; margin: 9px 0; margin-right: 15px; @@ -264,6 +264,7 @@ } .profiles .profiles-payment__input-group { border-radius: 3px; + margin-bottom: 12px; } .profiles .profiles-payment__input-group--email { width: 249px; @@ -278,7 +279,95 @@ width: 78px; } -.profiles .profiles__fields--save, .profiles .billing-profiles-location__input-group--first-name, .profiles .billing-profiles-location__input-group--last-name, .profiles .billing-profiles-location__input-group--address-one, .profiles .billing-profiles-location__input-group--address-two, .profiles .billing-profiles-location__input-group--city, .profiles .billing-profiles-location__input-group--zip-code, .profiles .billing-profiles-location__input-group--phone, .profiles .shipping-profiles-location__input-group--first-name, .profiles .shipping-profiles-location__input-group--last-name, .profiles .shipping-profiles-location__input-group--address-one, .profiles .shipping-profiles-location__input-group--address-two, .profiles .shipping-profiles-location__input-group--city, .profiles .shipping-profiles-location__input-group--zip-code, .profiles .shipping-profiles-location__input-group--phone, .profiles .profiles-payment__input-group--email, .profiles .profiles-payment__input-group--card-number, .profiles .profiles-payment__input-group--expiration, .profiles .profiles-payment__input-group--cvv { +/* + * Implementation of themes + */ +/* + * Implementation of themes + */ +.profiles .profiles__fields--save, .profiles-rates__input-group--price, .profiles-rates__input-group--rate, .profiles .billing-profiles-location__input-group--first-name, .profiles .billing-profiles-location__input-group--last-name, .profiles .billing-profiles-location__input-group--address-one, .profiles .billing-profiles-location__input-group--address-two, .profiles .billing-profiles-location__input-group--city, .profiles .billing-profiles-location__input-group--zip-code, .profiles .billing-profiles-location__input-group--phone, .profiles .shipping-profiles-location__input-group--first-name, .profiles .shipping-profiles-location__input-group--last-name, .profiles .shipping-profiles-location__input-group--address-one, .profiles .shipping-profiles-location__input-group--address-two, .profiles .shipping-profiles-location__input-group--city, .profiles .shipping-profiles-location__input-group--zip-code, .profiles .shipping-profiles-location__input-group--phone, .profiles .profiles-payment__input-group--email, .profiles .profiles-payment__input-group--card-number, .profiles .profiles-payment__input-group--expiration, .profiles .profiles-payment__input-group--cvv { + border: 1px solid; + margin: 9px 0; + margin-right: 15px; + height: 29px; +} + +.profiles-rates__section-header { + max-height: 32px; + margin: 14px 0 !important; +} +.profiles-rates__input-group { + border-radius: 3px; + margin-bottom: 28px; +} +.profiles-rates__input-group--delete { + font-family: AvenirNext-Medium; + font-size: 12px; + letter-spacing: 0; + border-radius: 3px; + border: none; + width: 83px; + height: 29px; + cursor: pointer; + margin-top: 8px; +} +.theme-light .profiles-rates__input-group--delete { + color: #161318; +} +.theme-dark .profiles-rates__input-group--delete { + color: #efefef; +} +.theme-light .profiles-rates__input-group--delete { + background: #f0405e; + color: #f4f4f4; +} +.theme-dark .profiles-rates__input-group--delete { + background: #f0405e; + color: #f4f4f4; +} +.profiles-rates__input-group--clear { + font-family: AvenirNext-Medium; + font-size: 12px; + letter-spacing: 0; + border-radius: 3px; + border: none; + width: 83px; + height: 29px; + cursor: pointer; + margin-top: 8px; +} +.theme-light .profiles-rates__input-group--clear { + color: #161318; +} +.theme-dark .profiles-rates__input-group--clear { + color: #efefef; +} +.theme-light .profiles-rates__input-group--clear { + background: #46adb4; + color: #f4f4f4; +} +.theme-dark .profiles-rates__input-group--clear { + background: #46adb4; + color: #f4f4f4; +} +.profiles-rates__input-group--rate { + margin-top: 17px !important; + width: 155px; +} +.profiles-rates__input-group--price { + margin-top: 17px !important; + width: 79px; +} +.profiles-rates__input-group--site { + width: 117px; + height: 29px; +} +.profiles-rates__input-group--name { + width: 117px; + height: 29px; +} + +.profiles .profiles__fields--save, .profiles .billing-profiles-location__input-group--first-name, .profiles .billing-profiles-location__input-group--last-name, .profiles .billing-profiles-location__input-group--address-one, .profiles .billing-profiles-location__input-group--address-two, .profiles .billing-profiles-location__input-group--city, .profiles .billing-profiles-location__input-group--zip-code, .profiles .billing-profiles-location__input-group--phone, .profiles .shipping-profiles-location__input-group--first-name, .profiles .shipping-profiles-location__input-group--last-name, .profiles .shipping-profiles-location__input-group--address-one, .profiles .shipping-profiles-location__input-group--address-two, .profiles .shipping-profiles-location__input-group--city, .profiles .shipping-profiles-location__input-group--zip-code, .profiles .shipping-profiles-location__input-group--phone, .profiles .profiles-payment__input-group--email, .profiles .profiles-payment__input-group--card-number, .profiles .profiles-payment__input-group--expiration, .profiles .profiles-payment__input-group--cvv, .profiles-rates__input-group--rate, .profiles-rates__input-group--price { border: 1px solid; margin: 9px 0; margin-right: 15px; @@ -288,6 +377,9 @@ .profiles--input-fields { margin-top: 22px !important; } +.profiles--save-row { + margin-top: 28px !important; +} .profiles .profiles__fields--matches { margin-top: 0.5px; height: 21px; @@ -302,11 +394,8 @@ height: 29px; border: 1px solid; border-radius: 3px; - margin-left: -25px; - margin-top: -18px; } .profiles .profiles__fields--save { - margin-top: 15px; background: #f0405e; color: #efefef; font-size: 12px; diff --git a/packages/frontend/src/profiles/profiles.jsx b/packages/frontend/src/profiles/profiles.jsx index 8b3c5cd9..ab12281b 100644 --- a/packages/frontend/src/profiles/profiles.jsx +++ b/packages/frontend/src/profiles/profiles.jsx @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import PaymentFields from './paymentFields'; +import ShippingRateFields from './shippingRates'; import LocationFields from './locationFields'; import LoadProfile from './loadProfile'; import validationStatus from '../utils/validationStatus'; @@ -57,6 +58,9 @@ export class ProfilesPrimitive extends Component { render() { const { currentProfile, onProfileNameChange } = this.props; + const shippingRateFields = currentProfile.editId ? ( + + ) : null; return (
@@ -91,10 +95,18 @@ export class ProfilesPrimitive extends Component { } disabled={currentProfile.billingMatchesShipping} /> - +
+
+ +
+
{shippingRateFields}
+
-
-
+
+
+
+
+ ); + } + + constructor(props) { + super(props); + this.selects = { + [RATES_FIELDS.SITE]: { + placeholder: 'Choose Site', + type: 'site', + className: 'col col--no-gutter-left', + }, + [RATES_FIELDS.RATE]: { + placeholder: 'Choose Rate', + type: 'name', + className: 'col col--no-gutter', + }, + }; + + this.deleteShippingRate = this.deleteShippingRate.bind(this); + } + + deleteShippingRate() { + const { onDeleteShippingRate, value } = this.props; + let siteObject = []; + if (value.selectedSite) { + siteObject = value.rates.find(v => v.site.url === value.selectedSite.value); + if (siteObject && siteObject.selectedRate) { + onDeleteShippingRate(value.selectedSite, siteObject.selectedRate); + } + } + } + + createOnChangeHandler(field) { + const { onChange, value } = this.props; + switch (field) { + case RATES_FIELDS.SITE: { + return event => { + onChange({ field, value: event }, PROFILE_FIELDS.EDIT_SELECTED_SITE); + }; + } + default: { + return event => { + const rate = { name: event.label, price: event.price, rate: event.value }; + onChange({ field, value: { site: value.selectedSite, rate } }, PROFILE_FIELDS.EDIT_RATES); + }; + } + } + } + + renderSelect(field, value, options) { + const { theme } = this.props; + const { placeholder, type, className } = this.selects[field]; + return ( +
+ +
+
+ +
+
+
+
+ {ShippingRatesPrimitive.renderButton('delete', this.deleteShippingRate, 'Delete')} +
+
+
+ ); + } + + render() { + const { value } = this.props; + const rateFieldsComponent = value.rates.length ? ( + this.renderRateFields() + ) : ( +
+
+

No shipping rates found

+
+
+ ); + return ( +
+
+

Shipping Rates

+
+
+
+
+ {rateFieldsComponent} +
+
+
+
+ ); + } +} + +ShippingRatesPrimitive.propTypes = { + theme: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onDeleteShippingRate: PropTypes.func.isRequired, + value: defns.profile.isRequired, +}; + +export const mapStateToProps = (state, ownProps) => ({ + theme: state.theme, + value: ownProps.profileToEdit, +}); + +export const mapDispatchToProps = (dispatch, ownProps) => ({ + onChange: (changes, section) => { + dispatch(profileActions.edit(ownProps.profileToEdit.id, section, changes.value, changes.field)); + }, + onDeleteShippingRate: (site, rate) => { + dispatch(profileActions.deleteRate(site, rate)); + }, +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ShippingRatesPrimitive); diff --git a/packages/frontend/src/settings/_settings.scss b/packages/frontend/src/settings/_settings.scss index c876a1cf..14266523 100644 --- a/packages/frontend/src/settings/_settings.scss +++ b/packages/frontend/src/settings/_settings.scss @@ -19,6 +19,7 @@ } .settings-defaults { + margin-bottom: 10px; &__section-header { margin-top: 15px; } @@ -136,10 +137,20 @@ } &--button { - @extend %button-base; - @include themify($themes) { - background: themed('buttonColorPrimary'); - color: themed('buttonPrimaryText'); + &-slack { + @extend %button-base; + @include themify($themes) { + background: themed('buttonColorPrimary'); + color: themed('buttonPrimaryText'); + } + } + + &-discord { + @extend %button-base; + @include themify($themes) { + background: themed('buttonColorPrimary'); + color: themed('buttonPrimaryText'); + } } } } diff --git a/packages/frontend/src/settings/_shippingManager.scss b/packages/frontend/src/settings/_shippingManager.scss new file mode 100644 index 00000000..c4353a00 --- /dev/null +++ b/packages/frontend/src/settings/_shippingManager.scss @@ -0,0 +1,108 @@ +%button-base { + @include settings-font(); + font-size: 12px; + width: 65px; + height: 29px; + cursor: pointer; + border: none; + border-radius: 3px; +} + +.settings { + &--shipping-manager { + &__section-header { + margin-top: 15px; + } + + &__input-group { + width: 422px; + height: 228px; + @include themify($themes) { + border-color: themed('tableBorderColor'); + } + border-width: 1px; + border-style: solid; + padding-bottom: 3px; + border-radius: 3px; + + &--product { + @include settings-font(); + border-width: 1px; + border-style: solid; + height: 29px; + border-radius: 3px; + width: 170px; + border-radius: 3px; + } + &--name { + @include settings-font(); + border-width: 1px; + border-style: solid; + height: 29px; + border-radius: 3px; + width: 170px; + border-radius: 3px; + } + &--profile { + @include settings-font(); + width: 170px; + border-radius: 3px; + } + &--site { + @include settings-font(); + width: 170px; + border-radius: 3px; + } + &--username { + @include settings-font(); + border-width: 1px; + border-style: solid; + height: 29px; + border-radius: 3px; + width: 170px; + border-radius: 3px; + } + &--password { + @include settings-font(); + border-width: 1px; + border-style: solid; + height: 29px; + border-radius: 3px; + width: 170px; + border-radius: 3px; + } + + &--label { + @include settings-font(); + @include themify($themes) { + color: themed('tableHeaderColor'); + } + margin: 0; + margin-bottom: 2px; + } + + &--fetch { + @extend %button-base; + @include themify($themes) { + background: themed('buttonColorPrimary') !important; + color: themed('buttonPrimaryText') !important; + } + width: 83px !important; + &:disabled { + opacity: 0.5; + cursor: not-allowed !important; + } + } + + &--clear { + @extend %button-base; + @include themify($themes) { + background: themed('buttonColorSecondary') !important; + color: themed('buttonPrimaryText') !important; + } + width: 83px !important; + margin-right: 7.5px; + } + } + } +} diff --git a/packages/frontend/src/settings/defaults.jsx b/packages/frontend/src/settings/defaults.jsx index 70e2ef4b..94428d44 100644 --- a/packages/frontend/src/settings/defaults.jsx +++ b/packages/frontend/src/settings/defaults.jsx @@ -115,18 +115,18 @@ export class DefaultsPrimitive extends Component { } render() { - const { settings } = this.props; - const defaultSizes = settings.defaults.sizes.map(s => ({ value: s, label: `${s}` })); + const { defaults } = this.props; + const defaultSizes = defaults.sizes.map(s => ({ value: s, label: `${s}` })); let defaultProfileValue = null; - if (settings.defaults.profile.id !== null) { + if (defaults.profile.id !== null) { defaultProfileValue = { - value: settings.defaults.profile.id, - label: settings.defaults.profile.profileName, + value: defaults.profile.id, + label: defaults.profile.profileName, }; } return (
-
+
@@ -153,10 +153,7 @@ export class DefaultsPrimitive extends Component {
- {this.renderDefaultsButton( - SETTINGS_FIELDS.SAVE_DEFAULTS, - settings.defaults, - )} + {this.renderDefaultsButton(SETTINGS_FIELDS.SAVE_DEFAULTS, defaults)}
{this.renderDefaultsButton(SETTINGS_FIELDS.CLEAR_DEFAULTS)} @@ -178,7 +175,7 @@ DefaultsPrimitive.propTypes = { onSaveDefaults: PropTypes.func.isRequired, onClearDefaults: PropTypes.func.isRequired, profiles: pDefns.profileList.isRequired, - settings: sDefns.settings.isRequired, + defaults: sDefns.defaults.isRequired, onKeyPress: PropTypes.func, theme: PropTypes.string.isRequired, errors: sDefns.settingsErrors.isRequired, @@ -190,7 +187,7 @@ DefaultsPrimitive.defaultProps = { export const mapStateToProps = state => ({ profiles: state.profiles, - settings: state.settings, + defaults: state.settings.defaults, errors: state.settings.errors, theme: state.theme, }); @@ -203,7 +200,7 @@ export const mapDispatchToProps = dispatch => ({ dispatch(settingsActions.save(defaults)); }, onClearDefaults: changes => { - dispatch(settingsActions.clear(changes)); + dispatch(settingsActions.clearDefaults(changes)); }, }); diff --git a/packages/frontend/src/settings/settings.css b/packages/frontend/src/settings/settings.css index da129f87..a2bcc445 100644 --- a/packages/frontend/src/settings/settings.css +++ b/packages/frontend/src/settings/settings.css @@ -40,6 +40,182 @@ background: #313236; } +.settings__input-group--button-discord, .settings__input-group--button-slack, .settings__button--close-captcha, .settings__button--open-captcha, .settings-defaults__input-group--clear, .settings-defaults__input-group--save, .settings--shipping-manager__input-group--clear, .settings--shipping-manager__input-group--fetch { + font-family: AvenirNext-Medium; + font-size: 9px; + letter-spacing: 0; + font-size: 12px; + width: 65px; + height: 29px; + cursor: pointer; + border: none; + border-radius: 3px; +} +.theme-light .settings__input-group--button-discord, .theme-light .settings__input-group--button-slack, .theme-light .settings__button--close-captcha, .theme-light .settings__button--open-captcha, .theme-light .settings-defaults__input-group--clear, .theme-light .settings-defaults__input-group--save, .theme-light .settings--shipping-manager__input-group--clear, .theme-light .settings--shipping-manager__input-group--fetch { + color: #161318; +} +.theme-dark .settings__input-group--button-discord, .theme-dark .settings__input-group--button-slack, .theme-dark .settings__button--close-captcha, .theme-dark .settings__button--open-captcha, .theme-dark .settings-defaults__input-group--clear, .theme-dark .settings-defaults__input-group--save, .theme-dark .settings--shipping-manager__input-group--clear, .theme-dark .settings--shipping-manager__input-group--fetch { + color: #efefef; +} + +.settings--shipping-manager__section-header { + margin-top: 15px; +} +.settings--shipping-manager__input-group { + width: 422px; + height: 228px; + border-width: 1px; + border-style: solid; + padding-bottom: 3px; + border-radius: 3px; +} +.theme-light .settings--shipping-manager__input-group { + border-color: #f0405e; +} +.theme-dark .settings--shipping-manager__input-group { + border-color: #f0405e; +} +.settings--shipping-manager__input-group--product { + font-family: AvenirNext-Medium; + font-size: 9px; + letter-spacing: 0; + border-width: 1px; + border-style: solid; + height: 29px; + border-radius: 3px; + width: 170px; + border-radius: 3px; +} +.theme-light .settings--shipping-manager__input-group--product { + color: #161318; +} +.theme-dark .settings--shipping-manager__input-group--product { + color: #efefef; +} +.settings--shipping-manager__input-group--name { + font-family: AvenirNext-Medium; + font-size: 9px; + letter-spacing: 0; + border-width: 1px; + border-style: solid; + height: 29px; + border-radius: 3px; + width: 170px; + border-radius: 3px; +} +.theme-light .settings--shipping-manager__input-group--name { + color: #161318; +} +.theme-dark .settings--shipping-manager__input-group--name { + color: #efefef; +} +.settings--shipping-manager__input-group--profile { + font-family: AvenirNext-Medium; + font-size: 9px; + letter-spacing: 0; + width: 170px; + border-radius: 3px; +} +.theme-light .settings--shipping-manager__input-group--profile { + color: #161318; +} +.theme-dark .settings--shipping-manager__input-group--profile { + color: #efefef; +} +.settings--shipping-manager__input-group--site { + font-family: AvenirNext-Medium; + font-size: 9px; + letter-spacing: 0; + width: 170px; + border-radius: 3px; +} +.theme-light .settings--shipping-manager__input-group--site { + color: #161318; +} +.theme-dark .settings--shipping-manager__input-group--site { + color: #efefef; +} +.settings--shipping-manager__input-group--username { + font-family: AvenirNext-Medium; + font-size: 9px; + letter-spacing: 0; + border-width: 1px; + border-style: solid; + height: 29px; + border-radius: 3px; + width: 170px; + border-radius: 3px; +} +.theme-light .settings--shipping-manager__input-group--username { + color: #161318; +} +.theme-dark .settings--shipping-manager__input-group--username { + color: #efefef; +} +.settings--shipping-manager__input-group--password { + font-family: AvenirNext-Medium; + font-size: 9px; + letter-spacing: 0; + border-width: 1px; + border-style: solid; + height: 29px; + border-radius: 3px; + width: 170px; + border-radius: 3px; +} +.theme-light .settings--shipping-manager__input-group--password { + color: #161318; +} +.theme-dark .settings--shipping-manager__input-group--password { + color: #efefef; +} +.settings--shipping-manager__input-group--label { + font-family: AvenirNext-Medium; + font-size: 9px; + letter-spacing: 0; + margin: 0; + margin-bottom: 2px; +} +.theme-light .settings--shipping-manager__input-group--label { + color: #161318; +} +.theme-dark .settings--shipping-manager__input-group--label { + color: #efefef; +} +.theme-light .settings--shipping-manager__input-group--label { + color: #161318; +} +.theme-dark .settings--shipping-manager__input-group--label { + color: #dcdcdc; +} +.settings--shipping-manager__input-group--fetch { + width: 83px !important; +} +.theme-light .settings--shipping-manager__input-group--fetch { + background: #f0405e !important; + color: #f4f4f4 !important; +} +.theme-dark .settings--shipping-manager__input-group--fetch { + background: #f0405e !important; + color: #f4f4f4 !important; +} +.settings--shipping-manager__input-group--fetch:disabled { + opacity: 0.5; + cursor: not-allowed !important; +} +.settings--shipping-manager__input-group--clear { + width: 83px !important; + margin-right: 7.5px; +} +.theme-light .settings--shipping-manager__input-group--clear { + background: #46adb4 !important; + color: #f4f4f4 !important; +} +.theme-dark .settings--shipping-manager__input-group--clear { + background: #46adb4 !important; + color: #f4f4f4 !important; +} + /* * Implementation of themes */ @@ -53,7 +229,7 @@ border-radius: 3px; } -.settings__input-group--button, .settings__button--close-captcha, .settings__button--open-captcha, .settings-defaults__input-group--clear, .settings-defaults__input-group--save { +.settings__input-group--button-discord, .settings__input-group--button-slack, .settings__button--close-captcha, .settings__button--open-captcha, .settings-defaults__input-group--clear, .settings-defaults__input-group--save, .settings--shipping-manager__input-group--fetch, .settings--shipping-manager__input-group--clear { font-family: AvenirNext-Medium; font-size: 9px; letter-spacing: 0; @@ -64,13 +240,16 @@ border: none; border-radius: 3px; } -.theme-light .settings__input-group--button, .theme-light .settings__button--close-captcha, .theme-light .settings__button--open-captcha, .theme-light .settings-defaults__input-group--clear, .theme-light .settings-defaults__input-group--save { +.theme-light .settings__input-group--button-discord, .theme-light .settings__input-group--button-slack, .theme-light .settings__button--close-captcha, .theme-light .settings__button--open-captcha, .theme-light .settings-defaults__input-group--clear, .theme-light .settings-defaults__input-group--save, .theme-light .settings--shipping-manager__input-group--fetch, .theme-light .settings--shipping-manager__input-group--clear { color: #161318; } -.theme-dark .settings__input-group--button, .theme-dark .settings__button--close-captcha, .theme-dark .settings__button--open-captcha, .theme-dark .settings-defaults__input-group--clear, .theme-dark .settings-defaults__input-group--save { +.theme-dark .settings__input-group--button-discord, .theme-dark .settings__input-group--button-slack, .theme-dark .settings__button--close-captcha, .theme-dark .settings__button--open-captcha, .theme-dark .settings-defaults__input-group--clear, .theme-dark .settings-defaults__input-group--save, .theme-dark .settings--shipping-manager__input-group--fetch, .theme-dark .settings--shipping-manager__input-group--clear { color: #efefef; } +.settings-defaults { + margin-bottom: 10px; +} .settings-defaults__section-header { margin-top: 15px; } @@ -225,11 +404,19 @@ .settings__input-group--error-delay { width: 205px; } -.theme-light .settings__input-group--button { +.theme-light .settings__input-group--button-slack { + background: #f0405e; + color: #f4f4f4; +} +.theme-dark .settings__input-group--button-slack { + background: #f0405e; + color: #f4f4f4; +} +.theme-light .settings__input-group--button-discord { background: #f0405e; color: #f4f4f4; } -.theme-dark .settings__input-group--button { +.theme-dark .settings__input-group--button-discord { background: #f0405e; color: #f4f4f4; } diff --git a/packages/frontend/src/settings/settings.jsx b/packages/frontend/src/settings/settings.jsx index 2ddce0d0..2a7948be 100644 --- a/packages/frontend/src/settings/settings.jsx +++ b/packages/frontend/src/settings/settings.jsx @@ -1,163 +1,34 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import NumberFormat from 'react-number-format'; +import React from 'react'; import ProxyList from './proxyList'; import Webhooks from './webhooks'; import Defaults from './defaults'; -import sDefns from '../utils/definitions/settingsDefinitions'; -import { settingsActions, mapSettingsFieldToKey, SETTINGS_FIELDS } from '../state/actions'; -import { buildStyle } from '../utils/styles'; -import { mapThemeToColor } from '../constants/themes'; +import ShippingManager from './shippingManager'; import '../app.css'; import './settings.css'; -export class SettingsPrimitive extends Component { - static openHarvesterWindow(theme) { - if (window.Bridge) { - window.Bridge.launchCaptchaHarvester({ backgroundColor: mapThemeToColor[theme] }); - } else { - // TODO - Show notification #77: https://github.com/walmat/nebula/issues/77 - console.error('Unable to launch harvester!'); - } - } - - static closeAllCaptchaWindows() { - if (window.Bridge) { - window.Bridge.closeAllCaptchaWindows(); - } else { - // TODO - Show notification #77: https://github.com/walmat/nebula/issues/77 - console.error('Unable to close all windows'); - } - } - - constructor(props) { - super(props); - this.settingsDelays = { - [SETTINGS_FIELDS.EDIT_MONITOR_DELAY]: { - className: 'col col--no-gutter', - label: 'Monitor Delay', - placeholder: '3500', - delayType: 'monitor', - }, - [SETTINGS_FIELDS.EDIT_ERROR_DELAY]: { - className: 'col col--end col--no-gutter-right', - label: 'Error Delay', - placeholder: '3500', - delayType: 'error', - }, - }; - } - - createOnChangeHandler(field) { - const { onSettingsChange } = this.props; - return event => { - onSettingsChange({ - field, - value: event.target.value, - }); - }; - } - - renderDelay(field, value) { - const { errors } = this.props; - const { className, delayType, label, placeholder } = this.settingsDelays[field]; - return ( -
-

{label}

- -
- ); - } - - render() { - const { settings, theme } = this.props; - const { errorDelay, monitorDelay } = settings; - return ( -
+export const SettingsPrimitive = () => ( +
+
+
+
+
+

Settings

+
+
-
-
-
-

Settings

-
-
-
-
- -
-
- - -
-
-
- {this.renderDelay(SETTINGS_FIELDS.EDIT_MONITOR_DELAY, monitorDelay)} - {this.renderDelay(SETTINGS_FIELDS.EDIT_ERROR_DELAY, errorDelay)} -
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
+
+ +
+
+ + +
- ); - } -} - -SettingsPrimitive.propTypes = { - onSettingsChange: PropTypes.func.isRequired, - settings: sDefns.settings.isRequired, - theme: PropTypes.string.isRequired, - errors: sDefns.settingsErrors.isRequired, -}; - -export const mapStateToProps = state => ({ - settings: state.settings, - theme: state.theme, - errors: state.settings.errors, -}); - -export const mapDispatchToProps = dispatch => ({ - onSettingsChange: changes => { - dispatch(settingsActions.edit(changes.field, changes.value)); - }, -}); +
+
+); -export default connect( - mapStateToProps, - mapDispatchToProps, -)(SettingsPrimitive); +export default SettingsPrimitive; diff --git a/packages/frontend/src/settings/settings.scss b/packages/frontend/src/settings/settings.scss index 4c1d05cc..1cd21497 100644 --- a/packages/frontend/src/settings/settings.scss +++ b/packages/frontend/src/settings/settings.scss @@ -1,6 +1,7 @@ // Settings Page Rules @import '_proxylist'; +@import '_shippingManager'; @import '_settings'; /* Temporary */ diff --git a/packages/frontend/src/settings/shippingManager.jsx b/packages/frontend/src/settings/shippingManager.jsx new file mode 100644 index 00000000..2fcb93b0 --- /dev/null +++ b/packages/frontend/src/settings/shippingManager.jsx @@ -0,0 +1,332 @@ +import React, { Component } from 'react'; +import Select from 'react-select'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { buildStyle } from '../utils/styles'; +import { DropdownIndicator, colourStyles } from '../utils/styles/select'; +import { settingsActions, mapSettingsFieldToKey, SETTINGS_FIELDS } from '../state/actions'; +import pDefns from '../utils/definitions/profileDefinitions'; +import sDefns from '../utils/definitions/settingsDefinitions'; +import getAllSupportedSitesSorted from '../constants/getAllSites'; + +import addTestId from '../utils/addTestId'; + +export class ShippingManagerPrimitive extends Component { + constructor(props) { + super(props); + this.selects = { + [SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE]: { + label: 'Profile', + placeholder: 'Choose Profile', + type: 'profile', + className: 'col col--no-gutter-right', + }, + [SETTINGS_FIELDS.EDIT_SHIPPING_SITE]: { + label: 'Site', + placeholder: 'Choose Site', + type: 'site', + className: 'col col--gutter-left', + }, + }; + this.buttons = { + [SETTINGS_FIELDS.CLEAR_SHIPPING_FIELDS]: { + label: 'Clear', + type: 'clear', + }, + [SETTINGS_FIELDS.FETCH_SHIPPING_METHODS]: { + label: 'Fetch Rates', + type: 'fetch', + }, + }; + } + + buildProfileOptions() { + const { profiles } = this.props; + const opts = []; + profiles.forEach(profile => { + opts.push({ value: profile.id, label: profile.profileName }); + }); + return opts; + } + + createOnChangeHandler(field) { + const { onSettingsChange, profiles } = this.props; + switch (field) { + case SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE: + return event => { + const change = profiles.find(p => p.id === event.value); + onSettingsChange({ field, value: change }); + }; + case SETTINGS_FIELDS.EDIT_SHIPPING_SITE: + return event => { + const site = { + name: event.label, + url: event.value, + apiKey: event.apiKey, + localCheckout: event.localCheckout || false, + special: event.special || false, + auth: event.auth, + }; + onSettingsChange({ field, value: site }); + }; + default: + return event => { + onSettingsChange({ + field, + value: event.target.value, + }); + }; + } + } + + renderButton(field, value) { + const { + onKeyPress, + onClearShippingFields, + onFetchShippingMethods, + onStopShippingMethods, + shipping: { status }, + } = this.props; + const { type } = this.buttons[field]; + let { label } = this.buttons[field]; + let onClick; + switch (field) { + case SETTINGS_FIELDS.FETCH_SHIPPING_METHODS: { + onClick = () => onFetchShippingMethods(value); + break; + } + case SETTINGS_FIELDS.CLEAR_SHIPPING_FIELDS: { + if (status === 'inprogress') { + onClick = () => onStopShippingMethods(); + label = 'Cancel'; + break; + } + onClick = () => onClearShippingFields(field); + break; + } + default: { + onClick = () => {}; + } + } + const disabled = field === SETTINGS_FIELDS.FETCH_SHIPPING_METHODS && status === 'inprogress'; + return ( + + ); + } + + renderSelect(field, value, options) { + const { errors, theme } = this.props; + const { label, placeholder, className, type } = this.selects[field]; + return ( +
+

{label}

+ +
+
+

Rate Name

+ +
+
+
+ {this.renderSelect( + SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE, + shippingProfileValue, + this.buildProfileOptions(), + )} + {this.renderSelect( + SETTINGS_FIELDS.EDIT_SHIPPING_SITE, + shippingSiteValue, + getAllSupportedSitesSorted(), + )} +
+
+
+

Username

+ +
+
+

Password

+ +
+
+
+
+ {this.renderButton(SETTINGS_FIELDS.FETCH_SHIPPING_METHODS, shipping)} +
+
+ {this.renderButton(SETTINGS_FIELDS.CLEAR_SHIPPING_FIELDS)} +
+
+
+
+
+
+
+
+
+ ); + } +} + +ShippingManagerPrimitive.propTypes = { + onSettingsChange: PropTypes.func.isRequired, + onFetchShippingMethods: PropTypes.func.isRequired, + onStopShippingMethods: PropTypes.func.isRequired, + onClearShippingFields: PropTypes.func.isRequired, + profiles: pDefns.profileList.isRequired, + shipping: sDefns.shipping.isRequired, + onKeyPress: PropTypes.func, + theme: PropTypes.string.isRequired, + errors: sDefns.shippingErrors.isRequired, +}; + +ShippingManagerPrimitive.defaultProps = { + onKeyPress: () => {}, +}; + +export const mapStateToProps = state => ({ + profiles: state.profiles, + shipping: state.settings.shipping, + errors: state.settings.shipping.errors, + theme: state.theme, +}); + +export const mapDispatchToProps = dispatch => ({ + onSettingsChange: changes => { + dispatch(settingsActions.edit(changes.field, changes.value)); + }, + onFetchShippingMethods: shipping => { + dispatch(settingsActions.fetch(shipping)); + }, + onClearShippingFields: changes => { + dispatch(settingsActions.clearShipping(changes)); + }, + onStopShippingMethods: () => { + dispatch(settingsActions.stop()); + }, +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ShippingManagerPrimitive); diff --git a/packages/frontend/src/settings/webhooks.jsx b/packages/frontend/src/settings/webhooks.jsx index 7257e41d..0cc40b49 100644 --- a/packages/frontend/src/settings/webhooks.jsx +++ b/packages/frontend/src/settings/webhooks.jsx @@ -6,12 +6,12 @@ import { settingsActions, mapSettingsFieldToKey, SETTINGS_FIELDS } from '../stat import sDefns from '../utils/definitions/settingsDefinitions'; export class WebhooksPrimitive extends Component { - static renderWebhookButton({ onClick, onKeyPress }) { + static renderWebhookButton(type, onClick, onKeyPress) { return (
@@ -196,8 +249,11 @@ export class TasksPrimitive extends Component { TasksPrimitive.propTypes = { newTask: tDefns.task.isRequired, + monitorDelay: PropTypes.number.isRequired, + errorDelay: PropTypes.number.isRequired, tasks: tDefns.taskList.isRequired, proxies: PropTypes.arrayOf(sDefns.proxy).isRequired, + onSettingsChange: PropTypes.func.isRequired, onDestroyTask: PropTypes.func.isRequired, onStartTask: PropTypes.func.isRequired, onStopTask: PropTypes.func.isRequired, @@ -210,11 +266,16 @@ TasksPrimitive.defaultProps = { export const mapStateToProps = state => ({ newTask: state.newTask, + monitorDelay: state.settings.monitorDelay, + errorDelay: state.settings.errorDelay, tasks: state.tasks, proxies: state.settings.proxies, }); export const mapDispatchToProps = dispatch => ({ + onSettingsChange: changes => { + dispatch(settingsActions.edit(changes.field, changes.value)); + }, onDestroyTask: task => { dispatch(taskActions.destroy(task, 'all')); }, diff --git a/packages/frontend/src/utils/definitions/profileDefinitions.js b/packages/frontend/src/utils/definitions/profileDefinitions.js index 65611feb..86611ace 100644 --- a/packages/frontend/src/utils/definitions/profileDefinitions.js +++ b/packages/frontend/src/utils/definitions/profileDefinitions.js @@ -2,6 +2,7 @@ import locationState from './profiles/locationState'; import locationStateErrors from './profiles/locationStateErrors'; import paymentState from './profiles/paymentState'; import paymentStateErrors from './profiles/paymentStateErrors'; +import shippingRate from './profiles/rates'; import profile from './profiles/profile'; import profileList from './profiles/profileList'; @@ -10,6 +11,7 @@ export default { locationStateErrors, paymentState, paymentStateErrors, + rates: shippingRate, profile, profileList, }; diff --git a/packages/frontend/src/utils/definitions/profiles/profile.js b/packages/frontend/src/utils/definitions/profiles/profile.js index d876f690..83e9f6df 100644 --- a/packages/frontend/src/utils/definitions/profiles/profile.js +++ b/packages/frontend/src/utils/definitions/profiles/profile.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import locationState from './locationState'; import paymentState from './paymentState'; +import rates from './rates'; const profile = PropTypes.shape({ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), @@ -13,6 +14,11 @@ const profile = PropTypes.shape({ shipping: locationState, billing: locationState, payment: paymentState, + rates, + selectedSite: PropTypes.shape({ + name: PropTypes.string, + url: PropTypes.string, + }), }); export default profile; diff --git a/packages/frontend/src/utils/definitions/profiles/rates.js b/packages/frontend/src/utils/definitions/profiles/rates.js new file mode 100644 index 00000000..99a80fc1 --- /dev/null +++ b/packages/frontend/src/utils/definitions/profiles/rates.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; + +export const rate = PropTypes.shape({ + name: PropTypes.string, + rate: PropTypes.string, + price: PropTypes.string, +}); + +export const rateEntry = PropTypes.shape({ + site: PropTypes.shape({ + name: PropTypes.string, + url: PropTypes.string, + }), + rates: PropTypes.arrayOf(rate), + selectedRate: rate, +}); + +export const initialShippingRatesState = []; + +const rates = PropTypes.arrayOf(rateEntry); + +export default rates; diff --git a/packages/frontend/src/utils/definitions/settings/settings.js b/packages/frontend/src/utils/definitions/settings/settings.js index d7fc6339..9613ff07 100644 --- a/packages/frontend/src/utils/definitions/settings/settings.js +++ b/packages/frontend/src/utils/definitions/settings/settings.js @@ -4,11 +4,13 @@ import proxy from './proxy'; import proxyErrors from './proxyErrors'; import defaults from './defaults'; import settingsErrors from './settingsErrors'; +import shippingManager from './shippingManager'; const settings = PropTypes.shape({ proxies: PropTypes.arrayOf(proxy), proxyErrors, defaults, + shipping: shippingManager, monitordelay: PropTypes.number, errorDelay: PropTypes.number, discord: PropTypes.string, diff --git a/packages/frontend/src/utils/definitions/settings/settingsErrors.js b/packages/frontend/src/utils/definitions/settings/settingsErrors.js index 2e48dcff..84bb56a5 100644 --- a/packages/frontend/src/utils/definitions/settings/settingsErrors.js +++ b/packages/frontend/src/utils/definitions/settings/settingsErrors.js @@ -2,10 +2,12 @@ import PropTypes from 'prop-types'; import defaultsErrors from './defaultsErrors'; import proxyErrors from './proxyErrors'; +import shippingManagerErrors from './shippingManagerErrors'; const settingsErrors = PropTypes.shape({ proxies: proxyErrors, defaults: defaultsErrors, + shipping: shippingManagerErrors, discord: PropTypes.bool, slack: PropTypes.bool, }); diff --git a/packages/frontend/src/utils/definitions/settings/shippingManager.js b/packages/frontend/src/utils/definitions/settings/shippingManager.js new file mode 100644 index 00000000..55778bf8 --- /dev/null +++ b/packages/frontend/src/utils/definitions/settings/shippingManager.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; + +import pDefns from '../profileDefinitions'; +import shippingManagerErrors from './shippingManagerErrors'; + +const shippingManager = PropTypes.shape({ + name: PropTypes.string, + profile: pDefns.profile, + site: PropTypes.shape({ + name: PropTypes.string, + url: PropTypes.string, + supported: PropTypes.bool, + apiKey: PropTypes.string, + auth: PropTypes.bool, + }), + product: PropTypes.shape({ + raw: PropTypes.string, + variant: PropTypes.string, + pos_keywords: PropTypes.arrayOf(PropTypes.string), + neg_keywords: PropTypes.arrayOf(PropTypes.string), + url: PropTypes.string, + }), + username: PropTypes.string, + password: PropTypes.string, + status: PropTypes.string, + errors: shippingManagerErrors, +}); + +export default shippingManager; diff --git a/packages/frontend/src/utils/definitions/settings/shippingManagerErrors.js b/packages/frontend/src/utils/definitions/settings/shippingManagerErrors.js new file mode 100644 index 00000000..d0120b75 --- /dev/null +++ b/packages/frontend/src/utils/definitions/settings/shippingManagerErrors.js @@ -0,0 +1,12 @@ +import PropTypes from 'prop-types'; + +const shippingManagerErrors = PropTypes.shape({ + profile: PropTypes.bool, + name: PropTypes.bool, + site: PropTypes.bool, + product: PropTypes.bool, + username: PropTypes.bool, + password: PropTypes.bool, +}); + +export default shippingManagerErrors; diff --git a/packages/frontend/src/utils/definitions/settingsDefinitions.js b/packages/frontend/src/utils/definitions/settingsDefinitions.js index a78807ef..24ee44f5 100644 --- a/packages/frontend/src/utils/definitions/settingsDefinitions.js +++ b/packages/frontend/src/utils/definitions/settingsDefinitions.js @@ -1,5 +1,7 @@ import defaults from './settings/defaults'; import defaultsErrors from './settings/defaultsErrors'; +import shippingManager from './settings/shippingManager'; +import shippingManagerErrors from './settings/shippingManagerErrors'; import proxy from './settings/proxy'; import proxyErrors from './settings/proxyErrors'; import settings from './settings/settings'; @@ -8,6 +10,8 @@ import settingsErrors from './settings/settingsErrors'; export default { defaults, defaultsErrors, + shipping: shippingManager, + shippingErrors: shippingManagerErrors, settings, settingsErrors, proxy, diff --git a/packages/frontend/src/utils/parseProductType.js b/packages/frontend/src/utils/parseProductType.js new file mode 100644 index 00000000..184fe2d5 --- /dev/null +++ b/packages/frontend/src/utils/parseProductType.js @@ -0,0 +1,54 @@ +import regexes from './validation'; + +export default product => { + const kws = product.raw.split(',').reduce((a, x) => a.concat(x.trim().split(' ')), []); + + const validKeywords = kws.every(kw => regexes.keywordRegex.test(kw)); + + // TEMPORARY! - for testing with the mock server: + // const localhostUrlRegex = /https?:\/\/localhost:\d{2,5}/; + // if (localhostUrlRegex.test(product.raw)) { + // return { + // ...product, + // url: product.raw, + // }; + // } + // END TEMPORARY + + if (regexes.urlRegex.test(product.raw)) { + // test a url match + return { + ...product, + url: product.raw, + }; + } + + if (regexes.variantRegex.test(product.raw)) { + // test variant match + return { + ...product, + variant: product.raw, + }; + } + + if (validKeywords) { + // test keyword match + const posKeywords = []; + const negKeywords = []; + kws.forEach(kw => { + if (kw.startsWith('+')) { + // positive keywords + posKeywords.push(kw.slice(1, kw.length)); + } else { + // negative keywords + negKeywords.push(kw.slice(1, kw.length)); + } + }); + return { + ...product, + pos_keywords: posKeywords, + neg_keywords: negKeywords, + }; + } + return null; +}; diff --git a/packages/frontend/src/utils/validation.js b/packages/frontend/src/utils/validation.js index daa1964f..dec29968 100644 --- a/packages/frontend/src/utils/validation.js +++ b/packages/frontend/src/utils/validation.js @@ -13,6 +13,8 @@ const regexes = { settingsProxyUserPass: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?):(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9][0-9]|6[0-4][0-9][0-9][0-9]|[0-5]?[0-9][0-9]?[0-9]?[0-9]?):[^\s:]+:[^\s:]+$/, settingsProxySubnet: /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/, settingsProxySubnetUserPass: /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?:[^\s:]+:[^\s:]+$/, + discordWebhook: /https:\/\/discordapp.com\/api\/webhooks\/[0-9]+\/[a-zA-Z-0-9]*/, + slackWebhook: /https:\/\/hooks\.slack\.com\/services\/[a-zA-Z0-9]+\/[a-zA-Z0-9]+\/[a-zA-Z-0-9]*/, }; export default regexes; diff --git a/packages/frontend/src/utils/validation/settingsProxyAttributeValidators.js b/packages/frontend/src/utils/validation/proxyAttributeValidators.js similarity index 88% rename from packages/frontend/src/utils/validation/settingsProxyAttributeValidators.js rename to packages/frontend/src/utils/validation/proxyAttributeValidators.js index 0fb346cf..9e758783 100644 --- a/packages/frontend/src/utils/validation/settingsProxyAttributeValidators.js +++ b/packages/frontend/src/utils/validation/proxyAttributeValidators.js @@ -1,5 +1,4 @@ import { SETTINGS_FIELDS } from '../../state/actions'; - import regexes from '../validation'; function validateProxies(proxies) { @@ -16,8 +15,8 @@ function validateProxies(proxies) { return errorMap; } -const settingsAttributeValidatorMap = { +const proxyAttributeValidatorMap = { [SETTINGS_FIELDS.EDIT_PROXIES]: validateProxies, }; -export default settingsAttributeValidatorMap; +export default proxyAttributeValidatorMap; diff --git a/packages/frontend/src/utils/validation/settingsAttributeValidators.js b/packages/frontend/src/utils/validation/settingsAttributeValidators.js new file mode 100644 index 00000000..be86d03e --- /dev/null +++ b/packages/frontend/src/utils/validation/settingsAttributeValidators.js @@ -0,0 +1,17 @@ +import { SETTINGS_FIELDS } from '../../state/actions'; +import regexes from '../validation'; + +function validateDiscordWebhook(input) { + return input && regexes.discordWebhook.test(input); +} + +function validateSlackWebhook(input) { + return input && regexes.slackWebhook.test(input); +} + +const settingsAttributeValidatorMap = { + [SETTINGS_FIELDS.EDIT_DISCORD]: validateDiscordWebhook, + [SETTINGS_FIELDS.EDIT_SLACK]: validateSlackWebhook, +}; + +export default settingsAttributeValidatorMap; diff --git a/packages/frontend/src/utils/validation/shippingFormAttributeValidators.js b/packages/frontend/src/utils/validation/shippingFormAttributeValidators.js new file mode 100644 index 00000000..d904631d --- /dev/null +++ b/packages/frontend/src/utils/validation/shippingFormAttributeValidators.js @@ -0,0 +1,53 @@ +import _ from 'underscore'; +import { SETTINGS_FIELDS } from '../../state/actions'; +import regexes from '../validation'; +import getAllSupportedSitesSorted from '../../constants/getAllSites'; + +function validateProduct(product) { + if (!product) { + return false; + } + + let rawProduct = product; + if (typeof product === 'object') { + rawProduct = product.raw; + } + + if (regexes.urlRegex.test(rawProduct)) { + return true; + } + if (regexes.variantRegex.test(rawProduct)) { + return true; + } + + const kws = rawProduct.split(',').reduce((a, x) => a.concat(x.trim().split(' ')), []); + const testKeywords = kws.map(val => regexes.keywordRegex.test(val)); + const validKeywords = _.every(testKeywords, isValid => isValid === true); + if (validKeywords) { + return true; + } + return false; // default to not valid +} + +function validateProfile(profile) { + return profile && profile.id; +} +function validateSite(site) { + const sites = getAllSupportedSitesSorted(); + return site && site.name && sites.some(s => s.label === site.name); +} + +function validateInput(input) { + return input && input !== ''; +} + +const shippingFormAttributeValidatorMap = { + [SETTINGS_FIELDS.EDIT_SHIPPING_PRODUCT]: validateProduct, + [SETTINGS_FIELDS.EDIT_SHIPPING_RATE_NAME]: validateInput, + [SETTINGS_FIELDS.EDIT_SHIPPING_PROFILE]: validateProfile, + [SETTINGS_FIELDS.EDIT_SHIPPING_SITE]: validateSite, + [SETTINGS_FIELDS.EDIT_SHIPPING_USERNAME]: validateInput, + [SETTINGS_FIELDS.EDIT_SHIPPING_PASSWORD]: validateInput, +}; + +export default shippingFormAttributeValidatorMap; diff --git a/packages/task-runner/src/shopify/_test/testMonitor.js b/packages/task-runner/src/shopify/_test/testMonitor.js deleted file mode 100644 index 518e7461..00000000 --- a/packages/task-runner/src/shopify/_test/testMonitor.js +++ /dev/null @@ -1,157 +0,0 @@ -/* eslint-disable no-console */ -const Monitor = require('../classes/monitor'); -const { States } = require('../taskRunner'); - -const tasks = [ - { - id: 1, - site: { - url: 'https://blendsus.com', - name: 'blendsus', - }, - product: { - raw: '+clarks, +gtx, -vans', - pos_keywords: ['clarks', 'gtx'], - neg_keywords: ['vans'], - }, - sizes: ['9', '7.5', '10', '8'], - errorDelay: 2000, - monitorDelay: 2000, - }, - { - id: 2, - site: { - url: 'https://blendsus.com', - name: 'blendsus', - }, - product: { - raw: '9577878421551', - variant: '9577878421551', - }, - sizes: ['9', '7.5', '10', '8'], - errorDelay: 2000, - monitorDelay: 2000, - }, - { - id: 3, - site: { - url: 'https://blendsus.com', - name: 'blendsus', - }, - product: { - raw: 'https://www.blendsus.com/products/clarks-x-beams-wallabee-gtx-boot-navy', - url: 'https://www.blendsus.com/products/clarks-x-beams-wallabee-gtx-boot-navy', - }, - sizes: ['9', '7.5', '10', '8'], - errorDelay: 2000, - monitorDelay: 2000, - }, -]; - -const context = { - task: tasks[0], - proxy: undefined, // Eventually test with proxy... - aborted: false, - runner_id: 1, -}; - -async function delay(time) { - return new Promise(resolve => setTimeout(resolve, time)); -} - -const monitor = new Monitor(context); - -async function testMonitorAbort() { - // check to make sure aborted works properly... - context.aborted = true; - const state = await monitor.run(); - if (state !== States.Aborted) { - throw new Error("Aborting doesn't work!"); - } - context.aborted = false; - console.log('Abort works fine'); -} - -async function testMonitorKeyword() { - let state; - [context.task] = tasks; - - // Keep running until we get a checkout - while (state !== States.Checkout) { - // eslint-disable-next-line no-await-in-loop - state = await monitor.run(); - console.log(`Run loop finished with state: ${state}`); - } - - console.log(`Variants to Checkout:\n${JSON.stringify(context.task.product.variants, null, 2)}`); -} - -async function testMonitorVariant() { - let state; - // get the second element - [, context.task] = tasks; - - // Keep running until we get a checkout - while (state !== States.Checkout) { - // eslint-disable-next-line no-await-in-loop - state = await monitor.run(); - console.log(`Run loop finished with state: ${state}`); - } - - console.log(`Variants to Checkout:\n${JSON.stringify(context.task.product.variants, null, 2)}`); -} - -async function testMonitorUrl() { - let state; - // get the third element - [, , context.task] = tasks; - - // Keep running until we get a checkout - while (state !== States.Checkout) { - // eslint-disable-next-line no-await-in-loop - state = await monitor.run(); - console.log(`Run loop finished with state: ${state}`); - } - - console.log(`Variants to Checkout:\n${JSON.stringify(context.task.product.variants, null, 2)}`); -} - -async function testMonitor() { - // ABORT - console.log('==============================================='); - console.log('Testing Monitor Abort'); - await testMonitorAbort(); - - await delay(1000); - - // KEYWORD - console.log('==============================================='); - console.log('Testing Monitor Keyword'); - await testMonitorKeyword(); - - await delay(1000); - - // VARIANT - console.log('==============================================='); - console.log('Testing Monitor Variant'); - await testMonitorVariant(); - - await delay(1000); - - // URL - console.log('==============================================='); - console.log('Testing Monitor Url'); - await testMonitorUrl(); - - await delay(1000); - - // Summary - console.log('==============================================='); - console.log('Monitor Summary'); - ['Keyword', 'Variant', 'Url'].forEach((s, idx) => { - console.log(`${s} Monitor Variants Returns:`); - console.log(JSON.stringify(tasks[idx].product.variants, null, 2)); - }); -} - -testMonitor(); diff --git a/packages/task-runner/src/shopify/classes/checkout.js b/packages/task-runner/src/shopify/classes/checkout.js index fd08c454..a2640484 100644 --- a/packages/task-runner/src/shopify/classes/checkout.js +++ b/packages/task-runner/src/shopify/classes/checkout.js @@ -9,7 +9,7 @@ const { userAgent, waitForDelay, } = require('./utils'); -const { States } = require('./utils/constants').TaskRunner; +const { States, Types } = require('./utils/constants').TaskRunner; class Checkout { constructor(context) { @@ -18,10 +18,27 @@ class Checkout { this._request = this._context.request; this.shippingMethods = []; - this.chosenShippingMethod = { - name: null, - id: null, - }; + const preFetchedShippingRates = this._context.task.profile.rates.find( + r => r.site.url === this._context.task.site.url, + ); + + if ( + this._context.type === Types.Normal && + preFetchedShippingRates && + preFetchedShippingRates.selectedRate + ) { + const { name, rate } = preFetchedShippingRates.selectedRate; + this.chosenShippingMethod = { + name, + id: rate, + }; + } else { + this.chosenShippingMethod = { + name: null, + id: null, + }; + } + this.paymentToken = null; this.checkoutToken = null; @@ -32,7 +49,9 @@ class Checkout { total: 0, }; + this.selectedShippingRate = null; this.captchaToken = ''; + this.needsCaptcha = false; this.captchaTokenRequest = null; } @@ -130,7 +149,7 @@ class Checkout { // still at login page if (redirectUrl.indexOf('login') > -1) { this._logger.verbose('CHECKOUT: Invalid login credentials'); - return { message: 'Invalid login credentials', nextState: States.Stopped }; + return { message: 'Invalid login credentials', nextState: States.Errored }; } // since we're here, we can assume `account/login` === false @@ -144,7 +163,7 @@ class Checkout { } } - return { message: 'Failed: Logging in', nextState: States.Stopped }; + return { message: 'Failed: Logging in', nextState: States.Errored }; } catch (err) { this._logger.debug('ACCOUNT: Error logging in: %j', err); @@ -153,7 +172,7 @@ class Checkout { nextState: States.Login, }); - return nextState || { message: 'Failed: Logging in', nextState: States.Stopped }; + return nextState || { message: 'Failed: Logging in', nextState: States.Errored }; } } @@ -197,7 +216,7 @@ class Checkout { const [redirectUrl, qs] = headers.location.split('?'); this._logger.verbose('CHECKOUT: Create checkout redirect url: %s', redirectUrl); if (!redirectUrl) { - return { message: 'Failed: Creating checkout', nextState: States.Stopped }; + return { message: 'Failed: Creating checkout', nextState: States.Errored }; } // account (e.g. – https://www.hanon-shop.com/account/login?checkout_url=https%3A%2F%2Fwww.hanon-shop.com%2F20316995%2Fcheckouts%2Fb92b2aa215abfde741a8cf0e99eeee01) @@ -214,7 +233,7 @@ class Checkout { this._context.timers.monitor.start(); return { message: 'Logging in', nextState: States.Login }; } - return { message: 'Account required', nextState: States.Stopped }; + return { message: 'Account required', nextState: States.Errored }; } if (redirectUrl.indexOf('stock_problems') > -1) { @@ -243,7 +262,7 @@ class Checkout { } // not sure where we are, stop... - return { message: 'Failed: Creating checkout', nextState: States.Stopped }; + return { message: 'Failed: Creating checkout', nextState: States.Errored }; } catch (err) { this._logger.debug('CHECKOUT: Error creating checkout: %j', err); @@ -251,7 +270,7 @@ class Checkout { message: 'Creating checkout', nextState: States.CreateCheckout, }); - return nextState || { message: 'Failed: Creating checkout', nextState: States.Stopped }; + return nextState || { message: 'Failed: Creating checkout', nextState: States.Errored }; } } @@ -295,7 +314,7 @@ class Checkout { // check server error if (statusCode === 400) { - return { message: 'Failed: Invalid queue', nextState: States.Stopped }; + return { message: 'Failed: Invalid queue', nextState: States.Errored }; } // check server error @@ -340,7 +359,7 @@ class Checkout { message: 'Waiting in queue', nextState: States.PollQueue, }); - return nextState || { message: 'Failed: Polling queue', nextState: States.Stopped }; + return nextState || { message: 'Failed: Polling queue', nextState: States.Errored }; } } @@ -410,7 +429,7 @@ class Checkout { nextState: States.PingCheckout, }); return ( - nextState || { message: 'Failed: Refreshing checkout session', nextState: States.Stopped } + nextState || { message: 'Failed: Refreshing checkout session', nextState: States.Errored } ); } } @@ -489,7 +508,7 @@ class Checkout { const $ = cheerio.load(body, { xmlMode: true, normalizeWhitespace: true }); const recaptcha = $('.g-recaptcha'); this._logger.silly('CHECKOUT: Recaptcha frame present: %s', recaptcha.length > 0); - if (recaptcha.length > 0 || url.indexOf('socialstatus') > -1) { + if (recaptcha.length > 0 || url.indexOf('socialstatus') > -1 || this.needsCaptcha) { this._context.task.checkoutSpeed = checkoutTimer.getRunTime(); return { message: 'Waiting for captcha', nextState: States.RequestCaptcha }; } @@ -504,7 +523,7 @@ class Checkout { message: 'Posting payment', nextState: States.PostPayment, }); - return nextState || { message: 'Failed: Posting payment', nextState: States.Stopped }; + return nextState || { message: 'Failed: Posting payment', nextState: States.Errored }; } } @@ -578,7 +597,7 @@ class Checkout { if (this._context.task.username && this._context.task.password) { return { message: 'Logging in', nextState: States.Login }; } - return { message: 'Account required', nextState: States.Stopped }; + return { message: 'Account required', nextState: States.Errored }; } // password page @@ -597,7 +616,7 @@ class Checkout { message: 'Processing payment', nextState: States.CompletePayment, }); - return nextState || { message: 'Failed: Posting payment review', nextState: States.Stopped }; + return nextState || { message: 'Failed: Posting payment review', nextState: States.Errored }; } } @@ -612,8 +631,8 @@ class Checkout { const { profileName } = profile; const { url, apiKey, name } = site; - if (checkoutTimer.getRunTime() > 10000) { - return { message: 'Processing timed out, check email', nextState: States.Stopped }; + if (checkoutTimer.getRunTime() > 20000) { + return { message: 'Processing timed out, check email', nextState: States.Finished }; } this._logger.verbose('CHECKOUT: Processing payment'); @@ -683,7 +702,7 @@ class Checkout { } catch (err) { this._logger.debug('CHECKOUT: Request error sending webhook: %s', err); } - return { message: 'Payment successful', nextState: States.Stopped }; + return { message: 'Payment successful', nextState: States.Finished }; } const { payment_processing_error_message: paymentProcessingErrorMessage } = payments[0]; @@ -691,11 +710,11 @@ class Checkout { if (paymentProcessingErrorMessage !== null) { // out of stock during payment processing if (paymentProcessingErrorMessage.indexOf('Some items are no longer available') > -1) { - return { message: 'Payment failed (OOS)', nextState: States.Stopped }; + return { message: 'Payment failed (OOS)', nextState: States.Finished }; } // generic payment processing failure - return { message: 'Payment failed', nextState: States.Stopped }; + return { message: 'Payment failed', nextState: States.Errored }; } } this._logger.verbose('CHECKOUT: Processing payment'); @@ -708,7 +727,7 @@ class Checkout { message: 'Processing payment', nextState: States.PaymentProcess, }); - return nextState || { message: 'Failed: Processing payment', nextState: States.Stopped }; + return nextState || { message: 'Failed: Processing payment', nextState: States.Errored }; } } } diff --git a/packages/task-runner/src/shopify/classes/checkouts/api.js b/packages/task-runner/src/shopify/classes/checkouts/api.js index ac9e6e24..4e7ed64e 100644 --- a/packages/task-runner/src/shopify/classes/checkouts/api.js +++ b/packages/task-runner/src/shopify/classes/checkouts/api.js @@ -10,7 +10,7 @@ const { } = require('../utils'); const { patchCheckoutForm } = require('../utils/forms'); const { buildPaymentForm, patchToCart } = require('../utils/forms'); -const { States, CheckoutRefresh } = require('../utils/constants').TaskRunner; +const { Types, States, CheckoutRefresh } = require('../utils/constants').TaskRunner; const Checkout = require('../checkout'); /** @@ -66,7 +66,7 @@ class APICheckout extends Checkout { this.paymentToken = id; return { message: 'Creating checkout', nextState: States.CreateCheckout }; } - return { message: 'Failed: Creating payment token', nextState: States.Stopped }; + return { message: 'Failed: Creating payment token', nextState: States.Errored }; } catch (err) { this._logger.debug('API CHECKOUT: Request error creating payment token: %j', err); @@ -74,7 +74,7 @@ class APICheckout extends Checkout { message: 'Starting task setup', nextState: States.PaymentToken, }); - return nextState || { message: 'Failed: Creating payment token', nextState: States.Stopped }; + return nextState || { message: 'Failed: Creating payment token', nextState: States.Errored }; } } @@ -125,7 +125,7 @@ class APICheckout extends Checkout { if (statusCode === 200) { return { message: 'Monitoring for product', nextState: States.Monitor }; } - return { message: 'Failed: Patching checkout', nextState: States.Stopped }; + return { message: 'Failed: Patching checkout', nextState: States.Errored }; } // login needed @@ -133,7 +133,7 @@ class APICheckout extends Checkout { if (this._context.task.username && this._context.task.password) { return { message: 'Logging in', nextState: States.Login }; } - return { message: 'Account required', nextState: States.Stopped }; + return { message: 'Account required', nextState: States.Errored }; } // password page @@ -148,7 +148,7 @@ class APICheckout extends Checkout { } // not sure where we are, stop... - return { message: 'Failed: Submitting information', nextState: States.Stopped }; + return { message: 'Failed: Submitting information', nextState: States.Errored }; } catch (err) { this._logger.debug('API CHECKOUT: Request error creating checkout: %j', err); @@ -156,12 +156,12 @@ class APICheckout extends Checkout { message: 'Submitting information', nextState: States.PatchCheckout, }); - return nextState || { message: 'Failed: Submitting information', nextState: States.Stopped }; + return nextState || { message: 'Failed: Submitting information', nextState: States.Errored }; } } async addToCart() { - const { timers } = this._context; + const { timers, type } = this._context; const { site, product, monitorDelay } = this._context.task; const { url } = site; @@ -205,7 +205,7 @@ class APICheckout extends Checkout { if (this._context.task.username && this._context.task.password) { return { message: 'Logging in', nextState: States.Login }; } - return { message: 'Account required', nextState: States.Stopped }; + return { message: 'Account required', nextState: States.Errored }; } // password page @@ -232,13 +232,16 @@ class APICheckout extends Checkout { return { message: 'Running for restocks', nextState: States.Restocking }; } if (error.variant_id && error.variant_id[0]) { + if (type === Types.ShippingRates) { + return { message: 'Invalid variant', nextState: States.Errored }; + } if (timers.monitor.getRunTime() > CheckoutRefresh) { return { message: 'Pinging checkout', nextState: States.PingCheckout }; } await waitForDelay(monitorDelay); return { message: 'Monitoring for product', nextState: States.AddToCart }; } - return { message: 'Failed: Add to cart', nextState: States.Stopped }; + return { message: 'Failed: Add to cart', nextState: States.Errored }; } if (body.checkout && body.checkout.line_items.length > 0) { @@ -259,9 +262,13 @@ class APICheckout extends Checkout { this.prices.total = ( parseFloat(this.prices.item) + parseFloat(this.prices.shipping) ).toFixed(2); + if (this.chosenShippingMethod.id) { + this._logger.silly('API CHECKOUT: Shipping total: %s', this.prices.shipping); + return { message: `Posting payment`, nextState: States.PostPayment }; + } return { message: 'Fetching shipping rates', nextState: States.ShippingRates }; } - return { message: 'Failed: Add to cart', nextState: States.Stopped }; + return { message: 'Failed: Add to cart', nextState: States.Errored }; } catch (err) { this._logger.debug('API CHECKOUT: Request error adding to cart %j', err); @@ -269,7 +276,7 @@ class APICheckout extends Checkout { message: 'Adding to cart', nextState: States.AddToCart, }); - return nextState || { message: 'Failed: Add to cart', nextState: States.Stopped }; + return nextState || { message: 'Failed: Add to cart', nextState: States.Errored }; } } @@ -309,7 +316,7 @@ class APICheckout extends Checkout { // extra check for country not supported if (statusCode === 422) { - return { message: 'Country not supported', nextState: States.Stopped }; + return { message: 'Country not supported', nextState: States.Errored }; } if (body && body.errors) { @@ -325,7 +332,7 @@ class APICheckout extends Checkout { if (errorMessage.indexOf("can't be blank") > -1) { this._logger.verbose('API CHECKOUT: Country not supported'); - return { message: 'Country not supported', nextState: States.Stopped }; + return { message: 'Country not supported', nextState: States.Errored }; } } await waitForDelay(monitorDelay); @@ -339,6 +346,8 @@ class APICheckout extends Checkout { }); const cheapest = _.min(this.shippingMethods, rate => rate.price); + // Store cheapest shipping rate + this.selectedShippingRate = cheapest; const { id, title } = cheapest; this.chosenShippingMethod = { id, name: title }; this._logger.silly('API CHECKOUT: Using shipping method: %s', title); @@ -358,7 +367,7 @@ class APICheckout extends Checkout { message: 'Fetching shipping rates', nextState: States.ShippingRates, }); - return nextState || { message: 'Failed: Fetching shipping rates', nextState: States.Stopped }; + return nextState || { message: 'Failed: Fetching shipping rates', nextState: States.Errored }; } } } diff --git a/packages/task-runner/src/shopify/classes/checkouts/frontend.js b/packages/task-runner/src/shopify/classes/checkouts/frontend.js index d907073f..b6aaca86 100644 --- a/packages/task-runner/src/shopify/classes/checkouts/frontend.js +++ b/packages/task-runner/src/shopify/classes/checkouts/frontend.js @@ -61,7 +61,7 @@ class FrontendCheckout extends Checkout { this.paymentToken = id; return { message: 'Monitoring for product', nextState: States.Monitor }; } - return { message: 'Failed: Creating payment token', nextState: States.Stopped }; + return { message: 'Failed: Creating payment token', nextState: States.Errored }; } catch (err) { this._logger.debug('FRONTEND CHECKOUT: Request error creating payment token: %s', err); @@ -69,7 +69,7 @@ class FrontendCheckout extends Checkout { message: 'Starting task setup', nextState: States.PaymentToken, }); - return nextState || { message: 'Failed: Creating payment token', nextState: States.Stopped }; + return nextState || { message: 'Failed: Creating payment token', nextState: States.Errored }; } } @@ -127,7 +127,7 @@ class FrontendCheckout extends Checkout { if (this._context.task.username && this._context.task.password) { return { message: 'Logging in', nextState: States.Login }; } - return { message: 'Account required', nextState: States.Stopped }; + return { message: 'Account required', nextState: States.Errored }; } // password page @@ -163,7 +163,7 @@ class FrontendCheckout extends Checkout { message: 'Adding to cart', nextState: States.AddToCart, }); - return nextState || { message: 'Failed: Add to cart', nextState: States.Stopped }; + return nextState || { message: 'Failed: Add to cart', nextState: States.Errored }; } } @@ -178,7 +178,6 @@ class FrontendCheckout extends Checkout { } async getCheckout() { - const { timers } = this._context; const { site, monitorDelay } = this._context.task; const { url } = site; @@ -222,7 +221,7 @@ class FrontendCheckout extends Checkout { if (this._context.task.username && this._context.task.password) { return { message: 'Logging in', nextState: States.Login }; } - return { message: 'Account required', nextState: States.Stopped }; + return { message: 'Account required', nextState: States.Errored }; } // out of stock @@ -247,8 +246,7 @@ class FrontendCheckout extends Checkout { const recaptcha = $('.g-recaptcha'); this._logger.silly('CHECKOUT: Recaptcha frame present: %s', recaptcha.length > 0); if (recaptcha.length > 0) { - this._context.task.checkoutSpeed = timers.checkout.getRunTime(); - return { message: 'Waiting for captcha', nextState: States.RequestCaptcha }; + this.needsCaptcha = true; } return { message: 'Submitting information', nextState: States.PatchCheckout }; @@ -259,7 +257,7 @@ class FrontendCheckout extends Checkout { message: 'Fetching checkout', nextState: States.GetCheckout, }); - return nextState || { message: 'Failed: Fetching checkout', nextState: States.Stopped }; + return nextState || { message: 'Failed: Fetching checkout', nextState: States.Errored }; } } @@ -309,7 +307,7 @@ class FrontendCheckout extends Checkout { if (statusCode >= 200 && statusCode < 310) { return { message: 'Fetching shipping rates', nextState: States.ShippingRates }; } - return { message: 'Failed: Submitting information', nextState: States.Stopped }; + return { message: 'Failed: Submitting information', nextState: States.Errored }; } // check for redirects @@ -319,7 +317,7 @@ class FrontendCheckout extends Checkout { if (this._context.task.username && this._context.task.password) { return { message: 'Logging in', nextState: States.Login }; } - return { message: 'Account required', nextState: States.Stopped }; + return { message: 'Account required', nextState: States.Errored }; } // password page @@ -335,7 +333,7 @@ class FrontendCheckout extends Checkout { } // unknown redirect, stopping... - return { message: 'Failed: Submitting information', nextState: States.Stopped }; + return { message: 'Failed: Submitting information', nextState: States.Errored }; } catch (err) { this._logger.debug('FRONTEND CHECKOUT: Request error patching checkout: %j', err); @@ -343,7 +341,7 @@ class FrontendCheckout extends Checkout { message: 'Submitting information', nextState: States.PatchCheckout, }); - return nextState || { message: 'Failed: Submitting information', nextState: States.Stopped }; + return nextState || { message: 'Failed: Submitting information', nextState: States.Errored }; } } @@ -379,7 +377,7 @@ class FrontendCheckout extends Checkout { // extra check for carting if (statusCode === 422) { - return { message: 'Country not supported', nextState: States.Stopped }; + return { message: 'Country not supported', nextState: States.Errored }; } if (statusCode === 500 || statusCode === 503) { @@ -426,7 +424,7 @@ class FrontendCheckout extends Checkout { message: 'Fetching shipping rates', nextState: States.ShippingRates, }); - return nextState || { message: 'Failed: Fetching shipping rates', nextState: States.Stopped }; + return nextState || { message: 'Failed: Fetching shipping rates', nextState: States.Errored }; } } } diff --git a/packages/task-runner/src/shopify/classes/monitor.js b/packages/task-runner/src/shopify/classes/monitor.js index 925d3444..90372811 100644 --- a/packages/task-runner/src/shopify/classes/monitor.js +++ b/packages/task-runner/src/shopify/classes/monitor.js @@ -1,6 +1,6 @@ const { Parser, AtomParser, JsonParser, XmlParser, getSpecialParser } = require('./parsers'); const { formatProxy, userAgent, rfrl, capitalizeFirstLetter, waitForDelay } = require('./utils'); -const { States } = require('./utils/constants').TaskRunner; +const { Types, States } = require('./utils/constants').TaskRunner; const { ErrorCodes } = require('./utils/constants'); const { ParseType, getParseType } = require('./utils/parse'); const generateVariants = require('./utils/generateVariants'); @@ -127,6 +127,9 @@ class Monitor { } catch (errors) { this._logger.debug('MONITOR: All request errored out! %j', errors); // handle parsing errors + if (this._context.type === Types.ShippingRates) { + return { message: 'Product not found!', nextState: States.Errored }; + } return this._handleParsingErrors(errors); } this._logger.verbose('MONITOR: %s retrieved as a matched product', parsed.title); @@ -178,6 +181,9 @@ class Monitor { fullProductInfo = await Parser.getFullProductInfo(url, this._request, this._logger); } catch (errors) { this._logger.debug('MONITOR: All request errored out! %j', errors); + if (this._context.type === Types.ShippingRates) { + return { message: 'Product not found!', nextState: States.Errored }; + } // handle parsing errors return this._handleParsingErrors(errors); } diff --git a/packages/task-runner/src/shopify/classes/utils/constants.js b/packages/task-runner/src/shopify/classes/utils/constants.js index 8555f6bc..4dc38f93 100644 --- a/packages/task-runner/src/shopify/classes/utils/constants.js +++ b/packages/task-runner/src/shopify/classes/utils/constants.js @@ -45,6 +45,14 @@ const TaskRunnerStates = { Stopped: 'STOPPED', }; +// Runner Type will be used on frontend, so changing +// these values may break certain things on the +// Frontend! +const TaskRunnerTypes = { + Normal: 'normal', + ShippingRates: 'srr', +}; + const TaskRunnerDelayTypes = { error: 'errorDelay', monitor: 'monitorDelay', @@ -119,6 +127,7 @@ module.exports = { Events: TaskManagerEvents, }, TaskRunner: { + Types: TaskRunnerTypes, Events: TaskRunnerEvents, States: TaskRunnerStates, StateMap: PollQueueStateToNextState, diff --git a/packages/task-runner/src/shopify/index.js b/packages/task-runner/src/shopify/index.js index acf05f6c..1f940747 100644 --- a/packages/task-runner/src/shopify/index.js +++ b/packages/task-runner/src/shopify/index.js @@ -1,11 +1,17 @@ const TaskManager = require('./managers/taskManager'); const SplitThreadTaskManager = require('./managers/splitThreadTaskManager'); const SplitProcessTaskManager = require('./managers/splitProcessTaskManager'); -const TaskRunner = require('./taskRunner'); +const TaskRunner = require('./runners/taskRunner'); +const ShippingRatesRunner = require('./runners/shippingRatesRunner'); +const { + TaskRunner: { Types: TaskRunnerTypes }, +} = require('./classes/utils/constants'); module.exports = { TaskManager, SplitThreadTaskManager, SplitProcessTaskManager, TaskRunner, + ShippingRatesRunner, + TaskRunnerTypes, }; diff --git a/packages/task-runner/src/shopify/managers/splitContextTaskManager.js b/packages/task-runner/src/shopify/managers/splitContextTaskManager.js index 99464847..5722aa6e 100644 --- a/packages/task-runner/src/shopify/managers/splitContextTaskManager.js +++ b/packages/task-runner/src/shopify/managers/splitContextTaskManager.js @@ -166,7 +166,7 @@ class SplitContextTaskManager extends TaskManager { }); } - async _start([runnerId, task, openProxy]) { + async _start([runnerId, task, openProxy, type]) { this._logger.verbose('Spawning Child Context for runner: %s', runnerId); const childContext = new this._ContextCtor(runnerId, task, openProxy); this._runners[runnerId] = childContext; @@ -180,7 +180,7 @@ class SplitContextTaskManager extends TaskManager { childContext.send({ target: childContext.target, event: '__start', - args: [runnerId, task, openProxy, this._loggerPath], + args: [type, runnerId, task, openProxy, this._loggerPath], }); await new Promise((resolve, reject) => { // create handler reference so we can clean it up later diff --git a/packages/task-runner/src/shopify/managers/taskManager.js b/packages/task-runner/src/shopify/managers/taskManager.js index 9ec99571..5a36a8a3 100644 --- a/packages/task-runner/src/shopify/managers/taskManager.js +++ b/packages/task-runner/src/shopify/managers/taskManager.js @@ -5,9 +5,10 @@ const EventEmitter = require('eventemitter3'); const hash = require('object-hash'); const shortid = require('shortid'); -const TaskRunner = require('../taskRunner'); +const TaskRunner = require('../runners/taskRunner'); +const ShippingRatesRunner = require('../runners/shippingRatesRunner'); const { Events } = require('../classes/utils/constants').TaskManager; -const { HookTypes } = require('../classes/utils/constants').TaskRunner; +const { HookTypes, Types: RunnerTypes } = require('../classes/utils/constants').TaskRunner; const Discord = require('../classes/hooks/discord'); const Slack = require('../classes/hooks/slack'); const { createLogger } = require('../../common/logger'); @@ -463,8 +464,10 @@ class TaskManager { * * If the given task has already started, this method does nothing. * @param {Task} task + * @param {object} options Options to customize the runner: + * - type - The runner type to start */ - async start(task) { + async start(task, { type = RunnerTypes.Normal }) { this._logger.info('Starting task %s', task.id); const alreadyStarted = Object.values(this._runners).find(r => r.taskId === task.id); @@ -475,7 +478,7 @@ class TaskManager { const { runnerId, openProxy } = await this.setup(task.site); this._logger.info('Creating new runner %s for task %s', runnerId, task.id); - this._start([runnerId, task, openProxy]).then(() => { + this._start([runnerId, task, openProxy, type]).then(() => { this.cleanup(runnerId); }); } @@ -488,9 +491,11 @@ class TaskManager { * tasks in the given list. * * @param {List} tasks list of tasks to start + * @param {object} options Options to customize the runner: + * - type - The runner type to start */ - startAll(tasks) { - [...tasks].forEach(t => this.start(t)); + startAll(tasks, options) { + [...tasks].forEach(t => this.start(t, options)); } /** @@ -639,15 +644,17 @@ class TaskManager { ); } - async _start([runnerId, task, openProxy]) { - const runner = new TaskRunner( - runnerId, - task, - openProxy, - this._loggerPath, - this._discord, - this._slack, - ); + async _start([runnerId, task, openProxy, type]) { + let runner; + if (type === RunnerTypes.Normal) { + runner = new TaskRunner(runnerId, task, openProxy, this._loggerPath); + } else if (type === RunnerTypes.ShippingRates) { + runner = new ShippingRatesRunner(runnerId, task, openProxy, this._loggerPath); + } + // Return early if invalid type was passed in + if (!runner) { + return; + } runner.site = task.site.url; this._runners[runnerId] = runner; diff --git a/packages/task-runner/src/shopify/runnerScripts/base.js b/packages/task-runner/src/shopify/runnerScripts/base.js index 90b85bf1..10a77813 100644 --- a/packages/task-runner/src/shopify/runnerScripts/base.js +++ b/packages/task-runner/src/shopify/runnerScripts/base.js @@ -1,8 +1,10 @@ const constants = require('../classes/utils/constants'); -const TaskRunner = require('../taskRunner'); +const TaskRunner = require('../runners/taskRunner'); +const ShippingRatesRunner = require('../runners/shippingRatesRunner'); const TaskManagerEvents = constants.TaskManager.Events; const TaskRunnerEvents = constants.TaskRunner.Events; +const RunnerTypes = constants.TaskRunner.Types; /** * This class is the base for all split-context runners @@ -138,7 +140,17 @@ class TaskRunnerContextTransformer { * @param {array} args - arguments needed to create a TaskRunner instance */ async _start(args) { - const runner = new TaskRunner(...args); + const [type, ...params] = args; + let runner; + if (type === RunnerTypes.Normal) { + runner = new TaskRunner(...params); + } else if (type === RunnerTypes.ShippingRates) { + runner = new ShippingRatesRunner(...params); + } + if (!runner) { + // Return early if we couldn't create the runner; + return; + } this._wireEvents(runner); await runner.start(); runner._events.removeAllListeners(); diff --git a/packages/task-runner/src/shopify/runners/shippingRatesRunner.js b/packages/task-runner/src/shopify/runners/shippingRatesRunner.js new file mode 100644 index 00000000..976b7444 --- /dev/null +++ b/packages/task-runner/src/shopify/runners/shippingRatesRunner.js @@ -0,0 +1,45 @@ +const TaskRunner = require('./taskRunner'); +const { Types, States, CheckoutTypes } = require('../classes/utils/constants').TaskRunner; + +class ShippingRatesRunner extends TaskRunner { + constructor(id, task, proxy, loggerPath) { + super(id, task, proxy, loggerPath, Types.ShippingRates); + + if (this._checkoutType === CheckoutTypes.fe) { + this._logger.error( + 'Running for Shipping Rates is not Supported with FE Sites! Throwing error now...', + ); + throw new Error('Running for Shipping Rates is not Supported with FE Sites!'); + } + } + + async runSingleLoop() { + const superShouldStop = await super.runSingleLoop(); + + // Stop if we reach the PostPayment State + if (this._state === States.PostPayment) { + this._emitTaskEvent({ + message: 'Shipping Rates Found', + done: true, + rates: this._checkout.shippingMethods, + selected: this._checkout.selectedShippingRate, + }); + return true; + } + + // Stop if we error out for another reason + if (this._state === States.Restocking || superShouldStop) { + // Stopped before + this._emitTaskEvent({ + message: 'Unable to get shipping rates!', + done: true, + }); + return true; + } + + // Continue on... + return false; + } +} + +module.exports = ShippingRatesRunner; diff --git a/packages/task-runner/src/shopify/taskRunner.js b/packages/task-runner/src/shopify/runners/taskRunner.js similarity index 90% rename from packages/task-runner/src/shopify/taskRunner.js rename to packages/task-runner/src/shopify/runners/taskRunner.js index 9611e876..96ed7739 100644 --- a/packages/task-runner/src/shopify/taskRunner.js +++ b/packages/task-runner/src/shopify/runners/taskRunner.js @@ -1,27 +1,37 @@ const EventEmitter = require('eventemitter3'); const request = require('request-promise'); -const Timer = require('./classes/timer'); -const Monitor = require('./classes/monitor'); -const RestockMonitor = require('./classes/restockMonitor'); -const Discord = require('./classes/hooks/discord'); -const Slack = require('./classes/hooks/slack'); -const AsyncQueue = require('../common/asyncQueue'); +const Timer = require('../classes/timer'); +const Monitor = require('../classes/monitor'); +const RestockMonitor = require('../classes/restockMonitor'); +const Discord = require('../classes/hooks/discord'); +const Slack = require('../classes/hooks/slack'); +const AsyncQueue = require('../../common/asyncQueue'); const { ErrorCodes, - TaskRunner: { States, Events, DelayTypes, HookTypes, StateMap, CheckoutRefresh, HarvestStates }, -} = require('./classes/utils/constants'); -const TaskManagerEvents = require('./classes/utils/constants').TaskManager.Events; -const { createLogger } = require('../common/logger'); -const { waitForDelay } = require('./classes/utils'); -const { getCheckoutMethod } = require('./classes/checkouts'); + TaskRunner: { + States, + Events, + Types, + DelayTypes, + HookTypes, + StateMap, + CheckoutRefresh, + HarvestStates, + }, +} = require('../classes/utils/constants'); +const TaskManagerEvents = require('../classes/utils/constants').TaskManager.Events; +const { createLogger } = require('../../common/logger'); +const { waitForDelay } = require('../classes/utils'); +const { getCheckoutMethod } = require('../classes/checkouts'); class TaskRunner { get state() { return this._state; } - constructor(id, task, proxy, loggerPath) { + constructor(id, task, proxy, loggerPath, type = Types.Normal) { + this._type = type; // Add Ids to object this.taskId = task.id; this.id = id; @@ -68,6 +78,7 @@ class TaskRunner { */ this._context = { id, + type: this._type, task, proxy: proxy ? proxy.proxy : null, request: this._request, @@ -258,7 +269,10 @@ class TaskRunner { } _emitTaskEvent(payload) { - this._emitEvent(Events.TaskStatus, payload); + if (payload.message) { + this._context.status = payload.message; + } + this._emitEvent(Events.TaskStatus, { ...payload, type: this._type }); } // MARK: State Machine Step Logic @@ -552,14 +566,18 @@ class TaskRunner { this._checkout.captchaTokenRequest.status, ); // TODO: should we emit a status update here? - return States.Stopped; + // clear out the status so we get a generic "errored out task event" + this._context.status = null; + return States.Errored; } default: { this._logger.verbose( 'Unknown Harvest Captcha status! %s, stopping...', this._checkout.captchaTokenRequest.status, ); - return States.Stopped; + // clear out the status so we get a generic "errored out task event" + this._context.status = null; + return States.Errored; } } } @@ -666,6 +684,7 @@ class TaskRunner { return () => { this._emitTaskEvent({ message: this._context.status || `Task has ${status}`, + done: true, }); return States.Stopped; }; @@ -706,27 +725,34 @@ class TaskRunner { // MARK: State Machine Run Loop + async runSingleLoop() { + let nextState = this._state; + if (this._context.aborted) { + nextState = States.Aborted; + } + try { + nextState = await this._handleStepLogic(this._state); + } catch (e) { + this._logger.debug('Run loop errored out! %s', e); + nextState = States.Errored; + } + this._logger.verbose('Run Loop finished, state transitioned to: %s', nextState); + + if (this._state !== nextState) { + this._prevState = this._state; + this._state = nextState; + } + + return false; + } + async start() { this._prevState = States.Started; this._state = States.Started; - while (this._state !== States.Stopped) { - let nextState = this._state; - if (this._context.aborted) { - nextState = States.Aborted; - } - try { - // eslint-disable-next-line no-await-in-loop - nextState = await this._handleStepLogic(this._state); - } catch (e) { - this._logger.debug('Run loop errored out! %s', e); - nextState = States.Errored; - } - this._logger.verbose('Run Loop finished, state transitioned to: %s', nextState); - - if (this._state !== nextState) { - this._prevState = this._state; - this._state = nextState; - } + let shouldStop = false; + while (this._state !== States.Stopped && !shouldStop) { + // eslint-disable-next-line no-await-in-loop + shouldStop = await this.runSingleLoop(); } this._cleanup();