Move docker-store docs to docker-store subdirectory
17
Dockerfile
|
@ -1,17 +0,0 @@
|
|||
FROM node:6.0.0-slim
|
||||
|
||||
RUN mkdir -p /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY package.json /usr/src/app/
|
||||
COPY npm-shrinkwrap.json /usr/src/app/
|
||||
RUN npm install
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
COPY . /usr/src/app
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
|
@ -1,8 +0,0 @@
|
|||
// Only run on Linux atm
|
||||
wrappedNode(label: 'docker') {
|
||||
deleteDir()
|
||||
stage "checkout"
|
||||
checkout scm
|
||||
|
||||
documentationChecker("docs")
|
||||
}
|
100
README.md
|
@ -1,100 +0,0 @@
|
|||
# Mercury
|
||||
[](https://circleci.com/gh/docker/mercury-ui) [](https://codecov.io/github/docker/mercury-ui?branch=master)
|
||||
|
||||
|
||||
### Develop
|
||||
|
||||
- Download [Node](https://nodejs.org/en/) or install it via homebrew `brew install node`
|
||||
- Install [React dev tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) and [Redux dev tools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd)
|
||||
|
||||
In order to test user actions you will need to provide JWT authentication.
|
||||
Add your JWT to your environment variables keyed by `DOCKERSTORE_TOKEN`
|
||||
If you have CSRF token, key it in your environment variables by `DOCKERSTORE_CSRF`
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run start
|
||||
```
|
||||
|
||||
### Dockerize
|
||||
|
||||
- Download [Docker for Mac or Windows](https://beta.docker.com/docs/)
|
||||
|
||||
```
|
||||
docker build -t docker/mercury-ui .
|
||||
docker run -p 3000:3000 docker/mercury-ui
|
||||
```
|
||||
|
||||
### Deploy to staging & production
|
||||
|
||||
#### Setup
|
||||
|
||||
1. Ensure access to Staging and Production VPNs
|
||||
2. Docker Infra adds your developer certificates to the store & hub aws teams.
|
||||
3. Install pip: [instructions here](https://pip.pypa.io/en/stable/installing/)
|
||||
3. Install hub-boss (globally for now):
|
||||
```
|
||||
git clone https://github.com/docker/saas-mega
|
||||
cd saas-mega/tools/hub-boss
|
||||
sudo pip install -r requirements.txt
|
||||
sudo pip install -e .
|
||||
```
|
||||
|
||||
4. Follow the [Pass Runbook](https://docker.atlassian.net/wiki/display/DI/Pass+Runbook) to install `pass`, `gpg-agent` and `pinentry`.
|
||||
|
||||
5. Set up Store TLS certificates to access the staging & production Docker Daemons (see [docs](https://docs.docker.com/engine/security/https/))
|
||||
```
|
||||
mkdir -p ~/.docker/certs
|
||||
cd ~/.docker/certs
|
||||
|
||||
# Get ca cert & key from pass
|
||||
pass show dev/teams/store/docker/store-ca-cert.pem > ca.pem
|
||||
pass show dev/teams/store/docker/store-ca-key.pem > ca-key.pem
|
||||
|
||||
# Generate a client private key
|
||||
openssl genrsa -out key.pem 4096
|
||||
|
||||
# Generate client CSR
|
||||
openssl req -subj '/CN=client' -new -key key.pem -out client.csr
|
||||
|
||||
# Sign the public key
|
||||
echo extendedKeyUsage = clientAuth > extfile.cnf
|
||||
openssl x509 -req -days 365 -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out cert.pem -extfile extfile.cnf
|
||||
|
||||
# Fix permissions
|
||||
chmod -v 0400 key.pem
|
||||
chmod -v 0444 ca.pem cert.pem
|
||||
|
||||
# cleanup
|
||||
rm ca-key.pem
|
||||
rm extfile.cnf
|
||||
rm client.csr
|
||||
rm ca.srl
|
||||
```
|
||||
|
||||
|
||||
#### Deploying to Staging
|
||||
|
||||
1. Bump the app version:
|
||||
```
|
||||
git checkout master
|
||||
git pull
|
||||
npm version major # commits change with new major version in package.json, creates new git tag e.g. <v6.0.0>
|
||||
git push origin master # push bump version commit
|
||||
git push origin --tags # pushes newly created git tag
|
||||
```
|
||||
|
||||
2. Wait for the new version to [build on DockerHub](https://hub.docker.com/r/docker/mercury-ui/builds/) (e.g. `6.0.0`)
|
||||
3. Connect to the Staging VPN
|
||||
4. Deploy: `./tools/scripts/deploy_stage.sh <old version> <new version> # e.g: 5.0.0 6.0.0`
|
||||
|
||||
#### Deploying to Production
|
||||
|
||||
1. Connect to Production VPN
|
||||
2. Alert the team that you're doing a production deploy
|
||||
3. Deploy to production: `./tools/scripts/deploy_prod.sh <old version> <new version>`
|
||||
|
||||
#### Rolling-Back
|
||||
|
||||
1. Notify team you're doing a UI roll-back:
|
||||
2. `./tools/scripts/deploy_prod.sh <current version> <previous stable version>`
|
15
circle.yml
|
@ -1,15 +0,0 @@
|
|||
machine:
|
||||
services:
|
||||
- docker
|
||||
node:
|
||||
version: 6.0.0
|
||||
|
||||
dependencies:
|
||||
cache_directories:
|
||||
- node_modules
|
||||
|
||||
test:
|
||||
override:
|
||||
- npm run test:coverage
|
||||
post:
|
||||
- npm run codecov
|
|
@ -1,49 +0,0 @@
|
|||
import 'babel-polyfill';
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { applyRouterMiddleware, Router, browserHistory } from 'react-router';
|
||||
import useScroll from 'react-router-scroll';
|
||||
import configureRoutes from 'routes';
|
||||
import configureStore from 'store';
|
||||
import getMuiTheme from 'material-ui/styles/getMuiTheme';
|
||||
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
|
||||
import { internalRouterReady } from 'actions/internal';
|
||||
|
||||
// Add global css styles
|
||||
import 'lib/css/global.css';
|
||||
|
||||
const initialState = window.__INITIAL_STATE__;
|
||||
const store = configureStore(initialState);
|
||||
const routes = configureRoutes(store);
|
||||
|
||||
|
||||
// Some components use react-tap-event-plugin to listen for touch events.
|
||||
// This dependency is temporary and will go away once react v1.0 is released.
|
||||
// Until then, be sure to inject this plugin at the start of your app.
|
||||
// http://material-ui.com/#/get-started/installation
|
||||
require('react-tap-event-plugin')();
|
||||
|
||||
browserHistory.listen(() => {
|
||||
analytics.page();
|
||||
});
|
||||
|
||||
const muiTheme = getMuiTheme();
|
||||
|
||||
// useScroll ensures that the page scrolls to the top when you load a new route
|
||||
// scroll behavior can be extended with a custom shouldUpdateScroll function
|
||||
// see https://github.com/taion/react-router-scroll for more info
|
||||
|
||||
store.dispatch(internalRouterReady());
|
||||
render((
|
||||
<Provider store={store}>
|
||||
<MuiThemeProvider muiTheme={muiTheme}>
|
||||
<Router
|
||||
history={browserHistory}
|
||||
routes={routes}
|
||||
render={applyRouterMiddleware(useScroll())}
|
||||
/>
|
||||
</MuiThemeProvider>
|
||||
</Provider>
|
||||
), document.getElementById('app'));
|
|
@ -1,7 +0,0 @@
|
|||
web:
|
||||
publish_all_ports: True
|
||||
restart_policy:
|
||||
MaximumRetryCount: 0
|
||||
Name: always
|
||||
ports:
|
||||
- 3000
|
|
@ -1,7 +0,0 @@
|
|||
web:
|
||||
publish_all_ports: True
|
||||
restart_policy:
|
||||
MaximumRetryCount: 0
|
||||
Name: always
|
||||
ports:
|
||||
- 3000
|
|
@ -1,4 +0,0 @@
|
|||
DEBUG: 'False'
|
||||
SERVICE_NAME: 'mercury-ui'
|
||||
RELEASE_STAGE: 'production'
|
||||
NODE_ENV: 'production'
|
|
@ -1,4 +0,0 @@
|
|||
DEBUG: 'True'
|
||||
SERVICE_NAME: 'mercury-ui'
|
||||
RELEASE_STAGE: 'staging'
|
||||
NODE_ENV: 'production'
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
16
mocha.js
|
@ -1,16 +0,0 @@
|
|||
require('./server/env');
|
||||
|
||||
var jsdom = require('jsdom').jsdom;
|
||||
var exposedProperties = ['window', 'navigator', 'document'];
|
||||
global.document = jsdom('');
|
||||
global.window = document.defaultView;
|
||||
Object.keys(document.defaultView).forEach((property) => {
|
||||
if (typeof global[property] === 'undefined') {
|
||||
exposedProperties.push(property);
|
||||
global[property] = document.defaultView[property];
|
||||
}
|
||||
});
|
||||
global.navigator = {
|
||||
userAgent: 'node.js'
|
||||
};
|
||||
documentRef = document;
|
143
package.json
|
@ -1,143 +0,0 @@
|
|||
{
|
||||
"name": "mercury",
|
||||
"version": "82.0.0",
|
||||
"description": "Mercury",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"author": "Docker Inc.",
|
||||
"license": "UNLICENSED",
|
||||
"engines": {
|
||||
"node": "~6.0.0",
|
||||
"npm": "~3.8.7"
|
||||
},
|
||||
"config": {
|
||||
"lintDirs": "client common server tools"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production webpack -p --progress --colors",
|
||||
"codecov": "codecov -t $CODECOV_REPOSITORY_TOKEN",
|
||||
"lint:css": "stylelint \"**/*.css\"",
|
||||
"lint:html": "htmllint server/**/*.html client/**/*.html src/**/*.html",
|
||||
"lint:js:fix": "eslint eslint src/components/common/AutocompleteSearchBar/index.js --fix",
|
||||
"lint:js": "eslint $npm_package_config_lintDirs --fix",
|
||||
"lint": "npm-run-all lint:*",
|
||||
"postmerge": "npm run runner -- hooks/postmerge",
|
||||
"precommit": "npm run runner -- hooks/precommit $npm_package_config_lintDirs",
|
||||
"runner": "node tools/runner",
|
||||
"shrinkwrap": "npm run runner -- shrinkwrap",
|
||||
"start": "node server/index.js",
|
||||
"test:coverage": "istanbul cover --report text --report lcov _mocha mocha.js src/**/*test.js -x mocha.js -x src/**/*test.js",
|
||||
"test": "mocha mocha.js src/**/*test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"app-module-path": "1.0.6",
|
||||
"aws-sdk": "2.3.18",
|
||||
"babel-core": "6.7.7",
|
||||
"babel-eslint": "6.0.4",
|
||||
"babel-loader": "6.2.4",
|
||||
"babel-plugin-add-module-exports": "0.1.4",
|
||||
"babel-plugin-transform-decorators-legacy": "1.3.4",
|
||||
"babel-polyfill": "6.7.4",
|
||||
"babel-preset-es2015": "6.6.0",
|
||||
"babel-preset-react": "6.5.0",
|
||||
"babel-preset-react-hmre": "1.1.1",
|
||||
"babel-preset-stage-0": "6.5.0",
|
||||
"babel-register": "6.7.2",
|
||||
"babel-runtime": "6.6.1",
|
||||
"basic-auth": "1.0.4",
|
||||
"chai": "3.5.0",
|
||||
"chai-enzyme": "0.4.2",
|
||||
"classnames": "2.2.4",
|
||||
"codecov": "1.0.1",
|
||||
"compression": "1.6.1",
|
||||
"country-list": "0.0.3",
|
||||
"css-loader": "0.23.1",
|
||||
"css-modules-require-hook": "4.0.0",
|
||||
"dom-scroll-into-view": "1.2.0",
|
||||
"enzyme": "2.2.0",
|
||||
"es6-promise": "3.2.1",
|
||||
"eslint": "2.8.0",
|
||||
"eslint-config-airbnb": "7.0.0",
|
||||
"eslint-plugin-jsx-a11y": "0.6.2",
|
||||
"eslint-plugin-react": "4.3.0",
|
||||
"eventemitter3": "1.2.0",
|
||||
"express": "4.13.4",
|
||||
"extract-text-webpack-plugin": "1.0.1",
|
||||
"fbjs": "0.8.1",
|
||||
"file-loader": "0.8.5",
|
||||
"github-markdown-css": "2.2.1",
|
||||
"htmllint-cli": "0.0.4",
|
||||
"istanbul": "1.0.0-alpha.2",
|
||||
"jsdom": "8.4.1",
|
||||
"json-loader": "0.5.4",
|
||||
"lodash": "4.11.2",
|
||||
"lost": "6.7.2",
|
||||
"marked": "0.3.5",
|
||||
"material-ui": "0.15.0",
|
||||
"md5": "2.1.0",
|
||||
"mocha": "2.4.5",
|
||||
"mocha-lcov-reporter": "1.2.0",
|
||||
"moment": "2.13.0",
|
||||
"normalize.css": "4.1.1",
|
||||
"normalizr": "2.0.1",
|
||||
"npm": "3.8.7",
|
||||
"npm-run-all": "1.8.0",
|
||||
"nprogress": "0.2.0",
|
||||
"numeral": "1.5.3",
|
||||
"postcss-calc": "5.2.1",
|
||||
"postcss-color-function": "2.0.1",
|
||||
"postcss-cssnext": "2.5.2",
|
||||
"postcss-custom-media": "5.0.1",
|
||||
"postcss-each": "0.9.1",
|
||||
"postcss-import": "8.1.0",
|
||||
"postcss-loader": "0.8.2",
|
||||
"postcss-media-minmax": "2.1.2",
|
||||
"postcss-mixins": "4.0.1",
|
||||
"postcss-nested": "1.0.0",
|
||||
"postcss-simple-vars": "1.2.0",
|
||||
"promisify-node": "0.4.0",
|
||||
"proxy-middleware": "0.15.0",
|
||||
"qs": "6.1.0",
|
||||
"raw-loader": "0.5.1",
|
||||
"rc-slider": "3.7.0",
|
||||
"react": "15.1.0",
|
||||
"react-addons-css-transition-group": "15.0.2",
|
||||
"react-addons-test-utils": "15.0.2",
|
||||
"react-cookie": "0.4.5",
|
||||
"react-dom": "15.0.2",
|
||||
"react-dropzone": "3.5.0",
|
||||
"react-modal": "1.2.1",
|
||||
"react-notification": "5.0.3",
|
||||
"react-redux": "4.4.5",
|
||||
"react-router": "2.4.0",
|
||||
"react-router-scroll": "0.2.0",
|
||||
"react-select": "1.0.0-beta12",
|
||||
"react-tap-event-plugin": "1.0.0",
|
||||
"redux": "3.5.2",
|
||||
"redux-actions": "0.9.1",
|
||||
"redux-devtools": "3.2.0",
|
||||
"redux-form": "5.2.2",
|
||||
"redux-logger": "2.6.1",
|
||||
"redux-promise-middleware": "3.0.0",
|
||||
"redux-thunk": "2.0.1",
|
||||
"reselect": "2.5.1",
|
||||
"sinon": "1.17.3",
|
||||
"style-loader": "0.13.1",
|
||||
"stylelint": "6.2.2",
|
||||
"stylelint-config-standard": "6.0.0",
|
||||
"superagent": "1.8.3",
|
||||
"superagent-promise": "1.1.0",
|
||||
"url-loader": "0.5.7",
|
||||
"url-pattern": "1.0.1",
|
||||
"uuid": "2.0.2",
|
||||
"uuid-v4": "0.1.0",
|
||||
"velocity-react": "1.1.5",
|
||||
"webpack": "1.13.0",
|
||||
"webpack-dev-middleware": "1.6.1",
|
||||
"webpack-hot-middleware": "2.10.0"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"optionalDependencies": {
|
||||
"husky": "0.11.3"
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
var path = require('path');
|
||||
var fs = require('fs');
|
||||
|
||||
require('css-modules-require-hook')({
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]',
|
||||
});
|
||||
|
||||
require('app-module-path').addPath(path.join(__dirname, '..', 'src'));
|
||||
require('app-module-path')
|
||||
.addPath(path.join(__dirname, '..', 'src', 'components'));
|
||||
require('app-module-path')
|
||||
.addPath(path.join(__dirname, '..', 'src', 'lib', 'css'));
|
||||
require('babel-register', {
|
||||
plugins: ['add-module-exports'],
|
||||
});
|
||||
|
||||
// Webpack loaders aren't used Server-side, so we need to handle .md files
|
||||
require.extensions['.md'] = function (module, filename) {
|
||||
// eslint-disable-next-line
|
||||
module.exports = fs.readFileSync(filename, 'utf8');
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
require('./env');
|
||||
require('./server');
|
173
server/proxy.js
|
@ -1,173 +0,0 @@
|
|||
/*
|
||||
Based on code from: https://github.com/chimurai/http-proxy-middleware
|
||||
Includes Fixes:
|
||||
- Fixed bug where domains with `-` would not work when rewriting cookie headers
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Steven Chim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
var os = require('os');
|
||||
var http = require('http');
|
||||
var https = require('https');
|
||||
var owns = {}.hasOwnProperty;
|
||||
|
||||
module.exports = function proxyMiddleware(options) {
|
||||
//enable ability to quickly pass a url for shorthand setup
|
||||
if(typeof options === 'string'){
|
||||
options = require('url').parse(options);
|
||||
}
|
||||
|
||||
var httpLib = options.protocol === 'https:' ? https : http;
|
||||
var request = httpLib.request;
|
||||
|
||||
options = options || {};
|
||||
options.hostname = options.hostname;
|
||||
options.port = options.port;
|
||||
options.pathname = options.pathname || '/';
|
||||
|
||||
return function (req, resp, next) {
|
||||
var url = req.url;
|
||||
// You can pass the route within the options, as well
|
||||
if (typeof options.route === 'string') {
|
||||
if (url === options.route) {
|
||||
url = '';
|
||||
} else if (url.slice(0, options.route.length) === options.route) {
|
||||
url = url.slice(options.route.length);
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
//options for this request
|
||||
var opts = extend({}, options);
|
||||
if (url && url.charAt(0) === '?') { // prevent /api/resource/?offset=0
|
||||
if (options.pathname.length > 1 && options.pathname.charAt(options.pathname.length - 1) === '/') {
|
||||
opts.path = options.pathname.substring(0, options.pathname.length - 1) + url;
|
||||
} else {
|
||||
opts.path = options.pathname + url;
|
||||
}
|
||||
} else if (url) {
|
||||
opts.path = slashJoin(options.pathname, url);
|
||||
} else {
|
||||
opts.path = options.pathname;
|
||||
}
|
||||
opts.method = req.method;
|
||||
opts.headers = options.headers ? merge(req.headers, options.headers) : req.headers;
|
||||
|
||||
applyViaHeader(req.headers, opts, opts.headers);
|
||||
|
||||
if (!options.preserveHost) {
|
||||
// Forwarding the host breaks dotcloud
|
||||
delete opts.headers.host;
|
||||
}
|
||||
|
||||
var myReq = request(opts, function (myRes) {
|
||||
var statusCode = myRes.statusCode
|
||||
, headers = myRes.headers
|
||||
, location = headers.location;
|
||||
// Fix the location
|
||||
if (((statusCode > 300 && statusCode < 304) || statusCode === 201) && location && location.indexOf(options.href) > -1) {
|
||||
// absoulte path
|
||||
headers.location = location.replace(options.href, slashJoin('/', slashJoin((options.route || ''), '')));
|
||||
}
|
||||
applyViaHeader(myRes.headers, opts, myRes.headers);
|
||||
rewriteCookieHosts(myRes.headers, opts, myRes.headers, req);
|
||||
resp.writeHead(myRes.statusCode, myRes.headers);
|
||||
myRes.on('error', function (err) {
|
||||
next(err);
|
||||
});
|
||||
myRes.pipe(resp);
|
||||
});
|
||||
myReq.on('error', function (err) {
|
||||
next(err);
|
||||
});
|
||||
if (!req.readable) {
|
||||
myReq.end();
|
||||
} else {
|
||||
req.pipe(myReq);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function applyViaHeader(existingHeaders, opts, applyTo) {
|
||||
if (!opts.via) return;
|
||||
|
||||
var viaName = (true === opts.via) ? os.hostname() : opts.via;
|
||||
var viaHeader = '1.1 ' + viaName;
|
||||
if(existingHeaders.via) {
|
||||
viaHeader = existingHeaders.via + ', ' + viaHeader;
|
||||
}
|
||||
|
||||
applyTo.via = viaHeader;
|
||||
}
|
||||
|
||||
function rewriteCookieHosts(existingHeaders, opts, applyTo, req) {
|
||||
if (!opts.cookieRewrite || !owns.call(existingHeaders, 'set-cookie')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingCookies = existingHeaders['set-cookie'],
|
||||
rewrittenCookies = [],
|
||||
rewriteHostname = (true === opts.cookieRewrite) ? os.hostname() : opts.cookieRewrite;
|
||||
|
||||
if (!Array.isArray(existingCookies)) {
|
||||
existingCookies = [ existingCookies ];
|
||||
}
|
||||
|
||||
for (var i = 0; i < existingCookies.length; i++) {
|
||||
|
||||
// Fixed bug where domains with `-` would not work when rewriting cookie headers
|
||||
var rewrittenCookie = existingCookies[i].replace(/(Domain)=[a-z\.-_-]*?(;|$)/gi, '$1=' + rewriteHostname + '$2');
|
||||
|
||||
if (!req.connection.encrypted) {
|
||||
rewrittenCookie = rewrittenCookie.replace(/;\s*?(Secure)/i, '');
|
||||
}
|
||||
rewrittenCookies.push(rewrittenCookie);
|
||||
}
|
||||
|
||||
applyTo['set-cookie'] = rewrittenCookies;
|
||||
}
|
||||
|
||||
function slashJoin(p1, p2) {
|
||||
var trailing_slash = false;
|
||||
|
||||
if (p1.length && p1[p1.length - 1] === '/') { trailing_slash = true; }
|
||||
if (trailing_slash && p2.length && p2[0] === '/') {p2 = p2.substring(1); }
|
||||
|
||||
return p1 + p2;
|
||||
}
|
||||
|
||||
function extend(obj, src) {
|
||||
for (var key in src) if (owns.call(src, key)) obj[key] = src[key];
|
||||
return obj;
|
||||
}
|
||||
|
||||
//merges data without changing state in either argument
|
||||
function merge(src1, src2) {
|
||||
var merged = {};
|
||||
extend(merged, src1);
|
||||
extend(merged, src2);
|
||||
return merged;
|
||||
}
|
|
@ -1,94 +0,0 @@
|
|||
import webpack from 'webpack';
|
||||
import webpackDevMiddleware from 'webpack-dev-middleware';
|
||||
import webpackHotMiddleware from 'webpack-hot-middleware';
|
||||
import webpackConfig from '../webpack.config';
|
||||
import path from 'path';
|
||||
import Express from 'express';
|
||||
import url from 'url';
|
||||
import compression from 'compression';
|
||||
import proxy from './proxy';
|
||||
|
||||
const server = new Express();
|
||||
const port = 3000;
|
||||
|
||||
server.disable('x-powered-by');
|
||||
server.use(compression());
|
||||
|
||||
server.use('/_health', (req, res) => {
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
// In development: enable hot reloading
|
||||
// In production: serve pre-built static assets
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const compiler = webpack(webpackConfig);
|
||||
server.use(webpackDevMiddleware(compiler, {
|
||||
noInfo: true,
|
||||
publicPath: webpackConfig.output.publicPath,
|
||||
}));
|
||||
|
||||
// Proxy requests to avoid CORS errors
|
||||
// For /v2 endpoint, rewrite cookie Domains to localhost
|
||||
// This makes the login & logout work in development
|
||||
const proxyOptions = url.parse('https://store-stage.docker.com/v2');
|
||||
proxyOptions.cookieRewrite = 'localhost';
|
||||
server.use('/v2', proxy(proxyOptions));
|
||||
server.use('/api', proxy('https://store-stage.docker.com/api'));
|
||||
|
||||
// Client-side hot reload
|
||||
server.use(webpackHotMiddleware(compiler));
|
||||
|
||||
// Server-side hot reload
|
||||
compiler.plugin('done', () => {
|
||||
Object.keys(require.cache).forEach(id => {
|
||||
if (id.indexOf(path.resolve(__dirname, '..', 'src')) !== -1 ||
|
||||
id.indexOf(path.resolve(__dirname)) !== -1) {
|
||||
delete require.cache[id];
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
server.use('/dist', Express.static('dist'));
|
||||
}
|
||||
|
||||
server.use((req, res) => {
|
||||
res.send(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Docker Store</title>
|
||||
<!-- Google Webmaster -->
|
||||
<meta
|
||||
name="google-site-verification"
|
||||
content="u4812of_thlIvAZUrmDNK4dCM30Us49hReSqGAlttNM"
|
||||
/>
|
||||
<!-- Segment -->
|
||||
<script type="text/javascript">
|
||||
!function(){var analytics=window.analytics=window.analytics||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","page","once","off","on"];analytics.factory=function(t){return function(){var e=Array.prototype.slice.call(arguments);e.unshift(t);analytics.push(e);return analytics}};for(var t=0;t<analytics.methods.length;t++){var e=analytics.methods[t];analytics[e]=analytics.factory(e)}analytics.load=function(t){var e=document.createElement("script");e.type="text/javascript";e.async=!0;e.src=("https:"===document.location.protocol?"https://":"http://")+"cdn.segment.com/analytics.js/v1/"+t+"/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(e,n)};analytics.SNIPPET_VERSION="3.1.0";
|
||||
analytics.load("PkiQ99OVaGVevM33khgOK18hXwwFSoPT");
|
||||
}}();
|
||||
</script>
|
||||
<script src="//d2wy8f7a9ursnm.cloudfront.net/bugsnag-2.min.js"
|
||||
data-apikey=${process.env.BUGSNAG_KEY}></script>
|
||||
${process.env.NODE_ENV === 'production' ?
|
||||
'<link rel="stylesheet" href="/dist/main.css">' : ''}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.__INITIAL_STATE__ = ${'{}'}
|
||||
</script>
|
||||
<script src="/dist/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
server.listen(port, error => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(`Server is listening at http://localhost:${port}.`);
|
||||
});
|
|
@ -1,209 +0,0 @@
|
|||
const request = require('superagent-promise')(require('superagent'), Promise);
|
||||
import { createAction } from 'redux-actions';
|
||||
import { jwt } from 'lib/utils/authHeaders';
|
||||
import { post } from 'superagent';
|
||||
import { readCookie } from 'lib/utils/cookie-handler';
|
||||
|
||||
const DOCKERCLOUD_HOST = '';
|
||||
const ACCOUNT_BASE_URL = `${DOCKERCLOUD_HOST}/v2`;
|
||||
|
||||
// eslint-disable-next-line
|
||||
export const ACCOUNT_FETCH_CURRENT_USER_INFORMATION = 'ACCOUNT_FETCH_CURRENT_USER_INFORMATION';
|
||||
export const ACCOUNT_FETCH_USER_EMAILS = 'ACCOUNT_FETCH_USER_EMAILS';
|
||||
export const ACCOUNT_FETCH_USER_INFORMATION = 'ACCOUNT_FETCH_USER_INFORMATION';
|
||||
export const ACCOUNT_FETCH_USER_NAMESPACES = 'ACCOUNT_FETCH_USER_NAMESPACES';
|
||||
export const ACCOUNT_FETCH_USER_ORGS = 'ACCOUNT_FETCH_USER_ORGS';
|
||||
export const ACCOUNT_LOGOUT = 'ACCOUNT_LOGOUT';
|
||||
export const ACCOUNT_TOGGLE_MAGIC_CARPET = 'ACCOUNT_TOGGLE_MAGIC_CARPET';
|
||||
export const ACCOUNT_SELECT_NAMESPACE = 'ACCOUNT_SELECT_NAMESPACE';
|
||||
|
||||
export const login = (username, password) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const endpoint = '/v2/users/login/';
|
||||
const req = post(endpoint)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', 'application/json')
|
||||
.set('X-CSRFToken', readCookie('csrftoken'));
|
||||
|
||||
req.send({ username, password }).end((err, res) => {
|
||||
if (err) {
|
||||
const errors = {};
|
||||
let body = {};
|
||||
|
||||
try {
|
||||
body = JSON.parse(res.text);
|
||||
} catch (e) {
|
||||
errors._error = res.text;
|
||||
reject(errors);
|
||||
}
|
||||
|
||||
if (body.detail) {
|
||||
errors._error = body.detail;
|
||||
}
|
||||
|
||||
if (body.username) {
|
||||
errors.username = body.username[0];
|
||||
}
|
||||
|
||||
if (body.password) {
|
||||
errors.password = body.password[0];
|
||||
}
|
||||
|
||||
reject(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const signup = (email, username, password, redirect_value) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const endpoint = '/v2/users/signup/';
|
||||
const req = post(endpoint)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', 'application/json')
|
||||
.set('X-CSRFToken', readCookie('csrftoken'));
|
||||
|
||||
req.send({ email, username, password, redirect_value }).end((err, res) => {
|
||||
if (err) {
|
||||
const errors = {};
|
||||
let body = {};
|
||||
|
||||
try {
|
||||
body = JSON.parse(res.text);
|
||||
} catch (e) {
|
||||
errors._error = res.text;
|
||||
reject(errors);
|
||||
}
|
||||
|
||||
if (body.detail) {
|
||||
errors._error = body.detail;
|
||||
}
|
||||
|
||||
if (body.email) {
|
||||
errors.email = body.email[0];
|
||||
}
|
||||
|
||||
if (body.username) {
|
||||
errors.username = body.username[0];
|
||||
}
|
||||
|
||||
if (body.password) {
|
||||
errors.password = body.password[0];
|
||||
}
|
||||
|
||||
reject(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export function accountLogout() {
|
||||
const accountUrl = `${ACCOUNT_BASE_URL}/user/logout`;
|
||||
return {
|
||||
type: ACCOUNT_LOGOUT,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.post(accountUrl)
|
||||
.set(jwt())
|
||||
.set('Accept', '*/*')
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
Sample JSON for user fetch
|
||||
{
|
||||
"id": "4663b07ca74111e492090242ac110143",
|
||||
"username": "test1",
|
||||
"full_name": "asdfasdfasdf",
|
||||
"location": "asdf",
|
||||
"company": "stuff",
|
||||
"gravatar_email": "",
|
||||
"is_staff": false,
|
||||
"is_admin": false,
|
||||
"profile_url": "",
|
||||
"date_joined": "2014-09-23T19:42:13Z",
|
||||
"gravatar_url": "https://secure.gravatar.com/avatar/88d62a9d7579193eea16d4f5ddee3f62.jpg?s=80&r=g&d=mm",
|
||||
"type": "User"
|
||||
}
|
||||
*/
|
||||
export function accountFetchUser({ namespace, isOrg }) {
|
||||
const userOrOrg = isOrg ? 'orgs' : 'users';
|
||||
const accountUrl = `${ACCOUNT_BASE_URL}/${userOrOrg}/${namespace}/`;
|
||||
return {
|
||||
type: ACCOUNT_FETCH_USER_INFORMATION,
|
||||
payload: {
|
||||
promise: request
|
||||
.get(accountUrl)
|
||||
.set(jwt())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function accountFetchCurrentUser({ shouldRedirectToLogin } = {}) {
|
||||
const url = `${ACCOUNT_BASE_URL}/user/`;
|
||||
return {
|
||||
type: ACCOUNT_FETCH_CURRENT_USER_INFORMATION,
|
||||
meta: {
|
||||
shouldRedirectToLogin,
|
||||
},
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(jwt())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function accountFetchUserEmails({ user }) {
|
||||
const url = `${ACCOUNT_BASE_URL}/emailaddresses/`;
|
||||
return {
|
||||
type: ACCOUNT_FETCH_USER_EMAILS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(jwt())
|
||||
.query({ user })
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function accountFetchUserOrgs() {
|
||||
// Fetches user objects for all namespaces you have access to - not just own
|
||||
const url = `${ACCOUNT_BASE_URL}/user/orgs/`;
|
||||
return {
|
||||
type: ACCOUNT_FETCH_USER_ORGS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(jwt())
|
||||
.query({ page_size: 100 })
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const accountSelectNamespace = createAction(ACCOUNT_SELECT_NAMESPACE);
|
||||
export const accountToggleMagicCarpet =
|
||||
createAction(ACCOUNT_TOGGLE_MAGIC_CARPET);
|
|
@ -1,607 +0,0 @@
|
|||
const request = require('superagent-promise')(require('superagent'), Promise);
|
||||
import { merge } from 'lodash';
|
||||
import isStaging from 'lib/utils/isStaging';
|
||||
import isDev from 'lib/utils/isDevelopment';
|
||||
import encodeForm from 'lib/utils/encodeForm';
|
||||
import createBearer from 'lib/utils/create-bearer';
|
||||
import { bearer } from 'lib/utils/authHeaders';
|
||||
import bugsnagNotify from 'lib/utils/metrics';
|
||||
import { isProductBundle } from 'lib/utils/product-utils';
|
||||
import {
|
||||
marketplaceFetchBundleDetail,
|
||||
marketplaceFetchRepositoryDetail,
|
||||
} from 'actions/marketplace';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export const BILLING_CREATE_PAYMENT_METHOD = 'BILLING_CREATE_PAYMENT_METHOD';
|
||||
export const BILLING_CREATE_PAYMENT_TOKEN = 'BILLING_CREATE_PAYMENT_TOKEN';
|
||||
export const BILLING_CREATE_PRODUCT = 'BILLING_CREATE_PRODUCT';
|
||||
export const BILLING_CREATE_PROFILE = 'BILLING_CREATE_PROFILE';
|
||||
export const BILLING_CREATE_SUBSCRIPTION = 'BILLING_CREATE_SUBSCRIPTION';
|
||||
export const BILLING_DELETE_PAYMENT_METHOD = 'BILLING_DELETE_PAYMENT_METHOD';
|
||||
export const BILLING_DELETE_SUBSCRIPTION = 'BILLING_DELETE_SUBSCRIPTION';
|
||||
export const BILLING_FETCH_INVOICE_PDF = 'BILLING_FETCH_INVOICE_PDF';
|
||||
export const BILLING_FETCH_INVOICES = 'BILLING_FETCH_INVOICES';
|
||||
export const BILLING_FETCH_LICENSE_DETAIL = 'BILLING_FETCH_LICENSE_DETAIL';
|
||||
export const BILLING_FETCH_LICENSE_FILE = 'BILLING_FETCH_LICENSE_FILE';
|
||||
export const BILLING_FETCH_PAYMENT_METHODS = 'BILLING_FETCH_PAYMENT_METHODS';
|
||||
export const BILLING_FETCH_PRODUCT = 'BILLING_FETCH_PRODUCT';
|
||||
export const BILLING_FETCH_PROFILE_SUBSCRIPTIONS = 'BILLING_FETCH_PROFILE_SUBSCRIPTIONS';
|
||||
export const BILLING_FETCH_PROFILE_SUBSCRIPTIONS_AND_PRODUCTS = 'BILLING_FETCH_PROFILE_SUBSCRIPTIONS_AND_PRODUCTS';
|
||||
export const BILLING_FETCH_PROFILE = 'BILLING_FETCH_PROFILE';
|
||||
export const BILLING_SET_DEFAULT_PAYMENT_METHOD = 'BILLING_SET_DEFAULT_PAYMENT_METHOD';
|
||||
export const BILLING_UPDATE_PROFILE = 'BILLING_UPDATE_PROFILE';
|
||||
export const BILLING_UPDATE_SUBSCRIPTION = 'BILLING_UPDATE_SUBSCRIPTION';
|
||||
|
||||
const BILLING_HOST = '';
|
||||
export const BILLING_BASE_URL = `${BILLING_HOST}/api/billing/v4`;
|
||||
|
||||
/*
|
||||
* =============================================================================
|
||||
* BILLING PRODUCT CALLS
|
||||
* - Calls associated with the product catalog & plans
|
||||
* =============================================================================
|
||||
*/
|
||||
export function billingFetchProduct({ id }) {
|
||||
const url = `${BILLING_BASE_URL}/products/${id}/`;
|
||||
return {
|
||||
type: BILLING_FETCH_PRODUCT,
|
||||
meta: { id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const billingCreateProduct = ({ id, body }) => dispatch => {
|
||||
const url = `${BILLING_BASE_URL}/products/${id}/`;
|
||||
return dispatch({
|
||||
type: BILLING_CREATE_PRODUCT,
|
||||
meta: { id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.put(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.send(body)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
* =============================================================================
|
||||
* USER BILLING CALLS
|
||||
* - Calls associated with a User's billing/subscription information
|
||||
* - Must include JWT and docker_id
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// PAYMENTS & PAYMENT METHODS
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
export function billingFetchPaymentMethods({ docker_id }) {
|
||||
const url = `${BILLING_BASE_URL}/accounts/${docker_id}/payment-methods/`;
|
||||
return {
|
||||
type: BILLING_FETCH_PAYMENT_METHODS,
|
||||
meta: {
|
||||
docker_id,
|
||||
shouldRedirectToLogin: true,
|
||||
},
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
NOTE: Stripe's api only accepts x-www-form-urlencoded data
|
||||
NOTE: 'billforwardCreatePayment' is chained to this function and requires
|
||||
`billforward-id`. The `billforward-id` is being passed through to the
|
||||
create payment function via the META here.
|
||||
*/
|
||||
function createCardToken({
|
||||
cvc,
|
||||
exp_month,
|
||||
exp_year,
|
||||
name_first,
|
||||
name_last,
|
||||
number,
|
||||
}, meta) {
|
||||
// TODO ENV VAR - nathan - move const variables to environment
|
||||
const stripeUrl = 'https://api.stripe.com/v1/tokens';
|
||||
let stripeToken = 'pk_live_89IjovLdwh2MTzV7JsGJK3qk';
|
||||
if (isStaging() || isDev()) {
|
||||
stripeToken = 'pk_test_DMJYisAqHlWvFPgRfkKayAcF';
|
||||
}
|
||||
const card = {
|
||||
name: `${name_first} ${name_last}`,
|
||||
cvc,
|
||||
number,
|
||||
exp_month,
|
||||
exp_year,
|
||||
};
|
||||
const encoded = encodeForm({ card });
|
||||
return {
|
||||
type: BILLING_CREATE_PAYMENT_TOKEN,
|
||||
payload: {
|
||||
promise: request.post(stripeUrl)
|
||||
.accept('application/json')
|
||||
.type('application/x-www-form-urlencoded')
|
||||
.set('Authorization', createBearer(stripeToken))
|
||||
.send(encoded)
|
||||
.then((res) => {
|
||||
return {
|
||||
body: res.body,
|
||||
meta,
|
||||
};
|
||||
}, (res) => {
|
||||
/*
|
||||
402 PAYMENT REQUIRED error occurs when user has incorrectly input
|
||||
their card information. We don't care about user error - only if
|
||||
something goes wrong on our end.
|
||||
*/
|
||||
if (res.status !== 402 || !isDev()) {
|
||||
bugsnagNotify('STRIPE TOKEN ERR', res.message);
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
NOTE: This is the call to billforward that actually adds a payment method
|
||||
to a user's billing profile.
|
||||
This call requires a billforward-id (accountID) which is DIFFERENT than the
|
||||
docker_id - Hence why 'billingCreatePaymentMethod' is NOT being wrapped by
|
||||
the getAccountFromNamespace decorator.
|
||||
*/
|
||||
function billforwardCreatePayment({
|
||||
'@type': type,
|
||||
accountID,
|
||||
cardID,
|
||||
defaultPaymentMethod,
|
||||
gateway,
|
||||
stripeToken,
|
||||
}) {
|
||||
// TODO ENV VAR - nathan - move const variables to environment
|
||||
let billforwardUrl =
|
||||
'https://api.billforward.net/v1/tokenization/auth-capture';
|
||||
let billforwardToken = '650cbe35-4aca-4820-a7d1-accec8a7083a';
|
||||
if (isStaging() || isDev()) {
|
||||
billforwardUrl =
|
||||
'https://api-sandbox.billforward.net:443/v1/tokenization/auth-capture';
|
||||
billforwardToken = 'ec687f76-c1b6-4d71-b919-4fe99202ca13';
|
||||
}
|
||||
return {
|
||||
type: BILLING_CREATE_PAYMENT_METHOD,
|
||||
payload: {
|
||||
promise: request.post(billforwardUrl)
|
||||
.accept('application/json')
|
||||
.type('application/json')
|
||||
.set('Authorization', createBearer(billforwardToken))
|
||||
.send({
|
||||
'@type': type,
|
||||
accountID,
|
||||
cardID,
|
||||
defaultPaymentMethod,
|
||||
gateway,
|
||||
stripeToken,
|
||||
})
|
||||
.then((res) => res.body.results, (res) => {
|
||||
if (!isDev()) {
|
||||
bugsnagNotify('BF AUTH CAPTURE ERR', res.message);
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const billingCreatePaymentMethod = ({
|
||||
billforward_id,
|
||||
cvc,
|
||||
exp_month,
|
||||
exp_year,
|
||||
name_first,
|
||||
name_last,
|
||||
number,
|
||||
}) => dispatch => {
|
||||
const cardData = {
|
||||
cvc,
|
||||
exp_month,
|
||||
exp_year,
|
||||
name_first,
|
||||
name_last,
|
||||
number,
|
||||
};
|
||||
/*
|
||||
NOTE:
|
||||
Creating a payment method requires 2 parts
|
||||
1 - Generating a token from Stripe's api
|
||||
2 - Sending generated token to Billforward's api to attach payment method to
|
||||
a relevant billing profile.
|
||||
*/
|
||||
return dispatch(createCardToken(cardData, { billforward_id }))
|
||||
.then((res) => {
|
||||
const tokenObject = res.value.body;
|
||||
const stripeToken = tokenObject.id;
|
||||
const cardID = tokenObject.card.id;
|
||||
const accountID = res.value.meta.billforward_id;
|
||||
const bfData = {
|
||||
'@type': 'StripeAuthCaptureRequest',
|
||||
accountID,
|
||||
cardID,
|
||||
defaultPaymentMethod: true,
|
||||
gateway: 'Stripe',
|
||||
stripeToken,
|
||||
};
|
||||
return dispatch(billforwardCreatePayment(bfData));
|
||||
});
|
||||
};
|
||||
|
||||
// Note: No response from this endpoint. Should refetch payment methods in then
|
||||
export const billingSetDefaultPaymentMethod = ({ docker_id, card_id }) => {
|
||||
const url =
|
||||
`${BILLING_BASE_URL}/accounts/${docker_id}/payment-methods/${card_id}/`;
|
||||
return {
|
||||
type: BILLING_SET_DEFAULT_PAYMENT_METHOD,
|
||||
payload: {
|
||||
promise: request
|
||||
.patch(url)
|
||||
.set(bearer())
|
||||
.send({
|
||||
default: true,
|
||||
})
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Note: No response from this endpoint. Should refetch payment methods in then
|
||||
export const billingDeletePaymentMethod = ({ docker_id, card_id }) => {
|
||||
const url =
|
||||
`${BILLING_BASE_URL}/accounts/${docker_id}/payment-methods/${card_id}/`;
|
||||
return {
|
||||
type: BILLING_DELETE_PAYMENT_METHOD,
|
||||
payload: {
|
||||
promise: request
|
||||
.del(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// PROFILES
|
||||
//------------------------------------------------------------------------------
|
||||
export const billingFetchProfile = ({ docker_id, isOrg }) => dispatch => {
|
||||
const url = `${BILLING_BASE_URL}/accounts/${docker_id}/`;
|
||||
return dispatch({
|
||||
type: BILLING_FETCH_PROFILE,
|
||||
meta: { docker_id, isOrg },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => {
|
||||
/*
|
||||
NOTE: required billforward-account-id must be pulled from the header
|
||||
*/
|
||||
return merge(
|
||||
{},
|
||||
res.body,
|
||||
{ profile:
|
||||
{ billforward_id: res.header['billforward-account-id'] },
|
||||
}
|
||||
);
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* NOTE: A sample profile includes information such as...
|
||||
* profile: {
|
||||
* first_name,
|
||||
* last_name,
|
||||
* email,
|
||||
* primary_phone,
|
||||
* company_name,
|
||||
* job_function,
|
||||
* addresses,
|
||||
* }
|
||||
*/
|
||||
export function billingCreateProfile({
|
||||
docker_id,
|
||||
profile,
|
||||
}) {
|
||||
const url = `${BILLING_BASE_URL}/accounts/${docker_id}/`;
|
||||
return {
|
||||
type: BILLING_CREATE_PROFILE,
|
||||
meta: { docker_id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.put(url)
|
||||
.set(bearer())
|
||||
.send({ profile })
|
||||
.end()
|
||||
.then((res) => {
|
||||
/*
|
||||
NOTE: required billforward-account-id must be pulled from the header
|
||||
*/
|
||||
return merge(
|
||||
{},
|
||||
res.body,
|
||||
{ profile:
|
||||
{ billforward_id: res.header['billforward-account-id'] },
|
||||
}
|
||||
);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function billingUpdateProfile({
|
||||
addresses,
|
||||
company_name,
|
||||
docker_id,
|
||||
email,
|
||||
first_name,
|
||||
job_function,
|
||||
last_name,
|
||||
phone_primary,
|
||||
}) {
|
||||
const url = `${BILLING_BASE_URL}/accounts/${docker_id}/profile/`;
|
||||
return {
|
||||
type: BILLING_UPDATE_PROFILE,
|
||||
meta: { docker_id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.patch(url)
|
||||
.set(bearer())
|
||||
.send({
|
||||
addresses,
|
||||
company_name,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
job_function,
|
||||
phone_primary,
|
||||
})
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// INVOICES
|
||||
//------------------------------------------------------------------------------
|
||||
export function billingFetchInvoices({ docker_id }) {
|
||||
const url = `${BILLING_BASE_URL}/invoices/`;
|
||||
return {
|
||||
type: BILLING_FETCH_INVOICES,
|
||||
meta: { shouldRedirectToLogin: true },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.query({ docker_id })
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function billingFetchInvoicePDF({ docker_id, invoice_id }) {
|
||||
const url = `${BILLING_BASE_URL}/invoices/${invoice_id}`;
|
||||
// Undocumented responseType property
|
||||
// https://github.com/visionmedia/superagent/pull/888/files
|
||||
return {
|
||||
type: BILLING_FETCH_INVOICE_PDF,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.query({ docker_id })
|
||||
.set(bearer())
|
||||
.accept('application/pdf')
|
||||
.responseType('blob')
|
||||
.end()
|
||||
.then(({ xhr }) => {
|
||||
// xhr.response is a Blob
|
||||
return xhr.response;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// LICENSES
|
||||
//------------------------------------------------------------------------------
|
||||
export const billingFetchLicenseDetail = ({ subscription_id }) => {
|
||||
const url = `${BILLING_BASE_URL}/subscriptions/${subscription_id}/license-detail/`;
|
||||
return {
|
||||
type: BILLING_FETCH_LICENSE_DETAIL,
|
||||
meta: { subscription_id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const billingFetchLicenseFile = ({ subscription_id }) => {
|
||||
const url = `${BILLING_BASE_URL}/subscriptions/${subscription_id}/license-file/`;
|
||||
return {
|
||||
type: BILLING_FETCH_LICENSE_FILE,
|
||||
meta: { subscription_id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// SUBSCRIPTIONS
|
||||
//------------------------------------------------------------------------------
|
||||
// TODO Kristie Replace this with the composition API when it is available
|
||||
export const billingFetchProfileSubscriptionsAndProducts = ({ docker_id }) => dispatch => {
|
||||
const url = `${BILLING_BASE_URL}/subscriptions/`;
|
||||
return dispatch({
|
||||
type: BILLING_FETCH_PROFILE_SUBSCRIPTIONS_AND_PRODUCTS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.query({ docker_id })
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => {
|
||||
const results = res.body || [];
|
||||
// For each subscription, we must fetch the product details
|
||||
// from billing AND from product catalog
|
||||
const promises = results.map((sub) => {
|
||||
const { product_id: id, subscription_id } = sub;
|
||||
const getProductDetails = [
|
||||
// Do not make this call bubble up the err
|
||||
dispatch(billingFetchProduct({ id })).catch(() => {}),
|
||||
];
|
||||
if (isProductBundle(id)) {
|
||||
getProductDetails.push(
|
||||
dispatch(marketplaceFetchBundleDetail({ id }))
|
||||
);
|
||||
// Fetch license details for DDC
|
||||
getProductDetails.push(
|
||||
dispatch(billingFetchLicenseDetail({ subscription_id }))
|
||||
.catch(() => {}) // Do not make this call bubble up the err
|
||||
);
|
||||
} else {
|
||||
getProductDetails.push(
|
||||
dispatch(marketplaceFetchRepositoryDetail({ id }))
|
||||
.catch(() => {})
|
||||
);
|
||||
}
|
||||
return Promise.when(getProductDetails);
|
||||
});
|
||||
return Promise.when(promises).then(() => results);
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const billingFetchProfileSubscriptions = ({ docker_id }) => dispatch => {
|
||||
const url = `${BILLING_BASE_URL}/subscriptions/`;
|
||||
return dispatch({
|
||||
type: BILLING_FETCH_PROFILE_SUBSCRIPTIONS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.query({ docker_id })
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export function billingCreateSubscription({
|
||||
docker_id,
|
||||
eusa,
|
||||
name,
|
||||
pricing_components, // Array of objects
|
||||
product_id,
|
||||
product_rate_plan,
|
||||
}) {
|
||||
const params = {
|
||||
docker_id,
|
||||
eusa,
|
||||
name,
|
||||
pricing_components,
|
||||
product_id,
|
||||
product_rate_plan,
|
||||
};
|
||||
const url = `${BILLING_BASE_URL}/subscriptions/`;
|
||||
return {
|
||||
type: BILLING_CREATE_SUBSCRIPTION,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.post(url)
|
||||
.query({ docker_id })
|
||||
.set(bearer())
|
||||
.send(params)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// body is the request body
|
||||
export const billingUpdateSubscription =
|
||||
({ subscription_id, body }) => dispatch => {
|
||||
const url = `${BILLING_BASE_URL}/subscriptions/${subscription_id}/`;
|
||||
return dispatch({
|
||||
type: BILLING_UPDATE_SUBSCRIPTION,
|
||||
meta: { subscription_id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.patch(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.send(body)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const billingDeleteSubscription = ({ subscription_id }) => dispatch => {
|
||||
const url = `${BILLING_BASE_URL}/subscriptions/${subscription_id}/`;
|
||||
return dispatch({
|
||||
type: BILLING_DELETE_SUBSCRIPTION,
|
||||
meta: { subscription_id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.del(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.send()
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
});
|
||||
};
|
|
@ -1,15 +0,0 @@
|
|||
export const INTERNAL_ROUTER_READY = 'INTERNAL_ROUTER_READY';
|
||||
export const INTERNAL_STORE_IDLE = 'INTERNAL_STORE_IDLE';
|
||||
|
||||
export function internalRouterReady() {
|
||||
return {
|
||||
type: INTERNAL_ROUTER_READY,
|
||||
};
|
||||
}
|
||||
|
||||
export function internalStoreIdle(state) {
|
||||
return {
|
||||
type: INTERNAL_STORE_IDLE,
|
||||
payload: state,
|
||||
};
|
||||
}
|
|
@ -1,371 +0,0 @@
|
|||
const request = require('superagent-promise')(require('superagent'), Promise);
|
||||
import { bearer } from 'lib/utils/authHeaders';
|
||||
import { DEFAULT_SEARCH_PAGE_SIZE } from 'lib/constants/defaults';
|
||||
import { OFFICIAL } from 'lib/constants/searchFilters/sources';
|
||||
// TODO Kristie 5/17/16 Use actual API when it is ready
|
||||
import searchPlatformFilters from 'lib/constants/searchFilters/platforms';
|
||||
import isStaging from 'lib/utils/isStaging';
|
||||
import sanitize from 'lib/utils/remove-undefined';
|
||||
import trim from 'lodash/trim';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export const MARKETPLACE_FETCH_AUTOCOMPLETE_SUGGESTIONS = 'MARKETPLACE_FETCH_AUTOCOMPLETE_SUGGESTIONS';
|
||||
export const MARKETPLACE_FETCH_BUNDLE_DETAIL = 'MARKETPLACE_FETCH_BUNDLE_DETAIL';
|
||||
export const MARKETPLACE_FETCH_BUNDLE_SUMMARY = 'MARKETPLACE_FETCH_BUNDLE_SUMMARY';
|
||||
export const MARKETPLACE_FETCH_CATEGORIES = 'MARKETPLACE_FETCH_CATEGORIES';
|
||||
export const MARKETPLACE_FETCH_MOST_POPULAR = 'MARKETPLACE_FETCH_MOST_POPULAR';
|
||||
export const MARKETPLACE_FETCH_FEATURED = 'MARKETPLACE_FETCH_FEATURED';
|
||||
export const MARKETPLACE_FETCH_PLATFORMS = 'MARKETPLACE_FETCH_PLATFORMS';
|
||||
export const MARKETPLACE_FETCH_REPOSITORY_DETAIL = 'MARKETPLACE_FETCH_REPOSITORY_DETAIL';
|
||||
export const MARKETPLACE_FETCH_REPOSITORY_SUMMARY = 'MARKETPLACE_FETCH_REPOSITORY_SUMMARY';
|
||||
export const MARKETPLACE_SEARCH = 'MARKETPLACE_SEARCH';
|
||||
export const MARKETPLACE_CREATE_REPOSITORY = 'MARKETPLACE_CREATE_REPOSITORY';
|
||||
export const MARKETPLACE_EDIT_REPOSITORY = 'MARKETPLACE_EDIT_REPOSITORY';
|
||||
export const MARKETPLACE_DELETE_REPOSITORY = 'MARKETPLACE_DELETE_REPOSITORY';
|
||||
|
||||
const MARKETPLACE_HOST = '';
|
||||
const MARKETPLACE_BASE_URL = `${MARKETPLACE_HOST}/api/content/v1`;
|
||||
|
||||
const MARKETPLACE_PRIVATE_HOST = process.env.NODE_ENV !== 'production' || isStaging() ?
|
||||
'https://mercury-content.s.stage-us-east-1.aws.dckr.io' :
|
||||
'https://mercury-content.s.us-east-1.aws.dckr.io';
|
||||
const MARKETPLACE_PRIVATE_BASE_URL = `${MARKETPLACE_PRIVATE_HOST}/api/private/content`;
|
||||
/* eslint-enable max-len */
|
||||
|
||||
export function marketplaceSearch({
|
||||
q,
|
||||
category,
|
||||
order,
|
||||
page,
|
||||
page_size = DEFAULT_SEARCH_PAGE_SIZE,
|
||||
platform,
|
||||
sort,
|
||||
// Unless explicitly specified, do not include community results
|
||||
source = OFFICIAL,
|
||||
}) {
|
||||
// Trim whitespace from query
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
q = trim(q);
|
||||
const params = {
|
||||
q,
|
||||
category,
|
||||
order,
|
||||
page,
|
||||
page_size,
|
||||
platform,
|
||||
sort,
|
||||
source,
|
||||
};
|
||||
|
||||
// Track browse and search separately
|
||||
if (q === '' && page_size === '99') {
|
||||
analytics.track('browse', sanitize({ category, platform, source }));
|
||||
} else {
|
||||
analytics.track('search', sanitize({ q, category, platform, source }));
|
||||
}
|
||||
const url = `${MARKETPLACE_BASE_URL}/repositories/search`;
|
||||
return {
|
||||
type: MARKETPLACE_SEARCH,
|
||||
meta: { q, page, page_size },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.query(params)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch the suggestions for the drop down on global search
|
||||
export function marketplaceFetchAutocompleteSuggestions({ q }) {
|
||||
const page = 1;
|
||||
const page_size = 6;
|
||||
const params = { q, page, page_size };
|
||||
|
||||
const url = `${MARKETPLACE_BASE_URL}/repositories/search`;
|
||||
return {
|
||||
type: MARKETPLACE_FETCH_AUTOCOMPLETE_SUGGESTIONS,
|
||||
meta: params,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.query(params)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function marketplaceFetchRepositorySummary({ id }) {
|
||||
const url =
|
||||
`${MARKETPLACE_BASE_URL}/repositories/${id}/summary`;
|
||||
return {
|
||||
type: MARKETPLACE_FETCH_REPOSITORY_SUMMARY,
|
||||
meta: { id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function marketplaceFetchRepositoryDetail({ id }) {
|
||||
const url = `${MARKETPLACE_BASE_URL}/repositories/${id}`;
|
||||
return {
|
||||
type: MARKETPLACE_FETCH_REPOSITORY_DETAIL,
|
||||
meta: { id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function marketplaceFetchBundleSummary({ id }) {
|
||||
const url = `${MARKETPLACE_BASE_URL}/bundles/${id}/summary`;
|
||||
return {
|
||||
type: MARKETPLACE_FETCH_BUNDLE_SUMMARY,
|
||||
meta: { id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function marketplaceFetchBundleDetail({ id, shouldRedirectToLogin }) {
|
||||
const url = `${MARKETPLACE_BASE_URL}/bundles/${id}`;
|
||||
return {
|
||||
type: MARKETPLACE_FETCH_BUNDLE_DETAIL,
|
||||
meta: {
|
||||
id,
|
||||
shouldRedirectToLogin,
|
||||
},
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function marketplaceFetchCategories() {
|
||||
const url = `${MARKETPLACE_BASE_URL}/categories`;
|
||||
return {
|
||||
type: MARKETPLACE_FETCH_CATEGORIES,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// TODO Kristie 5/17/16 Use actual API when it is ready
|
||||
export function marketplaceFetchPlatforms() {
|
||||
return {
|
||||
type: MARKETPLACE_FETCH_PLATFORMS,
|
||||
payload: {
|
||||
promise: new Promise((resolve) => {
|
||||
resolve(searchPlatformFilters);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Home page special searches
|
||||
// -----------------------------------------------------------------------------
|
||||
// Most Popular Images (fetch 9)
|
||||
export function marketplaceFetchMostPopular() {
|
||||
const params = { page_size: 9, source: OFFICIAL };
|
||||
const url = `${MARKETPLACE_BASE_URL}/repositories/search`;
|
||||
return {
|
||||
type: MARKETPLACE_FETCH_MOST_POPULAR,
|
||||
meta: params,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.query(params)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Newest Images (fetch 9)
|
||||
export function marketplaceFetchFeatured() {
|
||||
const params = {
|
||||
page_size: 9,
|
||||
category: 'featured',
|
||||
source: OFFICIAL,
|
||||
};
|
||||
const url = `${MARKETPLACE_BASE_URL}/repositories/search`;
|
||||
return {
|
||||
type: MARKETPLACE_FETCH_FEATURED,
|
||||
meta: params,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.query(params)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function marketplaceCreateRepository({
|
||||
name,
|
||||
namespace,
|
||||
reponame,
|
||||
publisher,
|
||||
short_description,
|
||||
full_description,
|
||||
categories,
|
||||
platforms,
|
||||
source,
|
||||
logo_url,
|
||||
screenshots,
|
||||
links,
|
||||
eusa,
|
||||
download_attribute,
|
||||
instructions,
|
||||
}) {
|
||||
const url = `${MARKETPLACE_PRIVATE_BASE_URL}/repositories/`;
|
||||
const body = {
|
||||
name,
|
||||
namespace,
|
||||
reponame,
|
||||
publisher,
|
||||
short_description,
|
||||
full_description,
|
||||
categories,
|
||||
platforms,
|
||||
source,
|
||||
logo_url,
|
||||
screenshots,
|
||||
links,
|
||||
eusa,
|
||||
download_attribute,
|
||||
instructions,
|
||||
};
|
||||
return {
|
||||
type: MARKETPLACE_CREATE_REPOSITORY,
|
||||
meta: body,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.post(url)
|
||||
.set(bearer())
|
||||
.send(body)
|
||||
.accept('application/json')
|
||||
.type('application/json')
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function marketplaceEditRepository({
|
||||
id,
|
||||
name,
|
||||
namespace,
|
||||
reponame,
|
||||
publisher,
|
||||
short_description,
|
||||
full_description,
|
||||
categories,
|
||||
platforms,
|
||||
source,
|
||||
logo_url,
|
||||
screenshots,
|
||||
links,
|
||||
eusa,
|
||||
download_attribute,
|
||||
instructions,
|
||||
}) {
|
||||
const body = {
|
||||
name,
|
||||
namespace,
|
||||
reponame,
|
||||
publisher,
|
||||
short_description,
|
||||
full_description,
|
||||
categories,
|
||||
platforms,
|
||||
logo_url,
|
||||
source,
|
||||
screenshots,
|
||||
links,
|
||||
eusa,
|
||||
download_attribute,
|
||||
instructions,
|
||||
};
|
||||
const url = `${MARKETPLACE_PRIVATE_BASE_URL}/repositories/${id}`;
|
||||
return {
|
||||
type: MARKETPLACE_EDIT_REPOSITORY,
|
||||
meta: body,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.patch(url)
|
||||
.set(bearer())
|
||||
.send(body)
|
||||
.accept('application/json')
|
||||
.type('application/json')
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function marketplaceDeleteRepository({ id }) {
|
||||
const url = `${MARKETPLACE_PRIVATE_BASE_URL}/repositories/${id}`;
|
||||
return {
|
||||
type: MARKETPLACE_DELETE_REPOSITORY,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.del(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
const request = require('superagent-promise')(require('superagent'), Promise);
|
||||
import { normalize } from 'normalizr';
|
||||
import { jwt } from 'lib/utils/authHeaders';
|
||||
import { normalizers } from 'lib/constants/nautilus';
|
||||
import { DEFAULT_TAGS_PAGE_SIZE } from 'lib/constants/defaults';
|
||||
|
||||
export const NAUTILUS_FETCH_TAGS_AND_SCANS = 'NAUTILUS_FETCH_TAGS_AND_SCANS';
|
||||
export const NAUTILUS_FETCH_SCAN_DETAIL = 'NAUTILUS_FETCH_SCAN_DETAIL';
|
||||
|
||||
// Don't need to proxy the nautilus api
|
||||
const NAUTILUS_HOST = '';
|
||||
const NAUTILUS_BASE_URL = `${NAUTILUS_HOST}/api/nautilus/v1/repositories`;
|
||||
|
||||
const DOCKERHUB_HOST = '';
|
||||
const DOCKERHUB_BASE_URL = `${DOCKERHUB_HOST}/v2/repositories`;
|
||||
|
||||
|
||||
export const nautilusFetchTagsAndScans = ({
|
||||
id,
|
||||
namespace,
|
||||
reponame,
|
||||
page = 1,
|
||||
page_size = DEFAULT_TAGS_PAGE_SIZE,
|
||||
}) => {
|
||||
const scansUrl = `${NAUTILUS_BASE_URL}/summaries/${namespace}/${reponame}/`;
|
||||
const tagsUrl = `${DOCKERHUB_BASE_URL}/${namespace}/${reponame}/tags/`;
|
||||
return {
|
||||
type: NAUTILUS_FETCH_TAGS_AND_SCANS,
|
||||
meta: { id, namespace, reponame, page, page_size },
|
||||
payload: {
|
||||
promise: new Promise((resolve, reject) => {
|
||||
// Fetch all of the tags for this repository (paginated)
|
||||
request.get(tagsUrl)
|
||||
.set(jwt())
|
||||
.query({ page, page_size })
|
||||
.end()
|
||||
.then(
|
||||
// onSuccess of fetch tags, attempt to fetch the scans
|
||||
({ body: tags }) => {
|
||||
// Fetch all of the scans for this repository
|
||||
request.get(scansUrl)
|
||||
.set(jwt())
|
||||
.end()
|
||||
.then(
|
||||
// onSuccess of fetch scans, return both tags and scans
|
||||
({ body: scans }) => { resolve([tags, scans]); },
|
||||
// onError of fetch scans, only return tags
|
||||
() => { resolve([tags]); },
|
||||
);
|
||||
},
|
||||
// onError of fetch tags, reject the promise (error)
|
||||
reject
|
||||
);
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const nautilusFetchScanDetail = ({ id, namespace, reponame, tag }) => {
|
||||
const url = `${NAUTILUS_BASE_URL}/result`;
|
||||
const params = { detailed: 1, namespace, reponame, tag };
|
||||
return {
|
||||
type: NAUTILUS_FETCH_SCAN_DETAIL,
|
||||
meta: { ...params, id },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(jwt())
|
||||
.query(params)
|
||||
.end()
|
||||
.then(({ body }) => {
|
||||
// The API response contains a 'scan' resource within an object
|
||||
// inside 'scan_details'
|
||||
const { image, latest_scan_status, scan_details } = body;
|
||||
// Normalize the API result using the 'scan' normalizer schema
|
||||
// TODO Kristie 5/11/16 Do we need the reponame and tag?
|
||||
const normalized = normalize({
|
||||
latest_scan_status,
|
||||
reponame: image.reponame,
|
||||
tag: image.tag,
|
||||
...scan_details,
|
||||
}, normalizers.scan);
|
||||
return normalized;
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,316 +0,0 @@
|
|||
const request = require('superagent-promise')(require('superagent'), Promise);
|
||||
import { bearer } from 'lib/utils/authHeaders';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export const PUBLISH_ACCEPT_VENDOR_AGREEMENT = 'PUBLISH_ACCEPT_VENDOR_AGREEMENT';
|
||||
export const PUBLISH_ADD_PRODUCT_TIERS = 'PUBLISH_ADD_PRODUCT_TIERS';
|
||||
export const PUBLISH_CREATE_PRODUCT = 'PUBLISH_CREATE_PRODUCT';
|
||||
export const PUBLISH_DELETE_PRODUCT_TIERS = 'PUBLISH_DELETE_PRODUCT_TIERS';
|
||||
export const PUBLISH_FETCH_PRODUCT_DETAILS = 'PUBLISH_FETCH_PRODUCT_DETAILS';
|
||||
export const PUBLISH_FETCH_PRODUCT_LIST = 'PUBLISH_FETCH_PRODUCT_LIST';
|
||||
export const PUBLISH_FETCH_PRODUCT_TIERS = 'PUBLISH_FETCH_PRODUCT_TIERS';
|
||||
export const PUBLISH_GET_PUBLISHERS = 'PUBLISH_GET_PUBLISHERS';
|
||||
export const PUBLISH_GET_SIGNUP = 'PUBLISH_GET_SIGNUP';
|
||||
export const PUBLISH_GET_VENDOR_AGREEMENT = 'PUBLISH_GET_VENDOR_AGREEMENT';
|
||||
export const PUBLISH_SUBSCRIBE = 'PUBLISH_SUBSCRIBE';
|
||||
export const PUBLISH_UPDATE_PRODUCT_DETAILS = 'PUBLISH_UPDATE_PRODUCT_DETAILS';
|
||||
export const PUBLISH_UPDATE_PRODUCT_REPOS = 'PUBLISH_UPDATE_PRODUCT_REPOS';
|
||||
export const PUBLISH_UPDATE_PRODUCT_TIERS = 'PUBLISH_UPDATE_PRODUCT_TIERS';
|
||||
export const PUBLISH_UPDATE_PUBLISHER_INFO = 'PUBLISH_UPDATE_PUBLISHER_INFO';
|
||||
|
||||
const PUBLISH_HOST = '';
|
||||
export const PUBLISH_BASE_URL = `${PUBLISH_HOST}/api/publish/v1`;
|
||||
|
||||
export const publishSubscribe = ({
|
||||
first_name,
|
||||
last_name,
|
||||
company,
|
||||
phone_number,
|
||||
email,
|
||||
}) => {
|
||||
const url = `${PUBLISH_BASE_URL}/signups`;
|
||||
return {
|
||||
type: PUBLISH_SUBSCRIBE,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.post(url)
|
||||
.set(bearer())
|
||||
.send({
|
||||
first_name,
|
||||
last_name,
|
||||
company,
|
||||
phone_number,
|
||||
email,
|
||||
})
|
||||
.end()
|
||||
.then(res => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishGetSignup = () => {
|
||||
const url = `${PUBLISH_BASE_URL}/signups`;
|
||||
return {
|
||||
type: PUBLISH_GET_SIGNUP,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishGetPublishers = () => {
|
||||
const url = `${PUBLISH_BASE_URL}/publishers`;
|
||||
return {
|
||||
type: PUBLISH_GET_PUBLISHERS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// NOTE: no reducers for this action yet
|
||||
export const publishUpdatePublisherInfo = (data) => {
|
||||
const url = `${PUBLISH_BASE_URL}/publishers`;
|
||||
/*
|
||||
data:
|
||||
{
|
||||
"email": "nandhini@docker.com",
|
||||
"first_name": "Nandhini",
|
||||
"last_name": "Santhanam",
|
||||
"company": "Docker,Inc",
|
||||
"phone_number": "2222222222",
|
||||
"links": [{
|
||||
"name": "google.com",
|
||||
"label": "website"
|
||||
}]
|
||||
}
|
||||
*/
|
||||
return {
|
||||
type: PUBLISH_UPDATE_PUBLISHER_INFO,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.patch(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.send(data)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishFetchProductList = () => {
|
||||
const url = `${PUBLISH_BASE_URL}/products`;
|
||||
return {
|
||||
type: PUBLISH_FETCH_PRODUCT_LIST,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishFetchProductDetails = ({ product_id }) => {
|
||||
const url = `${PUBLISH_BASE_URL}/products/${product_id}`;
|
||||
return {
|
||||
type: PUBLISH_FETCH_PRODUCT_DETAILS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(bearer())
|
||||
.accept('application/json')
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishGetVendorAgreement = () => {
|
||||
const url = `${PUBLISH_BASE_URL}/vendor-agreement`;
|
||||
return {
|
||||
type: PUBLISH_GET_VENDOR_AGREEMENT,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// NOTE: no reducers for this action yet
|
||||
export const publishCreateProduct = ({ name, status, repositories }) => {
|
||||
const body = {
|
||||
name,
|
||||
status,
|
||||
repositories,
|
||||
};
|
||||
const url = `${PUBLISH_BASE_URL}/products`;
|
||||
return {
|
||||
type: PUBLISH_CREATE_PRODUCT,
|
||||
meta: body,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.post(url)
|
||||
.set(bearer())
|
||||
.send(body)
|
||||
.accept('application/json')
|
||||
.type('application/json')
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// NOTE: no reducers for this action yet
|
||||
export const publishUpdateProductRepos = ({ product_id, repoSources }) => {
|
||||
const url = `${PUBLISH_BASE_URL}/products/${product_id}/repositories`;
|
||||
return {
|
||||
type: PUBLISH_UPDATE_PRODUCT_REPOS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.put(url)
|
||||
.set(bearer())
|
||||
.send(repoSources)
|
||||
.accept('application/json')
|
||||
.type('application/json')
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// NOTE: no reducers for this action yet
|
||||
export const publishUpdateProductDetails = ({ product_id, details }) => {
|
||||
const url = `${PUBLISH_BASE_URL}/products/${product_id}/`;
|
||||
/*
|
||||
details:
|
||||
{
|
||||
name, * required
|
||||
status, * required
|
||||
product_type,
|
||||
full_description,
|
||||
short_description,
|
||||
categories,
|
||||
platforms,
|
||||
links,
|
||||
screenshots,
|
||||
}
|
||||
*/
|
||||
return {
|
||||
type: PUBLISH_UPDATE_PRODUCT_DETAILS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.patch(url)
|
||||
.set(bearer())
|
||||
.send(details)
|
||||
.accept('application/json')
|
||||
.type('application/json')
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishAcceptVendorAgreement = () => {
|
||||
const url = `${PUBLISH_BASE_URL}/accept-vendor-agreement`;
|
||||
return {
|
||||
type: PUBLISH_ACCEPT_VENDOR_AGREEMENT,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.post(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishFetchProductTiers = ({ product_id }) => {
|
||||
const url = `${PUBLISH_BASE_URL}/products/${product_id}/rate-plans`;
|
||||
return {
|
||||
type: PUBLISH_FETCH_PRODUCT_TIERS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishCreateProductTiers = ({ product_id, tiersList }) => {
|
||||
const url = `${PUBLISH_BASE_URL}/products/${product_id}/rate-plans`;
|
||||
return {
|
||||
type: PUBLISH_ADD_PRODUCT_TIERS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.post(url)
|
||||
.set(bearer())
|
||||
.send(tiersList)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishUpdateProductTiers = ({ product_id, tiersObject }) => {
|
||||
const url = `${PUBLISH_BASE_URL}/products/${product_id}/rate-plans`;
|
||||
return {
|
||||
type: PUBLISH_UPDATE_PRODUCT_TIERS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.put(url)
|
||||
.set(bearer())
|
||||
.send(tiersObject)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const publishDeleteProductTiers = ({ product_id, tier_id }) => {
|
||||
const url = `${PUBLISH_BASE_URL}/products/${product_id}/rate-plans/${tier_id}`;
|
||||
return {
|
||||
type: PUBLISH_DELETE_PRODUCT_TIERS,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.del(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,120 +0,0 @@
|
|||
const request = require('superagent-promise')(require('superagent'), Promise);
|
||||
import {
|
||||
DEFAULT_TAGS_PAGE_SIZE,
|
||||
DEFAULT_COMMENTS_PAGE_SIZE,
|
||||
} from 'lib/constants/defaults';
|
||||
import { jwt } from 'lib/utils/authHeaders';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export const REPOSITORY_FETCH_COMMENTS = 'REPOSITORY_FETCH_COMMENTS';
|
||||
export const REPOSITORY_FETCH_OWNED_NAMESPACES = 'REPOSITORY_FETCH_OWNED_NAMESPACES';
|
||||
export const REPOSITORY_FETCH_REPOSITORIES_FOR_NAMESPACE = 'REPOSITORY_FETCH_REPOSITORIES_FOR_NAMESPACE';
|
||||
export const REPOSITORY_FETCH_IMAGE_DETAIL = 'REPOSITORY_FETCH_IMAGE_DETAIL';
|
||||
export const REPOSITORY_FETCH_IMAGE_TAGS = 'REPOSITORY_FETCH_IMAGE_TAGS';
|
||||
/* eslint-enable max-len */
|
||||
|
||||
const DOCKERHUB_HOST = '';
|
||||
const DOCKERHUB_BASE_URL = `${DOCKERHUB_HOST}/v2/repositories`;
|
||||
|
||||
export function repositoryFetchImageDetail({
|
||||
namespace,
|
||||
reponame,
|
||||
}) {
|
||||
const url = `${DOCKERHUB_BASE_URL}/${namespace}/${reponame}/`;
|
||||
return ({
|
||||
type: REPOSITORY_FETCH_IMAGE_DETAIL,
|
||||
meta: { namespace, reponame },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function repositoryFetchImageTags({
|
||||
// Temporary until the marketplace tags service is up
|
||||
id,
|
||||
isCertified = false,
|
||||
namespace,
|
||||
page = 1,
|
||||
page_size = DEFAULT_TAGS_PAGE_SIZE,
|
||||
reponame,
|
||||
}) {
|
||||
const url = `${DOCKERHUB_BASE_URL}/${namespace}/${reponame}/tags/`;
|
||||
const params = { page_size, page };
|
||||
return ({
|
||||
type: REPOSITORY_FETCH_IMAGE_TAGS,
|
||||
meta: { id, isCertified, namespace, reponame, page, page_size },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.query(params)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function repositoryFetchComments({
|
||||
id,
|
||||
isCertified = false,
|
||||
namespace,
|
||||
reponame,
|
||||
page = 1,
|
||||
page_size = DEFAULT_COMMENTS_PAGE_SIZE,
|
||||
}) {
|
||||
const url = `${DOCKERHUB_BASE_URL}/${namespace}/${reponame}/comments/`;
|
||||
const params = { page_size, page };
|
||||
return ({
|
||||
type: REPOSITORY_FETCH_COMMENTS,
|
||||
meta: { id, isCertified, page, page_size, namespace, reponame },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.accept('application/json')
|
||||
.query(params)
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function repositoryFetchOwnedNamespaces() {
|
||||
const url = `${DOCKERHUB_BASE_URL}/namespaces/`;
|
||||
return {
|
||||
type: REPOSITORY_FETCH_OWNED_NAMESPACES,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(jwt())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function repositoryFetchRepositoriesForNamespace({ namespace }) {
|
||||
const url = `${DOCKERHUB_BASE_URL}/${namespace}/`;
|
||||
return {
|
||||
type: REPOSITORY_FETCH_REPOSITORIES_FOR_NAMESPACE,
|
||||
meta: { namespace },
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(jwt())
|
||||
.query({ page_size: 0 })
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
import { createAction } from 'redux-actions';
|
||||
/* eslint-disable max-len */
|
||||
|
||||
// TODO: tests - nathan 06/3/16 Write tests for redirect
|
||||
export const FINISH_PAGE_TRANSITION = 'FINISH_PAGE_TRANSITION';
|
||||
export const REDIRECT = 'REDIRECT';
|
||||
export const ROOT_CHANGE_GLOBAL_SEARCH_VALUE = 'ROOT_CHANGE_GLOBAL_SEARCH_VALUE';
|
||||
export const START_PAGE_TRANSITION = 'START_PAGE_TRANSITION';
|
||||
|
||||
export const redirectTo = createAction(REDIRECT);
|
||||
|
||||
export const startPageTransition = createAction(START_PAGE_TRANSITION);
|
||||
export const finishPageTransition = createAction(FINISH_PAGE_TRANSITION);
|
||||
|
||||
export const rootChangeGlobalSearchValue = ({ value }) => {
|
||||
return {
|
||||
type: ROOT_CHANGE_GLOBAL_SEARCH_VALUE,
|
||||
payload: { value },
|
||||
};
|
||||
};
|
||||
|
||||
/* eslint-enable */
|
|
@ -1,71 +0,0 @@
|
|||
const request = require('superagent-promise')(require('superagent'), Promise);
|
||||
import { bearer } from 'lib/utils/authHeaders';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export const WHITELIST_FETCH_AUTHORIZATION = 'WHITELIST_FETCH_AUTHORIZATION';
|
||||
export const WHITELIST_SUBSCRIBE_TO_BETA = 'WHITELIST_SUBSCRIBE_TO_BETA';
|
||||
export const WHITELIST_AM_I_WAITING = 'WHITELIST_AM_I_WAITING';
|
||||
|
||||
const WHITELIST_HOST = '';
|
||||
export const WHITELIST_BASE_URL = `${WHITELIST_HOST}/api/whitelist/v1`;
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// AUTHORIZATION
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
export const whitelistFetchAuthorization = () => {
|
||||
const url = `${WHITELIST_BASE_URL}/authorize`;
|
||||
return {
|
||||
type: WHITELIST_FETCH_AUTHORIZATION,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then((res) => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// http://store-stage.docker.com/api/whitelist/v1/consumer_interest
|
||||
export const whitelistAmIWaiting = () => {
|
||||
const url = `${WHITELIST_BASE_URL}/consumer_interest`;
|
||||
return {
|
||||
type: WHITELIST_AM_I_WAITING,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.get(url)
|
||||
.set(bearer())
|
||||
.end()
|
||||
.then(res => res.body),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const whitelistSubscribeToBeta = ({
|
||||
first_name,
|
||||
last_name,
|
||||
company,
|
||||
email,
|
||||
}) => {
|
||||
const url = `${WHITELIST_BASE_URL}/consumer_interest`;
|
||||
return {
|
||||
type: WHITELIST_SUBSCRIBE_TO_BETA,
|
||||
payload: {
|
||||
promise:
|
||||
request
|
||||
.post(url)
|
||||
.set(bearer())
|
||||
.send({
|
||||
first_name,
|
||||
last_name,
|
||||
company,
|
||||
email,
|
||||
})
|
||||
.end()
|
||||
.then(res => res.body),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,57 +0,0 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import {
|
||||
Select,
|
||||
Input,
|
||||
} from 'common';
|
||||
|
||||
import css from './styles.css';
|
||||
|
||||
const Emails = ({
|
||||
accountEmails,
|
||||
fields,
|
||||
onSelectChange,
|
||||
initialized = {},
|
||||
}) => {
|
||||
let emails;
|
||||
if (accountEmails.length <= 1) {
|
||||
emails = (
|
||||
<Input
|
||||
readOnly
|
||||
className={css.input}
|
||||
value={accountEmails[0]}
|
||||
id={'email'}
|
||||
inputStyle={{ color: 'white', width: '100%' }}
|
||||
underlineFocusStyle={{ borderColor: 'white' }}
|
||||
placeholder="Email"
|
||||
style={{ marginBottom: '14px', width: '' }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
emails = (
|
||||
<Select
|
||||
{...fields.email}
|
||||
onBlur={() => {}}
|
||||
className={css.select}
|
||||
placeholder="Email"
|
||||
disabled={!!initialized.email}
|
||||
style={{ marginBottom: '10px', width: '' }}
|
||||
options={accountEmails.map((e) => {
|
||||
return { label: e, value: e };
|
||||
})}
|
||||
onChange={onSelectChange}
|
||||
ignoreCase
|
||||
clearable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return emails;
|
||||
};
|
||||
|
||||
Emails.propTypes = {
|
||||
accountEmails: PropTypes.array.isRequired,
|
||||
fields: PropTypes.any.isRequired,
|
||||
initialized: PropTypes.object,
|
||||
onSelectChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Emails;
|
|
@ -1,152 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
} from 'common';
|
||||
import { whitelistSubscribeToBeta } from 'actions/whitelist';
|
||||
import EmailSelect from './emailSelect';
|
||||
import validate from './validations';
|
||||
import css from './styles.css';
|
||||
|
||||
const { array, func, object } = PropTypes;
|
||||
|
||||
const dispatcher = {
|
||||
whitelistSubscribeToBeta,
|
||||
};
|
||||
|
||||
const fields = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'company',
|
||||
'email',
|
||||
];
|
||||
|
||||
const mapStateToProps = ({ account }, props) => {
|
||||
const initialValues = {
|
||||
email: props && props.emails && props.emails[0].email,
|
||||
};
|
||||
return {
|
||||
user: account && account.currentUser,
|
||||
initialValues,
|
||||
};
|
||||
};
|
||||
|
||||
class BetaForm extends Component {
|
||||
|
||||
static propTypes = {
|
||||
user: object,
|
||||
emails: array.isRequired,
|
||||
fields: object.isRequired,
|
||||
onSuccess: func.isRequired,
|
||||
handleSubmit: func.isRequired,
|
||||
whitelistSubscribeToBeta: func.isRequired,
|
||||
}
|
||||
|
||||
state = {
|
||||
inProgress: false,
|
||||
}
|
||||
|
||||
onSelectChange = (field) => (data) => {
|
||||
const fieldObject = this.props.fields[field];
|
||||
fieldObject.onChange(data.value);
|
||||
}
|
||||
|
||||
onSubmit = (values) => {
|
||||
this.setState({ inProgress: true });
|
||||
// submit form data to api backend
|
||||
const {
|
||||
whitelistSubscribeToBeta: subscribeToBeta,
|
||||
onSuccess,
|
||||
} = this.props;
|
||||
const {
|
||||
firstName: first_name,
|
||||
lastName: last_name,
|
||||
company,
|
||||
email,
|
||||
} = values;
|
||||
subscribeToBeta({
|
||||
first_name,
|
||||
last_name,
|
||||
company,
|
||||
email,
|
||||
}).then(() => {
|
||||
onSuccess(values);
|
||||
this.setState({ inProgress: false });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
emails,
|
||||
fields: propFields,
|
||||
handleSubmit,
|
||||
} = this.props;
|
||||
const {
|
||||
inProgress,
|
||||
} = this.state;
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
} = propFields;
|
||||
const submitText = inProgress ? 'Submitting...' : 'Request Beta Access';
|
||||
const emailsArray = emails && emails.map(email => email.email);
|
||||
return (
|
||||
<form
|
||||
key="beta-form"
|
||||
className={css.main}
|
||||
onSubmit={handleSubmit(this.onSubmit)}
|
||||
>
|
||||
<Input
|
||||
{...firstName}
|
||||
className={css.input}
|
||||
id={'first'}
|
||||
placeholder="First Name"
|
||||
inputStyle={{ color: 'white', width: '100%' }}
|
||||
underlineFocusStyle={{ borderColor: 'white' }}
|
||||
errorText={firstName.touched && firstName.error}
|
||||
/>
|
||||
<Input
|
||||
{...lastName}
|
||||
className={css.input}
|
||||
id={'last'}
|
||||
placeholder="Last Name"
|
||||
inputStyle={{ color: 'white', width: '100%' }}
|
||||
underlineFocusStyle={{ borderColor: 'white' }}
|
||||
errorText={lastName.touched && lastName.error}
|
||||
/>
|
||||
<Input
|
||||
{...company}
|
||||
className={css.input}
|
||||
id={'company'}
|
||||
placeholder="Company"
|
||||
inputStyle={{ color: 'white', width: '100%' }}
|
||||
underlineFocusStyle={{ borderColor: 'white' }}
|
||||
errorText={company.touched && company.error}
|
||||
/>
|
||||
<EmailSelect
|
||||
accountEmails={emailsArray}
|
||||
fields={propFields}
|
||||
onSelectChange={this.onSelectChange('email')}
|
||||
/>
|
||||
<Button
|
||||
disabled={inProgress}
|
||||
className={css.submit}
|
||||
inverted type="submit"
|
||||
>
|
||||
{submitText}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default reduxForm({
|
||||
form: 'betaForm',
|
||||
fields,
|
||||
validate,
|
||||
},
|
||||
mapStateToProps,
|
||||
dispatcher,
|
||||
)(BetaForm);
|
|
@ -1,15 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.main {
|
||||
@mixin clearfix;
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-bottom: $space-lg;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit {
|
||||
cursor: pointer;
|
||||
margin: $space-xs 0;
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
export default (values) => {
|
||||
const errors = {};
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
} = values;
|
||||
if (!firstName) {
|
||||
errors.firstName = 'Required';
|
||||
}
|
||||
if (!lastName) {
|
||||
errors.lastName = 'Required';
|
||||
}
|
||||
if (!company) {
|
||||
errors.company = 'Required';
|
||||
}
|
||||
return errors;
|
||||
};
|
|
@ -1,210 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import isStaging from 'lib/utils/isStaging';
|
||||
import isDev from 'lib/utils/isDevelopment';
|
||||
import css from './styles.css';
|
||||
import LoginForm from 'common/LoginForm';
|
||||
import BetaForm from './BetaForm';
|
||||
import {
|
||||
accountFetchCurrentUser,
|
||||
accountFetchUserEmails,
|
||||
} from 'actions/account';
|
||||
import {
|
||||
whitelistAmIWaiting,
|
||||
whitelistFetchAuthorization,
|
||||
} from 'actions/whitelist';
|
||||
import routes from 'lib/constants/routes';
|
||||
import { readCookie } from 'lib/utils/cookie-handler';
|
||||
import DDCBanner from 'components/Home/DDCBanner';
|
||||
import HelpArticlesCards from 'components/Home/HelpArticlesCards';
|
||||
const { array, bool, func, object, shape } = PropTypes;
|
||||
|
||||
const mapStateToProps = ({ account }) => {
|
||||
const { currentUser, isCurrentUserBetalisted, userEmails } = account;
|
||||
return {
|
||||
currentUser: currentUser || {},
|
||||
isCurrentUserBetalisted,
|
||||
userEmails,
|
||||
};
|
||||
};
|
||||
|
||||
const dispatcher = {
|
||||
accountFetchCurrentUser,
|
||||
accountFetchUserEmails,
|
||||
whitelistAmIWaiting,
|
||||
whitelistFetchAuthorization,
|
||||
};
|
||||
|
||||
@connect(mapStateToProps, dispatcher)
|
||||
export default class Beta extends Component {
|
||||
static propTypes = {
|
||||
currentUser: object.isRequired,
|
||||
isCurrentUserBetalisted: bool.isRequired,
|
||||
location: object.isRequired,
|
||||
userEmails: shape({
|
||||
results: array,
|
||||
}),
|
||||
accountFetchCurrentUser: func.isRequired,
|
||||
accountFetchUserEmails: func.isRequired,
|
||||
whitelistAmIWaiting: func.isRequired,
|
||||
whitelistFetchAuthorization: func.isRequired,
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
router: shape({
|
||||
push: func.isRequired,
|
||||
}).isRequired,
|
||||
}
|
||||
|
||||
state = { error: '' }
|
||||
|
||||
onError = (error) => {
|
||||
this.setState({ error });
|
||||
}
|
||||
|
||||
maybeRenderError() {
|
||||
const { error } = this.state;
|
||||
return error ? <div className={css.error}>{error}</div> : null;
|
||||
}
|
||||
|
||||
betaSuccess = (values) => {
|
||||
// onSuccess of beta signup submission
|
||||
// We need to fetch whether a user has access to the whitelist
|
||||
const { currentUser } = this.props;
|
||||
this.props.whitelistAmIWaiting();
|
||||
analytics.identify(currentUser.id, {
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
company: values.company,
|
||||
}, () => {
|
||||
analytics.track('Signed Up for Private Beta');
|
||||
});
|
||||
};
|
||||
|
||||
loginSuccess = () => {
|
||||
analytics.track('Logged In on Beta Page');
|
||||
|
||||
// If user is on the whitelist then redirect them to the landing page
|
||||
this.props.whitelistFetchAuthorization().then(() => {
|
||||
this.context.router.replace({ pathname: routes.home() });
|
||||
});
|
||||
|
||||
this.props.accountFetchCurrentUser().then(user => {
|
||||
const username = user.username;
|
||||
this.props.whitelistAmIWaiting();
|
||||
this.props.accountFetchUserEmails({ user: username }).then(emails => {
|
||||
// If user is already authorized, send them to the landing page
|
||||
|
||||
const primaryEmails = emails &&
|
||||
emails.results &&
|
||||
emails.results.filter(r => r.primary);
|
||||
const primaryEmail = primaryEmails &&
|
||||
primaryEmails.length &&
|
||||
primaryEmails[0].email;
|
||||
|
||||
if (user && primaryEmail) {
|
||||
analytics.identify(user.id, {
|
||||
Docker_Hub_User_Name__c: user.username,
|
||||
username: user.username,
|
||||
dockerUUID: user.id,
|
||||
point_of_entry: 'docker_store',
|
||||
email: primaryEmail,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderLogin() {
|
||||
const endpoint = '/v2/users/login/';
|
||||
let registerUrl = 'https://cloud.docker.com/';
|
||||
let forgotPasswordUrl = 'https://cloud.docker.com/reset-password';
|
||||
if (isStaging() || isDev()) {
|
||||
registerUrl = 'https://cloud-stage.docker.com/';
|
||||
forgotPasswordUrl = 'https://cloud-stage.docker.com/reset-password';
|
||||
}
|
||||
return (
|
||||
<div className={css.login}>
|
||||
<div className={css.subText}>
|
||||
Login with your Docker ID to access the Beta
|
||||
</div>
|
||||
{this.maybeRenderError()}
|
||||
<div className={css.form} key="login-sign-in">
|
||||
<LoginForm
|
||||
autoFocus
|
||||
csrftoken={readCookie('csrftoken')}
|
||||
endpoint={endpoint}
|
||||
onSuccess={this.loginSuccess}
|
||||
onError={this.onError}
|
||||
/>
|
||||
<div className={css.signupFlow}>
|
||||
<a href={forgotPasswordUrl}>Forgot Password?</a> |
|
||||
<a href={registerUrl}>Create an account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderBetaSignup() {
|
||||
const { userEmails } = this.props;
|
||||
return (
|
||||
<div className={css.beta}>
|
||||
<div className={css.subText}>
|
||||
Currently Beta is invite-only.
|
||||
Fill in the form below to request your invite.
|
||||
</div>
|
||||
<div className={css.form} key="beta-signup">
|
||||
<BetaForm
|
||||
onSuccess={this.betaSuccess}
|
||||
emails={userEmails.results}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderHero() {
|
||||
const {
|
||||
currentUser,
|
||||
isCurrentUserBetalisted,
|
||||
userEmails,
|
||||
} = this.props;
|
||||
let form;
|
||||
const isLoggedIn = currentUser && currentUser.id || false;
|
||||
if (isLoggedIn && !!userEmails.results && userEmails.results.length > 0) {
|
||||
if (isCurrentUserBetalisted) {
|
||||
form = (
|
||||
<div className={css.betaThanks}>
|
||||
Thank you! We've received your request and we'll email you
|
||||
when you are accepted to the Beta.
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
form = this.renderBetaSignup();
|
||||
}
|
||||
} else {
|
||||
form = this.renderLogin();
|
||||
}
|
||||
return (
|
||||
<div className={css.heroWrapper}>
|
||||
<div className="wrapped">
|
||||
<div className={css.heroContent}>
|
||||
<div className={css.title}>Docker Store Beta</div>
|
||||
{form}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={css.home}>
|
||||
{this.renderHero()}
|
||||
<DDCBanner isBetaPage />
|
||||
<HelpArticlesCards />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
$hero-height: 550px;
|
||||
$featured-height: 250px;
|
||||
$section-padding: 64px;
|
||||
$color-ddc-background: #1b4255;
|
||||
|
||||
/* Hero and NavBar */
|
||||
.home {
|
||||
top: calc($topnav-height * -1);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroWrapper {
|
||||
min-height: $hero-height;
|
||||
position: relative;
|
||||
padding: $topnav-height 0;
|
||||
|
||||
/* set fallback in case multiple background images are not supported */
|
||||
background-color: $color-dodger-blue;
|
||||
background-image:
|
||||
linear-gradient(to bottom, rgba(0, 191, 165, 0.8) 0%, transparent 100%),
|
||||
linear-gradient(-74deg, transparent 90%, rgba(255, 255, 255, 0.23) 20%),
|
||||
linear-gradient(-74deg, transparent 83%, rgba(255, 255, 255, 0.18) 15%),
|
||||
linear-gradient(-74deg, transparent 76%, rgba(255, 255, 255, 0.1) 15%),
|
||||
linear-gradient(to top, #127ab1, #1799e0, #1796db);
|
||||
}
|
||||
|
||||
.title {
|
||||
@mixin display2;
|
||||
@mixin semiBold;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navBar {
|
||||
display: flex;
|
||||
height: $topnav-height;
|
||||
color: $color-white;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.rightNavLinks {
|
||||
@mixin fontSize 2;
|
||||
display: inline-flex;
|
||||
|
||||
& > * {
|
||||
margin-right: $space-sm;
|
||||
margin-left: $space-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
@mixin flexCentered;
|
||||
height: calc($hero-height - $topnav-height);
|
||||
margin-top: $topnav-height;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
color: $color-white;
|
||||
}
|
||||
|
||||
.subText {
|
||||
@mixin subhead;
|
||||
margin-bottom: $space-xxxl;
|
||||
}
|
||||
|
||||
.login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
width: $input-width-large;
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
.signupFlow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: $space-xs 0;
|
||||
}
|
||||
}
|
||||
|
||||
.betaThanks {
|
||||
width: $input-width-xlarge;
|
||||
margin: $space-xl 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
@mixin fontSize 2;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
background: $color-variant-panic;
|
||||
padding: $space-sm $space-xs;
|
||||
}
|
||||
|
||||
/* Content sections */
|
||||
.sectionWrapper {
|
||||
padding-bottom: $section-padding;
|
||||
& > div {
|
||||
padding-top: $section-padding;
|
||||
}
|
||||
}
|
||||
|
||||
/* Help Articles */
|
||||
.articles {
|
||||
@mixin 2columnsResponsive 4, 8;
|
||||
}
|
||||
|
||||
.helpArticlesCards {
|
||||
align-self: stretch;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.helpArticlesHeadline {
|
||||
@mixin fontSize 6;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.helpArticlesDescription {
|
||||
@mixin fontSize 2;
|
||||
margin-bottom: $space-xl;
|
||||
}
|
|
@ -1,167 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import css from './styles.css';
|
||||
import {
|
||||
ApplicationServicesIcon,
|
||||
DatabasesIcon,
|
||||
MessagingServicesIcon,
|
||||
OperatingSystemsIcon,
|
||||
ProgrammingLanguagesIcon,
|
||||
StorageIcon,
|
||||
AnalyticsIcon,
|
||||
// ApplicationFrameworkIcon,
|
||||
InfrastructureIcon,
|
||||
BaseImagesIcon,
|
||||
FeaturedImagesIcon,
|
||||
ToolsIcon,
|
||||
} from 'common';
|
||||
import {
|
||||
ANALYTICS_CATEGORY,
|
||||
APPLICATION_FRAMEWORK_CATEGORY,
|
||||
APPLICATION_INFRASTRUCTURE_CATEGORY,
|
||||
APPLICATION_SERVICES_CATEGORY,
|
||||
BASE_CATEGORY,
|
||||
DATABASE_CATEGORY,
|
||||
FEATURED_CATEGORY,
|
||||
LANGUAGES_CATEGORY,
|
||||
MESSAGING_CATEGORY,
|
||||
OS_CATEGORY,
|
||||
STORAGE_CATEGORY,
|
||||
TOOLS_CATEGORY,
|
||||
} from 'lib/constants/landingPage';
|
||||
import ChevronArrows from '../ChevronArrows';
|
||||
import findKey from 'lodash/findKey';
|
||||
const { func, object } = PropTypes;
|
||||
const noOp = () => {};
|
||||
|
||||
export default class CategoryCards extends Component {
|
||||
static propTypes = {
|
||||
categories: object.isRequired,
|
||||
categoryDescriptions: object.isRequired,
|
||||
goToCategory: func.isRequired,
|
||||
}
|
||||
|
||||
state = {
|
||||
firstCategoryIndex: 0,
|
||||
}
|
||||
|
||||
setFirstCategoryIndex = (i) => () => {
|
||||
this.setState({
|
||||
firstCategoryIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
getIcon = (category) => {
|
||||
switch (category.name) {
|
||||
case ANALYTICS_CATEGORY:
|
||||
return <AnalyticsIcon className={css.icon} />;
|
||||
// TODO Get real icon for framework
|
||||
case APPLICATION_FRAMEWORK_CATEGORY:
|
||||
return <ApplicationServicesIcon className={css.icon} />;
|
||||
case APPLICATION_INFRASTRUCTURE_CATEGORY:
|
||||
return <InfrastructureIcon className={css.icon} />;
|
||||
case APPLICATION_SERVICES_CATEGORY:
|
||||
return <ApplicationServicesIcon className={css.icon} />;
|
||||
case BASE_CATEGORY:
|
||||
return <BaseImagesIcon className={css.icon} />;
|
||||
case DATABASE_CATEGORY:
|
||||
return <DatabasesIcon className={css.icon} />;
|
||||
case FEATURED_CATEGORY:
|
||||
return <FeaturedImagesIcon className={css.icon} />;
|
||||
case LANGUAGES_CATEGORY:
|
||||
return <ProgrammingLanguagesIcon className={css.icon} />;
|
||||
case MESSAGING_CATEGORY:
|
||||
return <MessagingServicesIcon className={css.icon} />;
|
||||
case OS_CATEGORY:
|
||||
return <OperatingSystemsIcon className={css.icon} />;
|
||||
case STORAGE_CATEGORY:
|
||||
return <StorageIcon className={css.icon} />;
|
||||
case TOOLS_CATEGORY:
|
||||
return <ToolsIcon className={css.icon} />;
|
||||
default:
|
||||
return <OperatingSystemsIcon className={css.icon} />;
|
||||
}
|
||||
}
|
||||
|
||||
renderCard = (category, index) => {
|
||||
const { categories, goToCategory } = this.props;
|
||||
const { name, description } = category;
|
||||
const categoryName = findKey(categories, (label) => label === name);
|
||||
const backgroundClass = `card${index}`;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`${css.card} ${css[backgroundClass]}`}
|
||||
onClick={goToCategory(categoryName)}
|
||||
>
|
||||
<div className={css.iconWrapper}>
|
||||
{this.getIcon(category)}
|
||||
</div>
|
||||
<div className={css.cardContent}>
|
||||
<div className={css.cardTitle}>{name}</div>
|
||||
<div className={css.cardDescription}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
analytics,
|
||||
application_framework,
|
||||
application_services,
|
||||
application_infrastructure,
|
||||
base,
|
||||
database,
|
||||
featured,
|
||||
languages,
|
||||
messaging,
|
||||
os,
|
||||
storage,
|
||||
tools,
|
||||
} = this.props.categoryDescriptions;
|
||||
const categories = [
|
||||
featured,
|
||||
database,
|
||||
base,
|
||||
languages,
|
||||
application_services,
|
||||
messaging,
|
||||
os,
|
||||
analytics,
|
||||
application_framework,
|
||||
storage,
|
||||
tools,
|
||||
application_infrastructure,
|
||||
];
|
||||
const numCategories = categories.length;
|
||||
const { firstCategoryIndex } = this.state;
|
||||
const numShowing = 6;
|
||||
const lastCategoryIndex = firstCategoryIndex + numShowing - 1;
|
||||
const displayCategories =
|
||||
categories.slice(firstCategoryIndex, lastCategoryIndex + 1);
|
||||
const isPreviousDisabled = firstCategoryIndex === 0;
|
||||
const isNextDisabled = lastCategoryIndex === numCategories - 1;
|
||||
const onClickNext = isNextDisabled ?
|
||||
noOp : this.setFirstCategoryIndex(lastCategoryIndex + 1);
|
||||
const onClickPrevious = isPreviousDisabled ?
|
||||
noOp : this.setFirstCategoryIndex(firstCategoryIndex - numShowing);
|
||||
return (
|
||||
<div>
|
||||
<div className={css.sectionTitleWrapper}>
|
||||
<div className={css.sectionTitle}>
|
||||
Everything you need to build applications for your business
|
||||
</div>
|
||||
<ChevronArrows
|
||||
isNextDisabled={isNextDisabled}
|
||||
isPreviousDisabled={isPreviousDisabled}
|
||||
onClickNext={onClickNext}
|
||||
onClickPrevious={onClickPrevious}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.categoryCards}>
|
||||
{displayCategories.map(this.renderCard)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
$category-icon-size: 100px;
|
||||
|
||||
.sectionTitle {
|
||||
@mixin fontSize 7;
|
||||
}
|
||||
|
||||
.sectionTitleWrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $space-xl;
|
||||
}
|
||||
|
||||
.categoryCards {
|
||||
@mixin 3columnsResponsive;
|
||||
}
|
||||
|
||||
.card {
|
||||
color: $color-white;
|
||||
border-radius: $corner-size;
|
||||
height: 240px;
|
||||
margin-top: $space-sm;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
position: absolute;
|
||||
top: $space-xxxl;
|
||||
left: 90px;
|
||||
right: 54px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@mixin square $category-icon-size;
|
||||
fill: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: calc($category-icon-size * -0.2);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
@mixin fontSize 7;
|
||||
font-weight: bold;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
@mixin fontSize 2;
|
||||
}
|
||||
|
||||
.card0 {
|
||||
background-color: #8460ff;
|
||||
}
|
||||
|
||||
.card1 {
|
||||
background-color: #ff4081;
|
||||
}
|
||||
|
||||
.card2 {
|
||||
background-color: $color-fiord;
|
||||
}
|
||||
|
||||
.card3 {
|
||||
background-color: $color-dodger-blue;
|
||||
}
|
||||
|
||||
.card4 {
|
||||
background-color: $color-robins-egg-blue;
|
||||
}
|
||||
|
||||
.card5 {
|
||||
background-color: $color-koromiko;
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import css from './styles.css';
|
||||
import { ChevronIcon } from 'common';
|
||||
import classnames from 'classnames';
|
||||
import { LARGE } from 'lib/constants/sizes';
|
||||
const { bool, func } = PropTypes;
|
||||
|
||||
export default class ChevronArrows extends Component {
|
||||
static propTypes = {
|
||||
isNextDisabled: bool,
|
||||
isPreviousDisabled: bool,
|
||||
onClickNext: func.isRequired,
|
||||
onClickPrevious: func.isRequired,
|
||||
}
|
||||
|
||||
_onClickNext = () => {
|
||||
if (!this.props.isNextDisabled) {
|
||||
this.props.onClickNext();
|
||||
}
|
||||
}
|
||||
|
||||
_onClickPrevious = () => {
|
||||
if (!this.props.isPreviousDisabled) {
|
||||
this.props.onClickPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isNextDisabled, isPreviousDisabled } = this.props;
|
||||
const nextClasses = classnames({
|
||||
[css.icon]: true,
|
||||
[css.disabled]: isNextDisabled,
|
||||
});
|
||||
const previousClasses = classnames({
|
||||
[css.icon]: true,
|
||||
[css.disabled]: isPreviousDisabled,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<div className={css.previous} onClick={this._onClickPrevious}>
|
||||
<ChevronIcon className={previousClasses} size={LARGE} />
|
||||
</div>
|
||||
<div className={css.next} onClick={this._onClickNext}>
|
||||
<ChevronIcon className={nextClasses} size={LARGE} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.icon {
|
||||
fill: $color-fiord;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
fill: $color-loblolly;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.previous,
|
||||
.next {
|
||||
@mixin square $space-xxxl;
|
||||
@mixin flexCentered;
|
||||
color: $color-regent-gray;
|
||||
display: inline-flex;
|
||||
background-color: $color-white;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background-color: $color-athens-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.previous {
|
||||
box-shadow:
|
||||
inset 0 -1px 0 0 #e0e4e7,
|
||||
inset 1px 0 0 0 rgba(224, 227, 231, 0.6);
|
||||
}
|
||||
|
||||
.next {
|
||||
box-shadow:
|
||||
inset -1px 0 0 0 rgba(224, 227, 231, 0.6),
|
||||
inset 0 -1px 0 0 #e0e4e7,
|
||||
inset 1px 0 0 0 rgba(224, 227, 231, 0.6);
|
||||
}
|
||||
|
||||
.previous > svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.next > svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import css from './styles.css';
|
||||
import { AngledTitleBox } from 'common';
|
||||
import { DDC } from 'lib/constants/landingPage';
|
||||
import { DDC_ID, DDC_TRIAL_PLAN } from 'lib/constants/eusa';
|
||||
import routes from 'lib/constants/routes';
|
||||
const { bool } = PropTypes;
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export default class DDCBanner extends Component {
|
||||
static propTypes = {
|
||||
isBetaPage: bool,
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isBetaPage } = this.props;
|
||||
const { title, DTR, UCP, CS } = DDC;
|
||||
const ddcDetail = routes.bundleDetail({ id: DDC_ID });
|
||||
const ddcPurchase = routes.bundleDetailPurchase({ id: DDC_ID });
|
||||
const ddcTrial = `${ddcPurchase}?plan=${DDC_TRIAL_PLAN}`;
|
||||
let featuredContent;
|
||||
if (isBetaPage) {
|
||||
featuredContent = (
|
||||
<AngledTitleBox
|
||||
title="Featured Docker Solution"
|
||||
className={css.titleBox}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const ddcWrapperClass = isBetaPage ? css.ddcWrapperBeta : css.ddcWrapper;
|
||||
const ddcSectionClass = isBetaPage ? css.ddcSectionBeta : css.ddcSection;
|
||||
const buttonClass = isBetaPage ? css.buttonBeta : css.button;
|
||||
return (
|
||||
<div className={ddcWrapperClass}>
|
||||
<div className="wrapped">
|
||||
{featuredContent}
|
||||
<div className={ddcSectionClass}>
|
||||
<div>
|
||||
<div className={css.titleAndButtons}>
|
||||
<div className={css.DDCtitle}>{title}</div>
|
||||
<Link to={ddcTrial} className={buttonClass}>
|
||||
30 Day FREE Evaluation
|
||||
</Link>
|
||||
<Link to={ddcDetail} className={buttonClass}>
|
||||
Get Datacenter
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.DDCComponents}>
|
||||
<div className={css.DDC}>
|
||||
<div className={css.DDCName}>{DTR.name}</div>
|
||||
<div className={css.DDCDescription}>{DTR.description}</div>
|
||||
</div>
|
||||
<div className={css.DDC}>
|
||||
<div className={css.DDCName}>{UCP.name}</div>
|
||||
<div className={css.DDCDescription}>{UCP.description}</div>
|
||||
</div>
|
||||
<div className={css.DDC}>
|
||||
<div className={css.DDCName}>{CS.name}</div>
|
||||
<div className={css.DDCDescription}>{CS.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
/* eslint-enable */
|
|
@ -1,89 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
$color-ddc-background: #1b4255;
|
||||
|
||||
/* DDC */
|
||||
.ddcWrapper {
|
||||
background-color: $color-ddc-background;
|
||||
color: $color-white;
|
||||
}
|
||||
|
||||
.ddcWrapperBeta {
|
||||
background-color: $color-white;
|
||||
color: $color-fiord;
|
||||
}
|
||||
|
||||
.ddcSection,
|
||||
.ddcSectionBeta {
|
||||
@mixin 2columnsResponsive 4.5, 7.5;
|
||||
}
|
||||
|
||||
.ddcSection {
|
||||
padding-top: $space-xxxxl;
|
||||
padding-bottom: $space-xxxxl;
|
||||
}
|
||||
|
||||
.ddcSectionBeta {
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.titleBox {
|
||||
padding-top: 64px;
|
||||
padding-bottom: $space-xl;
|
||||
}
|
||||
|
||||
.titleAndButtons {
|
||||
padding-bottom: $space-lg;
|
||||
|
||||
@media (min-width: $screen-md) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.DDCComponents {
|
||||
@mixin 3columns 4, 4, 4;
|
||||
}
|
||||
|
||||
.DDC {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.DDCName {
|
||||
@mixin fontSize 4;
|
||||
margin-bottom: $space-xl;
|
||||
}
|
||||
|
||||
.DDCDescription {
|
||||
@mixin fontSize 1;
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.DDCtitle {
|
||||
@mixin fontSize 7;
|
||||
margin-bottom: $space-xl;
|
||||
width: 80%;
|
||||
& > a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.button,
|
||||
.buttonBeta {
|
||||
@mixin fontSize 2;
|
||||
@mixin semiBold;
|
||||
text-decoration: none;
|
||||
padding: $space-sm $space-md;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
border-radius: $corner-size;
|
||||
margin-right: $space-sm;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 2px solid $color-white;
|
||||
}
|
||||
|
||||
.buttonBeta {
|
||||
color: $color-dodger-blue;
|
||||
border: 2px solid $color-dodger-blue;
|
||||
}
|
|
@ -1,99 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import css from './styles.css';
|
||||
import { AngledTitleBox } from 'common';
|
||||
import ChevronArrows from '../ChevronArrows';
|
||||
import ImageSearchResult from 'marketplace/Search/ImageSearchResult';
|
||||
const { array, bool, object, string } = PropTypes;
|
||||
import classnames from 'classnames';
|
||||
const noOp = () => {};
|
||||
|
||||
export default class FeaturedContentRow extends Component {
|
||||
static propTypes = {
|
||||
className: string,
|
||||
description: string.isRequired,
|
||||
headline: string.isRequired,
|
||||
images: array.isRequired,
|
||||
isFetching: bool,
|
||||
location: object.isRequired,
|
||||
title: string.isRequired,
|
||||
}
|
||||
|
||||
state = {
|
||||
firstImageIndex: 0,
|
||||
}
|
||||
|
||||
setFirstImageIndex = (i) => () => {
|
||||
this.setState({
|
||||
firstImageIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
renderImage = (image) => {
|
||||
const { location } = this.props;
|
||||
return (
|
||||
<ImageSearchResult
|
||||
key={image.id}
|
||||
className={css.imageWrapper}
|
||||
image={image}
|
||||
location={location}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
description,
|
||||
headline,
|
||||
images = [],
|
||||
title,
|
||||
} = this.props;
|
||||
const { firstImageIndex } = this.state;
|
||||
const numImages = images.length;
|
||||
|
||||
let numShowing = 3;
|
||||
if (typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia !== 'undefined') {
|
||||
// Reduce the amount of results showing on a small screen
|
||||
const isMedium = !window.matchMedia('(min-width: 960px)').matches;
|
||||
const isSmall = !window.matchMedia('(min-width: 820px)').matches;
|
||||
if (isSmall) {
|
||||
numShowing = 1;
|
||||
} else if (isMedium) {
|
||||
numShowing = 2;
|
||||
}
|
||||
}
|
||||
const lastImageIndex = firstImageIndex + numShowing - 1;
|
||||
const displayImages = images.slice(firstImageIndex, lastImageIndex + 1);
|
||||
// The first image showing is the first image available
|
||||
const isPreviousDisabled = firstImageIndex === 0;
|
||||
// The last image that could possibly be showing is the last image available
|
||||
// or there is a not full row
|
||||
const isNextDisabled = lastImageIndex >= numImages - 1;
|
||||
const onClickNext = isNextDisabled ?
|
||||
noOp : this.setFirstImageIndex(lastImageIndex + 1);
|
||||
const onClickPrevious = isPreviousDisabled ?
|
||||
noOp : this.setFirstImageIndex(firstImageIndex - numShowing);
|
||||
const classes = classnames({
|
||||
[css.contentRow]: true,
|
||||
[this.props.className]: !!this.props.className,
|
||||
});
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className={css.contentDescription}>
|
||||
<AngledTitleBox className={css.title} title={title} />
|
||||
<div className={css.headline}>{headline}</div>
|
||||
<div className={css.description}>{description}</div>
|
||||
<ChevronArrows
|
||||
isNextDisabled={isNextDisabled}
|
||||
isPreviousDisabled={isPreviousDisabled}
|
||||
onClickNext={onClickNext}
|
||||
onClickPrevious={onClickPrevious}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.images}>
|
||||
{displayImages.map(this.renderImage)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.contentRow {
|
||||
@mixin 2columns 3, 9;
|
||||
}
|
||||
|
||||
.images {
|
||||
lost-flex-container: row;
|
||||
justify-content: center;
|
||||
& > * {
|
||||
lost-column: 1;
|
||||
}
|
||||
|
||||
@media (min-width: $screen-md) {
|
||||
& > * {
|
||||
lost-column: 1/3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contentDescription {
|
||||
padding-right: $space-lg;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: $space-xl;
|
||||
}
|
||||
|
||||
.headline {
|
||||
@mixin fontSize 7;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.description {
|
||||
@mixin fontSize 2;
|
||||
margin-bottom: $space-xl;
|
||||
}
|
||||
|
||||
div.imageWrapper {
|
||||
padding-top: 0;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import css from './styles.css';
|
||||
import { Card, AngledTitleBox } from 'common';
|
||||
import { helpArticles } from 'lib/constants/landingPage';
|
||||
const {
|
||||
// small,
|
||||
// large,
|
||||
blogPost,
|
||||
} = helpArticles;
|
||||
|
||||
/* eslint-disable max-len */
|
||||
export default class HelpArticlesCards extends Component {
|
||||
// Two card article layout - leaving for when we switch back
|
||||
// render() {
|
||||
// return (
|
||||
// <div className={css.articles}>
|
||||
// <Card className={css.helpArticlesCards} shadow>
|
||||
// <AngledTitleBox title="How To" className={css.titleBox} />
|
||||
// <div className={css.helpArticlesHeadline}>{small.name}</div>
|
||||
// <div className={css.helpArticlesDescription}>
|
||||
// {small.description}
|
||||
// </div>
|
||||
// </Card>
|
||||
// <Card className={css.helpArticlesCards} shadow>
|
||||
// <AngledTitleBox title="Case Study" className={css.titleBox} />
|
||||
// <div className={css.helpArticlesHeadline}>{large.name}</div>
|
||||
// <div className={css.helpArticlesDescription}>
|
||||
// {large.description}
|
||||
// </div>
|
||||
// </Card>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
render() {
|
||||
const blogPostLink = 'https://blog.docker.com/2016/06/docker-store/';
|
||||
return (
|
||||
<div className={css.articles}>
|
||||
<Card className={css.helpArticlesCards} shadow>
|
||||
<AngledTitleBox title="Blog Post" className={css.titleBox} />
|
||||
<div className={css.helpArticlesHeadline}>{blogPost.name}</div>
|
||||
<div className={css.helpArticlesDescription}>
|
||||
{blogPost.description}
|
||||
</div>
|
||||
<a href={blogPostLink} target="_blank" className={css.link}>
|
||||
Read More
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
/* eslint-enable */
|
|
@ -1,32 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
/* Help Articles */
|
||||
.articles {
|
||||
@mixin wrapped;
|
||||
padding-top: 64px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.helpArticlesCards {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.titleBox {
|
||||
margin-bottom: $space-xl;
|
||||
}
|
||||
|
||||
.helpArticlesHeadline {
|
||||
@mixin fontSize 6;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.helpArticlesDescription {
|
||||
@mixin fontSize 2;
|
||||
margin-bottom: $space-xl;
|
||||
}
|
||||
|
||||
.link {
|
||||
@mixin fontSize 2;
|
||||
@mixin semiBold;
|
||||
color: $color-fiord;
|
||||
}
|
|
@ -1,147 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import css from './styles.css';
|
||||
import { AutocompleteSearchBar } from 'common';
|
||||
import {
|
||||
marketplaceFetchAutocompleteSuggestions,
|
||||
marketplaceSearch,
|
||||
} from 'actions/marketplace';
|
||||
import { rootChangeGlobalSearchValue } from 'actions/root';
|
||||
import formatCategories from 'lib/utils/format-categories';
|
||||
import routes from 'lib/constants/routes';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { DEFAULT_DEBOUNCE_TIME } from 'lib/constants/defaults';
|
||||
const { array, bool, func, object, shape } = PropTypes;
|
||||
|
||||
const mapStateToProps = ({ root }) => {
|
||||
const { autocomplete } = root;
|
||||
return { autocomplete };
|
||||
};
|
||||
|
||||
const dispatcher = {
|
||||
marketplaceFetchAutocompleteSuggestions,
|
||||
marketplaceSearch,
|
||||
rootChangeGlobalSearchValue,
|
||||
};
|
||||
|
||||
/*
|
||||
* Search to be used in the LandingPage with the same autocomplete functionality
|
||||
* as the global search bar
|
||||
*/
|
||||
@connect(mapStateToProps, dispatcher)
|
||||
export default class SearchWithAutocomplete extends Component {
|
||||
static propTypes = {
|
||||
autocomplete: shape({
|
||||
isFetching: bool,
|
||||
suggestions: array,
|
||||
}),
|
||||
location: object.isRequired,
|
||||
marketplaceFetchAutocompleteSuggestions: func.isRequired,
|
||||
marketplaceSearch: func.isRequired,
|
||||
rootChangeGlobalSearchValue: func.isRequired,
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
router: shape({
|
||||
push: func.isRequired,
|
||||
}).isRequired,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
// Make sure there is one debounce function per component instance
|
||||
// http://stackoverflow.com/a/28046731/5965502
|
||||
this.debouncedFetchAutocompleteSuggestions = debounce(
|
||||
this.debouncedFetchAutocompleteSuggestions,
|
||||
DEFAULT_DEBOUNCE_TIME
|
||||
);
|
||||
}
|
||||
|
||||
state = {
|
||||
searchQuery: '',
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.debouncedFetchAutocompleteSuggestions.cancel();
|
||||
}
|
||||
|
||||
onSearchQueryChange = (e, value) => {
|
||||
// Remove this synthetic event from the pool so that we can still access the
|
||||
// event asyncronously (for debouncing)
|
||||
// https://facebook.github.io/react/docs/events.html#event-pooling
|
||||
e.persist();
|
||||
// change the value showing in the search bar
|
||||
this.setState({ searchQuery: value });
|
||||
// fetch new suggestions (debounced)
|
||||
this.debouncedFetchAutocompleteSuggestions(e, value);
|
||||
}
|
||||
|
||||
onSelectAutosuggestItem = (value, item) => {
|
||||
const { id } = item;
|
||||
// Jump to the product detail page for this result
|
||||
const detail = routes.imageDetail({ id });
|
||||
this.context.router.push(detail);
|
||||
}
|
||||
|
||||
debouncedFetchAutocompleteSuggestions = (e, value) => {
|
||||
this.props.marketplaceFetchAutocompleteSuggestions({ q: value });
|
||||
}
|
||||
|
||||
// Search Bar form has been submitted
|
||||
search = (q) => {
|
||||
// change the value in the global search bar
|
||||
// this.props.rootChangeGlobalSearchValue({ value });
|
||||
// fire search action and transition to search results page
|
||||
this.props.marketplaceSearch({ q });
|
||||
const pathname = routes.search();
|
||||
const { state } = this.props.location;
|
||||
// search from global search bar will have a query (q) (no page num or size)
|
||||
const query = { q };
|
||||
this.context.router.push({ pathname, query, state });
|
||||
}
|
||||
|
||||
renderAutocompleteItem = (item, isHighlighted) => {
|
||||
const { id, name, categories } = item;
|
||||
const catNames = formatCategories(categories);
|
||||
const catText = catNames ? ` in ${catNames}` : '';
|
||||
const itemClass = isHighlighted ? css.highlightedResult : css.result;
|
||||
return (
|
||||
<div className={itemClass} key={id} id={id}>
|
||||
<span className={css.resultName}>{name}</span>
|
||||
<span className={css.resultCategories}>{catText}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { autocomplete } = this.props;
|
||||
const { searchQuery } = this.state;
|
||||
const { suggestions = [] } = autocomplete;
|
||||
const menuTitle = (
|
||||
<div className={css.menuTitle}>
|
||||
Suggested Results
|
||||
</div>
|
||||
);
|
||||
const getItemValue = (item) => item.id;
|
||||
const classNames = {
|
||||
icon: css.icon,
|
||||
input: css.input,
|
||||
wrapper: css.wrapper,
|
||||
};
|
||||
return (
|
||||
<AutocompleteSearchBar
|
||||
classNames={classNames}
|
||||
getItemValue={getItemValue}
|
||||
id="landingPage-search"
|
||||
items={suggestions}
|
||||
menuTitle={menuTitle}
|
||||
onChange={this.onSearchQueryChange}
|
||||
onSelect={this.onSelectAutosuggestItem}
|
||||
onSubmit={this.search}
|
||||
ref="autocomplete"
|
||||
renderItem={this.renderAutocompleteItem}
|
||||
value={searchQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
/* Search Bar */
|
||||
$search-bar-height: 58px;
|
||||
|
||||
/* SVG size SMALL */
|
||||
$icon-offset: calc(($search-bar-height - $icon-size-small) / 2);
|
||||
|
||||
.wrapper {
|
||||
width: 60%;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
fill: $color-regent-gray;
|
||||
position: absolute;
|
||||
top: $icon-offset;
|
||||
left: $icon-offset;
|
||||
}
|
||||
|
||||
.input {
|
||||
@mixin fontSize 4;
|
||||
text-indent: calc($icon-offset + $space-sm);
|
||||
height: $search-bar-height;
|
||||
color: $color-regent-gray;
|
||||
}
|
||||
|
||||
/* Search Autocomplete Results */
|
||||
.result {
|
||||
padding: $space-sm;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.highlightedResult {
|
||||
padding: $space-sm;
|
||||
cursor: default;
|
||||
background: $color-variant-light-shade-hover;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.menuTitle {
|
||||
@mixin fontSize 2;
|
||||
font-weight: 600;
|
||||
padding: $space-md $space-sm;
|
||||
cursor: default;
|
||||
color: $color-fiord;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.resultName {
|
||||
@mixin fontSize 2;
|
||||
color: $color-fiord;
|
||||
}
|
||||
|
||||
.resultCategories {
|
||||
@mixin fontSize 1;
|
||||
color: $color-regent-gray;
|
||||
}
|
|
@ -1,140 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import css from './styles.css';
|
||||
import { categoryDescriptions } from 'lib/constants/landingPage';
|
||||
import CategoryCards from './CategoryCards';
|
||||
import DDCBanner from './DDCBanner';
|
||||
import FeaturedContentRow from './FeaturedContentRow';
|
||||
import HelpArticlesCards from './HelpArticlesCards';
|
||||
import SearchWithAutocomplete from './SearchWithAutocomplete';
|
||||
import routes from 'lib/constants/routes';
|
||||
const { array, bool, func, object, shape } = PropTypes;
|
||||
|
||||
const mapStateToProps = ({ marketplace, root }) => {
|
||||
const { categories } = marketplace.filters;
|
||||
const { mostPopular, featured } = root.landingPage;
|
||||
return {
|
||||
categories,
|
||||
mostPopular,
|
||||
featured,
|
||||
};
|
||||
};
|
||||
|
||||
@connect(mapStateToProps)
|
||||
export default class Home extends Component {
|
||||
static propTypes = {
|
||||
autocomplete: shape({
|
||||
isFetching: bool,
|
||||
suggestions: array,
|
||||
}),
|
||||
categories: object.isRequired,
|
||||
location: object.isRequired,
|
||||
mostPopular: shape({
|
||||
isFetching: bool,
|
||||
images: array,
|
||||
}),
|
||||
featured: shape({
|
||||
isFetching: bool,
|
||||
images: array,
|
||||
}),
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
router: shape({
|
||||
push: func.isRequired,
|
||||
}).isRequired,
|
||||
}
|
||||
|
||||
goToCategory = (category) => () => {
|
||||
const query = { category };
|
||||
const { state } = this.props.location;
|
||||
const pathname = routes.search();
|
||||
this.context.router.push({ pathname, query, state });
|
||||
}
|
||||
|
||||
renderHero() {
|
||||
return (
|
||||
<div className={css.heroWrapper}>
|
||||
<div className="wrapped">
|
||||
<div className={css.heroContent}>
|
||||
<div className={css.title}>Search The Docker Store</div>
|
||||
<div className={css.subText}>
|
||||
Find Trusted and Enterprise Ready Containers
|
||||
</div>
|
||||
<SearchWithAutocomplete location={this.props.location} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderFeaturedRow1() {
|
||||
const { location, featured } = this.props;
|
||||
// TODO Kristie 7/18/16 Make this sound better :)
|
||||
const featuredHeadline = 'Featured Images';
|
||||
const featuredTitle = 'Image Spotlight';
|
||||
const featuredDescription =
|
||||
'Curated images, ready to use. Verified and secure.';
|
||||
return (
|
||||
<div className={`wrapped ${css.sectionWrapper}`}>
|
||||
<FeaturedContentRow
|
||||
description={featuredDescription}
|
||||
className={css.paddedRow}
|
||||
headline={featuredHeadline}
|
||||
images={featured.images}
|
||||
isFetching={featured.isFetching}
|
||||
location={location}
|
||||
title={featuredTitle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderFeaturedRow2() {
|
||||
const { location, mostPopular } = this.props;
|
||||
const popularHeadline = 'Most Popular Containers on the Store';
|
||||
const popularTitle = 'Most Popular';
|
||||
const popularDescription =
|
||||
'Most popular content from our trusted partners on the Docker Store';
|
||||
return (
|
||||
<div className={`wrapped ${css.sectionWrapper}`}>
|
||||
<FeaturedContentRow
|
||||
description={popularDescription}
|
||||
className={css.paddedRow}
|
||||
headline={popularHeadline}
|
||||
images={mostPopular.images}
|
||||
isFetching={mostPopular.isFetching}
|
||||
location={location}
|
||||
title={popularTitle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
renderCategoryCards() {
|
||||
const { categories } = this.props;
|
||||
return (
|
||||
<div className="wrapped">
|
||||
<CategoryCards
|
||||
categories={categories}
|
||||
categoryDescriptions={categoryDescriptions}
|
||||
goToCategory={this.goToCategory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={css.home}>
|
||||
{this.renderHero()}
|
||||
{this.renderFeaturedRow1()}
|
||||
<DDCBanner />
|
||||
{this.renderFeaturedRow2()}
|
||||
{this.renderCategoryCards()}
|
||||
<HelpArticlesCards />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
$hero-height: 500px;
|
||||
$section-padding: 64px;
|
||||
|
||||
/* Hero and NavBar */
|
||||
.home {
|
||||
top: calc($topnav-height * -1);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroWrapper {
|
||||
min-height: $hero-height;
|
||||
position: relative;
|
||||
padding: $topnav-height 0;
|
||||
|
||||
/* set fallback in case multiple background images are not supported */
|
||||
background-color: $color-dodger-blue;
|
||||
background-image:
|
||||
linear-gradient(to bottom, rgba(0, 191, 165, 0.8) 0%, transparent 100%),
|
||||
linear-gradient(-74deg, transparent 90%, rgba(255, 255, 255, 0.23) 20%),
|
||||
linear-gradient(-74deg, transparent 83%, rgba(255, 255, 255, 0.18) 15%),
|
||||
linear-gradient(-74deg, transparent 76%, rgba(255, 255, 255, 0.1) 15%),
|
||||
linear-gradient(to top, #127ab1, #1799e0, #1796db);
|
||||
}
|
||||
|
||||
.title {
|
||||
@mixin display2;
|
||||
@mixin semiBold;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.navBar {
|
||||
display: flex;
|
||||
height: $topnav-height;
|
||||
color: $color-white;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.rightNavLinks {
|
||||
@mixin fontSize 2;
|
||||
display: inline-flex;
|
||||
|
||||
& > * {
|
||||
margin-right: $space-sm;
|
||||
margin-left: $space-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
@mixin flexCentered;
|
||||
height: $hero-height;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
color: $color-white;
|
||||
}
|
||||
|
||||
.subText {
|
||||
@mixin headline;
|
||||
margin-bottom: $space-xxxl;
|
||||
}
|
||||
|
||||
/* Content sections */
|
||||
.sectionWrapper {
|
||||
padding-bottom: $section-padding;
|
||||
}
|
||||
|
||||
.paddedRow {
|
||||
padding-top: $section-padding;
|
||||
}
|
|
@ -1,222 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { post } from 'superagent';
|
||||
import Input from 'common/Input';
|
||||
import Button from 'common/Button';
|
||||
import css from './styles.css';
|
||||
import isValidPassword from 'lib/utils/password-validator';
|
||||
|
||||
const { string, func, bool } = PropTypes;
|
||||
|
||||
// Not sure where this should go yet
|
||||
const isValidDockerID = (val = '') => val.length;
|
||||
|
||||
// NOTE: This component has to be refactored, as it was developed
|
||||
// in a rush for Docker Store's launch
|
||||
export default class LoginForm extends Component {
|
||||
static propTypes = {
|
||||
endpoint: string,
|
||||
onSuccess: func,
|
||||
onError: func,
|
||||
autoFocus: bool,
|
||||
csrftoken: string,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onSuccess() {},
|
||||
onError() {},
|
||||
}
|
||||
|
||||
static getInput(value, label) {
|
||||
return (
|
||||
<Input
|
||||
className={css.input}
|
||||
value={value}
|
||||
id={label}
|
||||
inputStyle={{ color: 'white' }}
|
||||
underlineFocusStyle={{ borderColor: 'white' }}
|
||||
hintText={label}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
state = {
|
||||
id: '',
|
||||
password: '',
|
||||
inProgress: false,
|
||||
errors: {
|
||||
id: '',
|
||||
password: '',
|
||||
|
||||
// This field is disregarded in the form.
|
||||
// If we encounter it through the ajax call,
|
||||
// it's passed as-is to the onError callback()
|
||||
// detail: '',
|
||||
},
|
||||
}
|
||||
|
||||
getHintStyle(value) {
|
||||
return !value ? { color: 'white', opacity: 0.6 } : null;
|
||||
}
|
||||
|
||||
handleChange(which, ev) {
|
||||
this.setState({ [which]: ev.target.value });
|
||||
}
|
||||
|
||||
isValidState() {
|
||||
const { id, password } = this.state;
|
||||
return isValidDockerID(id) && isValidPassword(password);
|
||||
}
|
||||
|
||||
validateDefault(which, { allowEmpty = false } = {}) {
|
||||
const { id, password } = this.state;
|
||||
let errors = Object.assign({}, this.state.errors);
|
||||
|
||||
const errorId = () => {
|
||||
if ((allowEmpty && !id.length) || isValidDockerID(id)) return null;
|
||||
return ' ';
|
||||
};
|
||||
|
||||
const errorPassword = () => {
|
||||
if ((allowEmpty && !password.length) || isValidPassword(password)) {
|
||||
return null;
|
||||
}
|
||||
return ' ';
|
||||
};
|
||||
|
||||
switch (which) {
|
||||
case 'id': errors.id = errorId(); break;
|
||||
case 'password': errors.password = errorPassword(); break;
|
||||
default:
|
||||
errors = {
|
||||
id: errorId(),
|
||||
password: errorPassword(),
|
||||
};
|
||||
}
|
||||
|
||||
this.setState({ errors });
|
||||
}
|
||||
|
||||
handleSubmit = (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!this.isValidState()) {
|
||||
this.validateDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const { password } = this.state;
|
||||
const id = this.state.id && this.state.id.toLowerCase();
|
||||
const { endpoint, onSuccess, onError, csrftoken } = this.props;
|
||||
|
||||
this.setState({
|
||||
inProgress: true,
|
||||
errors: {
|
||||
id: null,
|
||||
password: null,
|
||||
},
|
||||
});
|
||||
|
||||
const req = post(endpoint)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', 'application/json');
|
||||
|
||||
if (csrftoken) {
|
||||
req.set('X-CSRFToken', csrftoken);
|
||||
}
|
||||
|
||||
req.send({ password, username: id }).end((err, res) => {
|
||||
this.setState({ inProgress: false });
|
||||
if (err) {
|
||||
const errors = {};
|
||||
let body = {};
|
||||
|
||||
try {
|
||||
body = JSON.parse(res.text);
|
||||
} catch (e) {
|
||||
onError(res.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.status === 401) {
|
||||
errors.id = ' ';
|
||||
errors.password = ' ';
|
||||
}
|
||||
|
||||
if (body.detail) {
|
||||
onError(body.detail);
|
||||
}
|
||||
|
||||
if (body.username) {
|
||||
errors.id = body.username[0];
|
||||
}
|
||||
|
||||
if (body.password) {
|
||||
errors.password = body.password[0];
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length) {
|
||||
this.setState({ errors });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess({ username: id, token: res.body.token });
|
||||
});
|
||||
}
|
||||
|
||||
resetFields() {
|
||||
this.setState({ id: '', password: '' });
|
||||
}
|
||||
|
||||
renderInputID(value) {
|
||||
const { autoFocus } = this.props;
|
||||
const baseInput = LoginForm.getInput(value, 'Docker ID');
|
||||
const errorText = this.state.errors.id;
|
||||
const hintStyle = this.getHintStyle(value);
|
||||
const onChange = (ev) => this.handleChange('id', ev);
|
||||
const onBlur = () => this.validateDefault('id', { allowEmpty: true });
|
||||
return React.cloneElement(
|
||||
baseInput,
|
||||
{ onChange, onBlur, hintStyle, errorText, autoFocus }
|
||||
);
|
||||
}
|
||||
|
||||
renderInputPassword(value) {
|
||||
const baseInput = LoginForm.getInput(value, 'Password');
|
||||
const errorText = this.state.errors.password;
|
||||
const hintStyle = this.getHintStyle(value);
|
||||
const onChange = (ev) => this.handleChange('password', ev);
|
||||
const onBlur = () => this.validateDefault('password', { allowEmpty: true });
|
||||
return React.cloneElement(
|
||||
baseInput,
|
||||
{ onChange, onBlur, hintStyle, errorText, type: 'password' }
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { id, password, inProgress } = this.state;
|
||||
const inputID = this.renderInputID(id);
|
||||
const inputPassword = this.renderInputPassword(password);
|
||||
|
||||
return (
|
||||
<div className={css.main}>
|
||||
<form onSubmit={this.handleSubmit} key="login-form">
|
||||
{inputID}
|
||||
{inputPassword}
|
||||
<Button
|
||||
disabled={inProgress}
|
||||
className={css.login}
|
||||
inverted
|
||||
type="submit"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.main {
|
||||
@mixin clearfix;
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-bottom: $space-lg;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login {
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-top: $space-xl;
|
||||
width: 110px;
|
||||
}
|
|
@ -1,100 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router';
|
||||
import staticBox from 'lib/decorators/StaticBox';
|
||||
import { readCookie } from 'lib/utils/cookie-handler';
|
||||
import isStaging from 'lib/utils/isStaging';
|
||||
import isDev from 'lib/utils/isDevelopment';
|
||||
import {
|
||||
accountFetchCurrentUser,
|
||||
accountFetchUserEmails,
|
||||
} from 'actions/account';
|
||||
import { DockerFlatIcon as DockerFlat } from 'common/Icon';
|
||||
import LoginForm from './LoginForm';
|
||||
import css from './styles.css';
|
||||
import qs from 'qs';
|
||||
import {
|
||||
isValidUrl,
|
||||
isQsPathSecure,
|
||||
} from 'lib/utils/url-utils';
|
||||
import routes from 'lib/constants/routes';
|
||||
|
||||
const { func, shape } = PropTypes;
|
||||
const dispatcher = { accountFetchCurrentUser, accountFetchUserEmails };
|
||||
|
||||
@staticBox
|
||||
@connect(null, dispatcher)
|
||||
export default class Login extends Component {
|
||||
static propTypes = {
|
||||
accountFetchCurrentUser: func.isRequired,
|
||||
accountFetchUserEmails: func.isRequired,
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
router: shape({
|
||||
replace: func.isRequired,
|
||||
}).isRequired,
|
||||
}
|
||||
|
||||
state = { error: '' }
|
||||
|
||||
onError = (error) => {
|
||||
this.setState({ error });
|
||||
}
|
||||
|
||||
navigate = () => {
|
||||
let query = window && window.location.search;
|
||||
// Scrape off first '?' from search
|
||||
if (query[0] === '?') {
|
||||
query = query.substring(1, query.length);
|
||||
}
|
||||
const { next } = qs.parse(query);
|
||||
const nextUrl = `${window.location.origin}${next}`;
|
||||
|
||||
if (isValidUrl(nextUrl) && isQsPathSecure(next)) {
|
||||
window.location = nextUrl;
|
||||
} else {
|
||||
window.location = routes.home();
|
||||
}
|
||||
}
|
||||
|
||||
maybeRenderError() {
|
||||
const { error } = this.state;
|
||||
return error ? <div className={css.error}>{error}</div> : null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const endpoint = '/v2/users/login/';
|
||||
let registerUrl = 'https://cloud.docker.com/';
|
||||
let forgotPasswordUrl = 'https://cloud.docker.com/reset-password';
|
||||
if (isStaging() || isDev()) {
|
||||
registerUrl = 'https://cloud-stage.docker.com/';
|
||||
forgotPasswordUrl = 'https://cloud-stage.docker.com/reset-password';
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{this.maybeRenderError()}
|
||||
<div className={css.banner}>
|
||||
<Link to="/">
|
||||
<DockerFlat />
|
||||
</Link>
|
||||
<h1>Welcome to the Docker Store</h1>
|
||||
<p>Login with your <strong>Docker ID</strong></p>
|
||||
</div>
|
||||
<div className={css.form}>
|
||||
<LoginForm
|
||||
autoFocus
|
||||
csrftoken={readCookie('csrftoken')}
|
||||
endpoint={endpoint}
|
||||
onSuccess={this.navigate}
|
||||
onError={this.onError}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.more}>
|
||||
<a href={forgotPasswordUrl}>Forgot Password?</a>
|
||||
<a href={registerUrl}>Create Account</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.banner {
|
||||
text-align: center;
|
||||
margin-bottom: $space-xxxxl;
|
||||
|
||||
svg {
|
||||
@mixin square 70px;
|
||||
fill: $color-white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@mixin fontSize 5;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
@mixin fontSize 2;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
margin-bottom: $space-xxxxl;
|
||||
}
|
||||
|
||||
.more {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
a {
|
||||
@mixin linkUnstyled;
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
text-align: left;
|
||||
padding-left: $space-lg;
|
||||
|
||||
&:first-child {
|
||||
padding-left: 0;
|
||||
text-align: right;
|
||||
padding-right: $space-lg;
|
||||
border-right: 1px solid white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
@mixin fontSize 2;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
background: $color-variant-panic;
|
||||
padding: $space-sm $space-xs;
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import css from './styles.css';
|
||||
|
||||
// Note: must include image like this so that webpack picks it up
|
||||
import image404 from 'lib/images/404@2x.png';
|
||||
|
||||
/* eslint-disable max-len */
|
||||
|
||||
export default class RouteNotFound404 extends Component {
|
||||
render() {
|
||||
return (
|
||||
<div className={css.wrapper}>
|
||||
<div>
|
||||
<img
|
||||
className={css.img}
|
||||
alt="404 Route Not Found"
|
||||
src={image404}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-enable */
|
|
@ -1,17 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
& > div {
|
||||
flex: 1 auto;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.img {
|
||||
/* scale to persist square */
|
||||
max-width: 100vw;
|
||||
max-height: 100vw;
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { Select } from '../lib/common';
|
||||
import { noOp } from '../lib/helpers';
|
||||
import css from './styles.css';
|
||||
const { string, func, array } = PropTypes;
|
||||
|
||||
const Accounts = ({ options, onSelectChange, selectedNamespace }) => {
|
||||
let accountSelects = null;
|
||||
if (options.length > 1) {
|
||||
accountSelects = (
|
||||
<div className={css.account}>
|
||||
<div className={css.sectionTitle}>
|
||||
Account
|
||||
</div>
|
||||
<Select
|
||||
value={selectedNamespace}
|
||||
className={css.accountSelect}
|
||||
clearable={false}
|
||||
ignoreCase
|
||||
onBlur={noOp}
|
||||
onChange={onSelectChange}
|
||||
options={options}
|
||||
placeholder="Account"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return accountSelects;
|
||||
};
|
||||
|
||||
Accounts.propTypes = {
|
||||
selectedNamespace: string.isRequired,
|
||||
onSelectChange: func.isRequired,
|
||||
options: array.isRequired,
|
||||
};
|
||||
|
||||
export default Accounts;
|
|
@ -1,21 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.account {
|
||||
@mixin fontSize 2;
|
||||
color: $color-regent-gray;
|
||||
margin: $space-sm 0;
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: $space-sm 0;
|
||||
}
|
||||
|
||||
.accountSelect {
|
||||
width: 150px;
|
||||
height: $select-height;
|
||||
width: $select-width;
|
||||
margin-left: $space-sm;
|
||||
}
|
|
@ -1,161 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import map from 'lodash/map';
|
||||
// Blob is a polyfill to support older browsers
|
||||
require('lib/utils/blob');
|
||||
import { saveAs } from 'lib/utils/file-saver';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import {
|
||||
Card,
|
||||
DownloadIcon,
|
||||
FetchingError,
|
||||
FullscreenLoading,
|
||||
} from '../lib/common';
|
||||
import { billingFetchInvoicePDF } from 'actions/billing';
|
||||
import css from './styles.css';
|
||||
const PENDING = 'Pending';
|
||||
|
||||
const { array, bool, func, object, shape, string } = PropTypes;
|
||||
|
||||
const mapStateToProps = ({ billing }) => {
|
||||
const { invoices } = billing;
|
||||
return {
|
||||
invoices,
|
||||
};
|
||||
};
|
||||
|
||||
const dispatcher = {
|
||||
fetchInvoicePDF: billingFetchInvoicePDF,
|
||||
};
|
||||
|
||||
@connect(mapStateToProps, dispatcher)
|
||||
export default class InvoicesView extends Component {
|
||||
static propTypes = {
|
||||
invoices: shape({
|
||||
error: string,
|
||||
isFetching: bool.isRequired,
|
||||
results: array.isRequired,
|
||||
}).isRequired,
|
||||
fetchInvoicePDF: func.isRequired,
|
||||
selectedUser: object.isRequired,
|
||||
}
|
||||
// When an invoice is being downloaded, there will be a key for invoice id
|
||||
// with status information: [invoiceId]: { isDownloading: bool, error: bool }
|
||||
state = {}
|
||||
|
||||
downloadInvoice = ({ invoice_id, issuedDate }) => () => {
|
||||
const { fetchInvoicePDF, selectedUser } = this.props;
|
||||
const { id: docker_id } = selectedUser;
|
||||
// Make sure to not override any existing invoice download statuses
|
||||
this.setState({
|
||||
[invoice_id]: {
|
||||
isDownloading: true,
|
||||
error: false,
|
||||
},
|
||||
}, () => {
|
||||
fetchInvoicePDF({ docker_id, invoice_id })
|
||||
.then((res) => {
|
||||
// value contains the API response
|
||||
const downloadContent = res.value;
|
||||
this.setState({
|
||||
[invoice_id]: {
|
||||
isDownloading: false,
|
||||
error: false,
|
||||
},
|
||||
});
|
||||
const blob = new Blob([downloadContent], { type: 'application/pdf' });
|
||||
saveAs(blob, `Docker Invoice ${issuedDate}.pdf`);
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({
|
||||
[invoice_id]: {
|
||||
isDownloading: false,
|
||||
error: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
renderInvoiceRow = (invoice) => {
|
||||
const {
|
||||
bf_invoice_id: invoice_id,
|
||||
cost,
|
||||
currency,
|
||||
issued,
|
||||
payment_received,
|
||||
state,
|
||||
subscriptions,
|
||||
} = invoice;
|
||||
// Do not render invoices in the 'Pending' state
|
||||
if (state === PENDING) {
|
||||
return null;
|
||||
}
|
||||
const { isDownloading, error } = this.state[invoice_id] || {};
|
||||
const issuedDate = new Date(issued).toDateString();
|
||||
const paid = new Date(payment_received).toDateString();
|
||||
const costAndCurrency = `$${cost} ${currency}`;
|
||||
const subscriptionsList = map(subscriptions, (sub) => {
|
||||
return (<div key={sub.subscription_id}>{sub.subscription_name}</div>);
|
||||
});
|
||||
let downloadText = 'PDF';
|
||||
if (isDownloading && !error) {
|
||||
downloadText = 'Loading...';
|
||||
} else if (isDownloading && error) {
|
||||
downloadText = 'Error';
|
||||
}
|
||||
const classes = classnames({
|
||||
[css.downloadLicense]: true,
|
||||
[css.downloadLicenseError]: isDownloading && error,
|
||||
});
|
||||
return (
|
||||
<div key={invoice_id} className={css.row}>
|
||||
<div>{issuedDate}</div>
|
||||
<div>{subscriptionsList}</div>
|
||||
<div>{costAndCurrency}</div>
|
||||
<div>{paid}</div>
|
||||
<div
|
||||
className={classes}
|
||||
onClick={this.downloadInvoice({ invoice_id, issuedDate })}
|
||||
>
|
||||
<DownloadIcon />
|
||||
{downloadText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isFetching, error, results } = this.props.invoices;
|
||||
if (isFetching) {
|
||||
return <FullscreenLoading />;
|
||||
} else if (error) {
|
||||
return (
|
||||
<div className={css.fetchingError}>
|
||||
<FetchingError resource="your invoices" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let content;
|
||||
if (results.length < 1) {
|
||||
content = <div className={css.noInvoices}>No invoices</div>;
|
||||
} else {
|
||||
content = map(results, this.renderInvoiceRow);
|
||||
}
|
||||
return (
|
||||
<Card title="Invoices" shadow>
|
||||
<div className={css.table}>
|
||||
<div className={css.head}>
|
||||
<div>Date</div>
|
||||
<div>Plans & Subscriptions</div>
|
||||
<div>Amount</div>
|
||||
<div>Paid</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.table {
|
||||
.head,
|
||||
.row {
|
||||
@mixin 4columns 2, 4.5, 2, 2, 1.5;
|
||||
}
|
||||
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row + .row {
|
||||
margin-top: $space-xxl;
|
||||
}
|
||||
|
||||
.head {
|
||||
font-weight: bold;
|
||||
color: $color-variant-dull;
|
||||
margin: $space-sm 0;
|
||||
}
|
||||
}
|
||||
|
||||
.noInvoices {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloadLicense {
|
||||
@mixin fontSize 2;
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
color: $color-dodger-blue;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
cursor: pointer;
|
||||
& > svg {
|
||||
fill: $color-dodger-blue;
|
||||
margin-right: $space-xxs;
|
||||
}
|
||||
}
|
||||
|
||||
.downloadLicenseError {
|
||||
color: $color-carnation;
|
||||
& > svg {
|
||||
fill: $color-carnation;
|
||||
}
|
||||
}
|
|
@ -1,215 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import {
|
||||
countryOptions,
|
||||
} from '../../lib/countries';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
} from '../../lib/common';
|
||||
import EmailSelect from '../EmailSelect';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import merge from 'lodash/merge';
|
||||
import css from './styles.css';
|
||||
const { array, func, object } = PropTypes;
|
||||
|
||||
class ContactForm extends Component {
|
||||
static propTypes = {
|
||||
emailsArray: array.isRequired,
|
||||
fields: object.isRequired,
|
||||
handleSubmit: func.isRequired,
|
||||
}
|
||||
|
||||
onSelectChange = (field) => (data) => {
|
||||
const fieldObject = this.props.fields[field];
|
||||
fieldObject.onChange(data.value);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
emailsArray,
|
||||
fields: propFields,
|
||||
} = this.props;
|
||||
const {
|
||||
address1,
|
||||
city,
|
||||
company,
|
||||
country,
|
||||
firstName,
|
||||
job,
|
||||
lastName,
|
||||
phone,
|
||||
postalCode,
|
||||
province,
|
||||
} = propFields;
|
||||
return (
|
||||
<div>
|
||||
<div className={css.title} >
|
||||
Contact Information
|
||||
</div>
|
||||
<div className={css.form}>
|
||||
<div className={css.row}>
|
||||
<Input
|
||||
{...firstName}
|
||||
id={'first'}
|
||||
placeholder="First Name"
|
||||
errorText={firstName.touched && firstName.error}
|
||||
/>
|
||||
<Input
|
||||
{...lastName}
|
||||
id={'last'}
|
||||
placeholder="Last Name"
|
||||
errorText={lastName.touched && lastName.error}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<Input
|
||||
{...company}
|
||||
id={'company'}
|
||||
placeholder="Company"
|
||||
/>
|
||||
<EmailSelect
|
||||
accountEmails={emailsArray}
|
||||
fields={propFields}
|
||||
onSelectChange={this.onSelectChange('email')}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<Input
|
||||
{...job}
|
||||
id={'job'}
|
||||
placeholder="Job"
|
||||
/>
|
||||
<Input
|
||||
{...phone}
|
||||
id={'phone'}
|
||||
placeholder="Phone"
|
||||
/>
|
||||
</div>
|
||||
<div className={css.title} >
|
||||
Address
|
||||
</div>
|
||||
<div className={css.fullRow}>
|
||||
<Input
|
||||
{...address1}
|
||||
style={{ width: '100%' }}
|
||||
id={'address'}
|
||||
placeholder="Address"
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<Input
|
||||
{...city}
|
||||
id={'city'}
|
||||
placeholder="City"
|
||||
/>
|
||||
<Input
|
||||
{...postalCode}
|
||||
id={'postalCode'}
|
||||
placeholder="Postal Code"
|
||||
/>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<Input
|
||||
{...province}
|
||||
id={'province'}
|
||||
placeholder="Province"
|
||||
/>
|
||||
<Select
|
||||
{...country}
|
||||
onBlur={() => {}}
|
||||
className={css.select}
|
||||
placeholder="Country"
|
||||
style={{ marginBottom: '10px', width: '' }}
|
||||
options={countryOptions}
|
||||
onChange={this.onSelectChange('country')}
|
||||
ignoreCase
|
||||
clearable={false}
|
||||
errorText={
|
||||
country.touched && country.error ? country.error : ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className={css.submit}
|
||||
type="submit"
|
||||
onClick={this.props.handleSubmit}
|
||||
>
|
||||
Update Contact Information
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fields = [
|
||||
'address1',
|
||||
'city',
|
||||
'company',
|
||||
'country',
|
||||
'email',
|
||||
'firstName',
|
||||
'job',
|
||||
'lastName',
|
||||
'phone',
|
||||
'postalCode',
|
||||
'province',
|
||||
];
|
||||
|
||||
const mapStateToProps = ({ account, billing }) => {
|
||||
const {
|
||||
profiles,
|
||||
} = billing;
|
||||
const {
|
||||
namespaceObjects,
|
||||
selectedNamespace,
|
||||
userEmails,
|
||||
} = account;
|
||||
const emailList = sortBy(userEmails.results, o => !o.primary);
|
||||
const emailsArray = emailList.map((emailObject) => {
|
||||
return emailObject.email;
|
||||
});
|
||||
const selectedUser = namespaceObjects.results[selectedNamespace] || {};
|
||||
const billingProfile = profiles.results[selectedUser.id];
|
||||
const initialData = {
|
||||
account: selectedNamespace,
|
||||
email: emailList[0],
|
||||
};
|
||||
let initialized = {};
|
||||
if (!!billingProfile) {
|
||||
const selectedEmail = billingProfile.email;
|
||||
if (emailsArray.indexOf(selectedEmail) < 0) {
|
||||
emailsArray.unshift(selectedEmail);
|
||||
}
|
||||
const address =
|
||||
billingProfile.addresses && billingProfile.addresses[0] || {};
|
||||
initialized = {
|
||||
address1: address.address_line_1,
|
||||
city: address.city,
|
||||
company: billingProfile.company_name,
|
||||
country: address.country,
|
||||
email: billingProfile.email,
|
||||
firstName: billingProfile.first_name,
|
||||
job: billingProfile.job_function,
|
||||
lastName: billingProfile.last_name,
|
||||
phone: billingProfile.phone_primary,
|
||||
postalCode: address.post_code,
|
||||
province: address.province,
|
||||
};
|
||||
merge(initialData, initialized);
|
||||
}
|
||||
return {
|
||||
emailsArray,
|
||||
initialValues: initialData,
|
||||
};
|
||||
};
|
||||
|
||||
export default reduxForm({
|
||||
form: 'ContactForm',
|
||||
fields,
|
||||
// validate,
|
||||
},
|
||||
mapStateToProps,
|
||||
)(ContactForm);
|
|
@ -1,23 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.form {
|
||||
padding: $space-sm;
|
||||
}
|
||||
|
||||
.row {
|
||||
@mixin 2columns 6, 6;
|
||||
}
|
||||
|
||||
.fullRow {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
@mixin fontSize 2;
|
||||
color: $color-fiord;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.submit {
|
||||
cursor: pointer;
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import {
|
||||
Select,
|
||||
Input,
|
||||
} from 'common';
|
||||
|
||||
const Emails = ({ accountEmails, fields, onSelectChange }) => {
|
||||
let emails;
|
||||
if (accountEmails.length <= 1) {
|
||||
emails = (
|
||||
<Input
|
||||
readOnly
|
||||
value={accountEmails[0]}
|
||||
id={'email'}
|
||||
placeholder="Email"
|
||||
style={{ marginBottom: '14px', width: '' }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
emails = (
|
||||
<Select
|
||||
{...fields.email}
|
||||
onBlur={() => {}}
|
||||
placeholder="Email"
|
||||
style={{ marginBottom: '10px', width: '' }}
|
||||
options={accountEmails.map((e) => {
|
||||
return { label: e, value: e };
|
||||
})}
|
||||
onChange={onSelectChange}
|
||||
ignoreCase
|
||||
clearable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return emails;
|
||||
};
|
||||
|
||||
Emails.propTypes = {
|
||||
accountEmails: PropTypes.array.isRequired,
|
||||
fields: PropTypes.any.isRequired,
|
||||
onSelectChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Emails;
|
|
@ -1,71 +0,0 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import {
|
||||
Select,
|
||||
} from '../../../lib/common';
|
||||
|
||||
import css from './styles.css';
|
||||
|
||||
const generateMonths = () => {
|
||||
const months = [];
|
||||
for (let i = 1; i < 13; i++) {
|
||||
const num = `0${i}`.slice(-2);
|
||||
months.push({ label: num, value: num });
|
||||
}
|
||||
return months;
|
||||
};
|
||||
|
||||
const generateYears = () => {
|
||||
const years = [];
|
||||
const thisYear = new Date().getFullYear();
|
||||
for (let i = thisYear; i < thisYear + 20; i++) {
|
||||
years.push({ label: i, value: i });
|
||||
}
|
||||
return years;
|
||||
};
|
||||
|
||||
const Expiration = props => {
|
||||
const { fields, onSelectChange } = props;
|
||||
const { expMonth, expYear } = fields;
|
||||
const monthClass = (expMonth.touched && expMonth.error) ?
|
||||
css.expirationErr : '';
|
||||
const yearClass = (expYear.touched && expYear.error) ?
|
||||
css.expirationErr : '';
|
||||
const error = !!monthClass || !!yearClass ?
|
||||
(<div className={css.dateErr}>Invalid Expiration</div>) : null;
|
||||
const accountSelects = (
|
||||
<div className={css.expiration}>
|
||||
<div className={css.title}>Expiration Date</div>
|
||||
<div className={css.dates}>
|
||||
<Select
|
||||
{...fields.expMonth}
|
||||
onBlur={() => {}}
|
||||
placeholder="Month"
|
||||
onChange={onSelectChange('expMonth')}
|
||||
className={monthClass}
|
||||
options={generateMonths()}
|
||||
ignoreCase
|
||||
clearable={false}
|
||||
/>
|
||||
<Select
|
||||
{...fields.expYear}
|
||||
onBlur={() => {}}
|
||||
placeholder="Year"
|
||||
onChange={onSelectChange('expYear')}
|
||||
className={yearClass}
|
||||
options={generateYears()}
|
||||
ignoreCase
|
||||
clearable={false}
|
||||
/>
|
||||
</div>
|
||||
{ error }
|
||||
</div>
|
||||
);
|
||||
return accountSelects;
|
||||
};
|
||||
|
||||
Expiration.propTypes = {
|
||||
fields: PropTypes.any.isRequired,
|
||||
onSelectChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Expiration;
|
|
@ -1,23 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.expiration {
|
||||
.title {
|
||||
margin-top: -22px;
|
||||
padding: $space-xxs;
|
||||
color: $color-variant-dull;
|
||||
}
|
||||
.dates {
|
||||
@mixin 2columns 6, 6;
|
||||
margin-bottom: $space-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.expirationErr > div {
|
||||
border-color: $color-variant-panic !important;
|
||||
}
|
||||
|
||||
.dateErr {
|
||||
color: $color-variant-panic;
|
||||
font-size: $font-size-0;
|
||||
margin: 0 $space-xxs;
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import Expiration from './CardExpiration';
|
||||
import validate from './validations';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
} from '../../lib/common';
|
||||
|
||||
import css from './styles.css';
|
||||
const { bool, func, object } = PropTypes;
|
||||
|
||||
class PaymentMethodsForm extends Component {
|
||||
static propTypes = {
|
||||
billingPaymentError: object,
|
||||
defaultSelected: bool.isRequired,
|
||||
fields: object.isRequired,
|
||||
handleSubmit: func.isRequired,
|
||||
}
|
||||
|
||||
onSelectChange = (field) => (data) => {
|
||||
const fieldObject = this.props.fields[field];
|
||||
fieldObject.onChange(data.value);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
billingPaymentError: error,
|
||||
defaultSelected,
|
||||
fields: propFields,
|
||||
} = this.props;
|
||||
const {
|
||||
cardNumber,
|
||||
cvv,
|
||||
firstName,
|
||||
lastName,
|
||||
} = propFields;
|
||||
const errClass = !isEmpty(error) ? css.error : '';
|
||||
const warning =
|
||||
'This will update your payment method for all subscriptions';
|
||||
let submitText = 'Change Card';
|
||||
if (!defaultSelected) {
|
||||
submitText = 'Add Card';
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`${css.title} ${errClass}`}>
|
||||
Credit card information
|
||||
</div>
|
||||
<div className={css.form}>
|
||||
<div className={css.row}>
|
||||
<Input
|
||||
{...firstName}
|
||||
id={'first'}
|
||||
placeholder="First Name"
|
||||
errorText={firstName.touched && firstName.error}
|
||||
/>
|
||||
<Input
|
||||
{...lastName}
|
||||
id={'last'}
|
||||
placeholder="Last Name"
|
||||
errorText={lastName.touched && lastName.error}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.cardRow}>
|
||||
<Input
|
||||
{...cardNumber}
|
||||
id={'card'}
|
||||
placeholder="Card number"
|
||||
errorText={
|
||||
(cardNumber.touched && cardNumber.error) ||
|
||||
(error.type === 'card_error' && error.message)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
{...cvv}
|
||||
id={'cvv'}
|
||||
placeholder="CVV"
|
||||
errorText={
|
||||
(cvv.touched && cvv.error) || error.type === 'card_error'
|
||||
}
|
||||
/>
|
||||
<Expiration
|
||||
fields={propFields}
|
||||
onSelectChange={this.onSelectChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className={css.submit}
|
||||
type="submit"
|
||||
onClick={this.props.handleSubmit}
|
||||
>
|
||||
{submitText}
|
||||
</Button>
|
||||
{warning}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fields = [
|
||||
'cardNumber',
|
||||
'cvv',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'expMonth',
|
||||
'expYear',
|
||||
];
|
||||
|
||||
const mapStateToProps = ({ account, billing }) => {
|
||||
const {
|
||||
profiles,
|
||||
paymentMethods,
|
||||
} = billing;
|
||||
const {
|
||||
namespaceObjects,
|
||||
selectedNamespace,
|
||||
} = account;
|
||||
const selectedUser = namespaceObjects.results[selectedNamespace] || {};
|
||||
const billingProfile = profiles.results[selectedUser.id];
|
||||
return {
|
||||
billingPaymentError: paymentMethods.error,
|
||||
billingProfile,
|
||||
};
|
||||
};
|
||||
|
||||
export default reduxForm({
|
||||
form: 'paymentMethodsForm',
|
||||
fields,
|
||||
validate,
|
||||
},
|
||||
mapStateToProps,
|
||||
)(PaymentMethodsForm);
|
|
@ -1,49 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.form {
|
||||
padding: $space-sm;
|
||||
}
|
||||
|
||||
.row {
|
||||
@mixin 2columns 6, 6;
|
||||
}
|
||||
|
||||
.cardRow {
|
||||
@mixin 3columns 6, 2, 4;
|
||||
margin-top: $space-md;
|
||||
}
|
||||
|
||||
.postalRow {
|
||||
@mixin 3columns 4, 2, 6;
|
||||
}
|
||||
|
||||
.title {
|
||||
@mixin fontSize 2;
|
||||
color: $color-fiord;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.error {
|
||||
@mixin fontSize 2;
|
||||
font-weight: bold;
|
||||
color: $color-variant-panic;
|
||||
}
|
||||
|
||||
.submit {
|
||||
cursor: pointer;
|
||||
margin-top: $space-lg;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: $color-variant-panic;
|
||||
margin
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 $space-md;
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
export default (values) => {
|
||||
const errors = {};
|
||||
const {
|
||||
cardNumber,
|
||||
cvv,
|
||||
expMonth,
|
||||
expYear,
|
||||
firstName,
|
||||
lastName,
|
||||
} = values;
|
||||
if (!firstName) {
|
||||
errors.firstName = 'Required';
|
||||
}
|
||||
if (!lastName) {
|
||||
errors.lastName = 'Required';
|
||||
}
|
||||
if (!cardNumber) {
|
||||
errors.cardNumber = 'Required';
|
||||
}
|
||||
if (!cvv) {
|
||||
errors.cvv = 'Required';
|
||||
}
|
||||
if (!expMonth) {
|
||||
errors.expMonth = 'Required';
|
||||
}
|
||||
if (!expYear) {
|
||||
errors.expYear = 'Required';
|
||||
}
|
||||
const date = new Date();
|
||||
if (
|
||||
values.expMonth < (date.getMonth() + 1) &&
|
||||
values.expYear <= date.getFullYear()
|
||||
) {
|
||||
errors.expMonth = 'Required';
|
||||
errors.expYear = 'Required';
|
||||
}
|
||||
/*
|
||||
TODO: feature - nathan 05/19/16
|
||||
Luhn card number check.
|
||||
cvv check.
|
||||
expiration check.
|
||||
billing address validations check
|
||||
email check?
|
||||
account check? (already has a plan associated)
|
||||
*/
|
||||
return errors;
|
||||
};
|
|
@ -1,310 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import map from 'lodash/map';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import find from 'lodash/find';
|
||||
import {
|
||||
billingCreatePaymentMethod,
|
||||
billingDeletePaymentMethod,
|
||||
billingFetchPaymentMethods,
|
||||
billingSetDefaultPaymentMethod,
|
||||
billingUpdateProfile,
|
||||
} from '../actions';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
FetchingError,
|
||||
FullscreenLoading,
|
||||
WarningIcon,
|
||||
} from '../lib/common';
|
||||
import {
|
||||
TINY,
|
||||
} from '../lib/constants';
|
||||
import PaymentForm from './PaymentForm';
|
||||
import ContactForm from './ContactForm';
|
||||
import css from './styles.css';
|
||||
|
||||
const dispatcher = {
|
||||
billingCreatePaymentMethod,
|
||||
billingDeletePaymentMethod,
|
||||
billingFetchPaymentMethods,
|
||||
billingSetDefaultPaymentMethod,
|
||||
billingUpdateProfile,
|
||||
};
|
||||
|
||||
const mapStateToProps = ({ billing }) => {
|
||||
const {
|
||||
paymentMethods,
|
||||
} = billing;
|
||||
return {
|
||||
paymentMethods,
|
||||
};
|
||||
};
|
||||
|
||||
const { object, bool, shape, func } = PropTypes;
|
||||
|
||||
@connect(mapStateToProps, dispatcher)
|
||||
export default class PaymentMethodsView extends Component {
|
||||
static propTypes = {
|
||||
billingProfile: object.isRequired,
|
||||
paymentMethods: shape({
|
||||
isFetching: bool.isRequired,
|
||||
results: object.isrequired,
|
||||
}),
|
||||
selectedUser: object.isRequired,
|
||||
// actions
|
||||
billingCreatePaymentMethod: func.isRequired,
|
||||
billingDeletePaymentMethod: func.isRequired,
|
||||
billingFetchPaymentMethods: func.isRequired,
|
||||
billingSetDefaultPaymentMethod: func.isRequired,
|
||||
billingUpdateProfile: func.isRequired,
|
||||
}
|
||||
|
||||
state = {
|
||||
selectedCardId: '',
|
||||
defaultPaymentWarning: '',
|
||||
}
|
||||
|
||||
setDefaultCard = (bfId) => () => {
|
||||
const {
|
||||
selectedUser,
|
||||
billingSetDefaultPaymentMethod: setDefaultPaymentMethod,
|
||||
billingFetchPaymentMethods: fetchPaymentMethods,
|
||||
} = this.props;
|
||||
const { id: docker_id } = selectedUser;
|
||||
setDefaultPaymentMethod({
|
||||
card_id: bfId,
|
||||
docker_id,
|
||||
}).then(() => {
|
||||
fetchPaymentMethods({ docker_id });
|
||||
});
|
||||
}
|
||||
|
||||
addCard = (data, shouldFetch) => {
|
||||
const {
|
||||
billingProfile,
|
||||
selectedUser,
|
||||
billingCreatePaymentMethod: createPaymentMethod,
|
||||
billingFetchPaymentMethods: fetchPaymentMethods,
|
||||
} = this.props;
|
||||
const { id: docker_id } = selectedUser;
|
||||
const billforward_id = billingProfile && billingProfile.billforward_id;
|
||||
const paymentInfo = {
|
||||
billforward_id,
|
||||
name_first: data.firstName,
|
||||
name_last: data.lastName,
|
||||
cvc: data.cvv,
|
||||
number: data.cardNumber,
|
||||
exp_month: data.expMonth,
|
||||
exp_year: data.expYear,
|
||||
};
|
||||
if (!!shouldFetch) {
|
||||
return createPaymentMethod(paymentInfo).then(() => {
|
||||
fetchPaymentMethods({ docker_id });
|
||||
});
|
||||
}
|
||||
return createPaymentMethod(paymentInfo);
|
||||
}
|
||||
|
||||
replaceCard = (bfId) => (data) => {
|
||||
const addCard = this.addCard;
|
||||
const deleteCard = this.deleteCard;
|
||||
addCard(data).then(() => {
|
||||
deleteCard(bfId)();
|
||||
});
|
||||
}
|
||||
|
||||
deleteCard = (bfId) => () => {
|
||||
const {
|
||||
selectedUser,
|
||||
billingDeletePaymentMethod: deletePaymentMethod,
|
||||
billingFetchPaymentMethods: fetchPaymentMethods,
|
||||
} = this.props;
|
||||
const { id: docker_id } = selectedUser;
|
||||
deletePaymentMethod({ docker_id, card_id: bfId }).then(() => {
|
||||
fetchPaymentMethods({ docker_id });
|
||||
});
|
||||
}
|
||||
|
||||
updateBillingProfile = (values) => {
|
||||
const {
|
||||
selectedUser,
|
||||
billingUpdateProfile: updateProfile,
|
||||
} = this.props;
|
||||
const {
|
||||
address1,
|
||||
city,
|
||||
company,
|
||||
country,
|
||||
email,
|
||||
firstName,
|
||||
job,
|
||||
lastName,
|
||||
phone,
|
||||
postalCode,
|
||||
province,
|
||||
} = values;
|
||||
const docker_id = selectedUser.id;
|
||||
const address = {
|
||||
address_line_1: address1,
|
||||
city,
|
||||
province,
|
||||
country,
|
||||
post_code: postalCode,
|
||||
primary_address: true,
|
||||
};
|
||||
const submitData = {
|
||||
addresses: [
|
||||
address,
|
||||
],
|
||||
company_name: company,
|
||||
docker_id,
|
||||
email,
|
||||
first_name: firstName,
|
||||
job_function: job,
|
||||
last_name: lastName,
|
||||
phone_primary: phone,
|
||||
};
|
||||
updateProfile(submitData).then(() => {
|
||||
const keys = selectedUser.type === 'User' ? {
|
||||
dockerUUID: selectedUser.id,
|
||||
Docker_Hub_User_Name__c: selectedUser.username,
|
||||
} : {
|
||||
dockerUUID: selectedUser.id,
|
||||
Docker_Hub_Organization_Name__c: selectedUser.orgname,
|
||||
};
|
||||
|
||||
analytics.track('account_info_update', {
|
||||
...keys,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
jobFunction: job,
|
||||
address: address1,
|
||||
postalCode,
|
||||
state: province,
|
||||
city,
|
||||
country,
|
||||
phone,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderCardInfo = (card, idx) => {
|
||||
const {
|
||||
bf_payment_method_id: cardId,
|
||||
description,
|
||||
expiry_month,
|
||||
expiry_year,
|
||||
card_type,
|
||||
default: defaultCard,
|
||||
} = card;
|
||||
const isDefault = defaultCard ? 'Default' : '';
|
||||
const endsWith = description.replace(/#*/, 'x');
|
||||
let selected =
|
||||
cardId === this.state.selectedCardId ? css.selected : '';
|
||||
if (!this.state.selectedCardId && defaultCard) {
|
||||
selected = css.selected;
|
||||
}
|
||||
const selectCard = () => { this.setState({ selectedCardId: cardId }); };
|
||||
return (
|
||||
<Card
|
||||
className={`${css.paymentMethod} ${selected}`}
|
||||
onClick={selectCard}
|
||||
key={idx}
|
||||
>
|
||||
{isDefault}
|
||||
<div>
|
||||
{card_type} ending in {endsWith}
|
||||
</div>
|
||||
<div>
|
||||
Expires: {expiry_month}/{expiry_year}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
// TODO design - nathan make this GORGEOUS. because it's ugly af right now
|
||||
const {
|
||||
paymentMethods,
|
||||
} = this.props;
|
||||
const {
|
||||
selectedCardId,
|
||||
} = this.state;
|
||||
if (paymentMethods.isFetching) {
|
||||
return <FullscreenLoading />;
|
||||
} else if (!isEmpty(paymentMethods.fetchingError)) {
|
||||
return (
|
||||
<div className={css.fetchingError}>
|
||||
<FetchingError resource="your payment methods" />
|
||||
</div>
|
||||
);
|
||||
} else if (paymentMethods.results.length < 1) {
|
||||
return (
|
||||
<Card shadow>
|
||||
No Payment Methods to show
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
let cardActions;
|
||||
const defaultCard = find(paymentMethods.results, card => card.default);
|
||||
let defaultSelected = false;
|
||||
let paymentFormSubmit = this.addCard;
|
||||
if (selectedCardId && defaultCard.bf_payment_method_id !== selectedCardId) {
|
||||
cardActions = (
|
||||
<div className={css.updateCardRow}>
|
||||
<div>
|
||||
<Button
|
||||
className={css.submit}
|
||||
onClick={this.setDefaultCard(selectedCardId)}
|
||||
>
|
||||
Make Default Card {this.state.hover}
|
||||
</Button>
|
||||
<Button
|
||||
className={css.submit}
|
||||
onClick={this.deleteCard(selectedCardId)}
|
||||
>
|
||||
Delete Payment Method
|
||||
</Button>
|
||||
</div>
|
||||
<div className={css.warning}>
|
||||
<WarningIcon size={TINY} />
|
||||
Warning: Changing your default payment method will
|
||||
change your default payment for ALL subscriptions
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (
|
||||
defaultCard.bf_payment_method_id === selectedCardId || !selectedCardId
|
||||
) {
|
||||
defaultSelected = true;
|
||||
paymentFormSubmit = this.replaceCard(defaultCard.bf_payment_method_id);
|
||||
}
|
||||
|
||||
const sortedPaymentMethods =
|
||||
sortBy(paymentMethods.results, method => !method.default);
|
||||
|
||||
const splitForms = (
|
||||
<div className={css.splitForms}>
|
||||
<PaymentForm
|
||||
onSubmit={paymentFormSubmit}
|
||||
defaultSelected={defaultSelected}
|
||||
/>
|
||||
<ContactForm onSubmit={this.updateBillingProfile} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card title="Payment Information" shadow>
|
||||
<div className={css.infoRow}>
|
||||
<div className={css.title}>Credit Cards</div>
|
||||
{map(sortedPaymentMethods, this.renderCardInfo)}
|
||||
</div>
|
||||
{cardActions}
|
||||
{splitForms}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.title {
|
||||
@mixin fontSize 2;
|
||||
color: $color-fiord;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.updateCardRow {
|
||||
margin-bottom: $space-md;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: $color-variant-panic;
|
||||
margin
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 $space-md;
|
||||
}
|
||||
|
||||
.paymentMethod {
|
||||
display: inline-flex;
|
||||
width: 22%;
|
||||
min-width: 200px;
|
||||
margin: $space-md;
|
||||
&.selected {
|
||||
border: 1px solid $color-variant-primary-hover;
|
||||
}
|
||||
&:hover {
|
||||
border: 1px solid $color-variant-primary-light;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.splitForms {
|
||||
@mixin 2columns 6, 6;
|
||||
> div:first-child {
|
||||
border-right: 1px solid $color-loblolly;
|
||||
}
|
||||
}
|
||||
|
||||
.submit {
|
||||
cursor: pointer;
|
||||
margin: $space-xs $space-md;
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import {
|
||||
billingCreatePaymentMethod,
|
||||
billingDeletePaymentMethod,
|
||||
billingFetchInvoices,
|
||||
billingFetchPaymentMethods,
|
||||
billingFetchProduct,
|
||||
billingFetchProfile,
|
||||
billingFetchProfileSubscriptions,
|
||||
billingSetDefaultPaymentMethod,
|
||||
billingUpdateProfile,
|
||||
} from 'actions/billing';
|
||||
|
||||
import {
|
||||
accountFetchCurrentUser,
|
||||
accountFetchUser,
|
||||
accountFetchUserEmails,
|
||||
accountSelectNamespace,
|
||||
} from 'actions/account';
|
||||
|
||||
import {
|
||||
repositoryFetchOwnedNamespaces,
|
||||
} from 'actions/repository';
|
||||
|
||||
export default {
|
||||
accountFetchCurrentUser,
|
||||
accountFetchUser,
|
||||
accountFetchUserEmails,
|
||||
accountSelectNamespace,
|
||||
billingCreatePaymentMethod,
|
||||
billingDeletePaymentMethod,
|
||||
billingFetchInvoices,
|
||||
billingFetchPaymentMethods,
|
||||
billingFetchProduct,
|
||||
billingFetchProfile,
|
||||
billingFetchProfileSubscriptions,
|
||||
billingSetDefaultPaymentMethod,
|
||||
billingUpdateProfile,
|
||||
repositoryFetchOwnedNamespaces,
|
||||
};
|
|
@ -1,266 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import InvoicesView from './Invoices';
|
||||
import PaymentMethodsView from './PaymentMethods';
|
||||
|
||||
import {
|
||||
accountFetchCurrentUser,
|
||||
accountFetchUser,
|
||||
accountFetchUserEmails,
|
||||
accountSelectNamespace,
|
||||
billingFetchInvoices,
|
||||
billingFetchPaymentMethods,
|
||||
billingFetchProfile,
|
||||
repositoryFetchOwnedNamespaces,
|
||||
} from './actions';
|
||||
import {
|
||||
AccountSelect,
|
||||
Card,
|
||||
FullscreenLoading,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from './lib/common';
|
||||
import {
|
||||
PAYMENTS,
|
||||
METHODS,
|
||||
} from './lib/constants';
|
||||
|
||||
import css from './styles.css';
|
||||
const { arrayOf, func, object, string, shape, bool } = PropTypes;
|
||||
|
||||
const mapStateToProps = ({ account, billing }) => {
|
||||
const {
|
||||
currentUser,
|
||||
namespaceObjects,
|
||||
ownedNamespaces,
|
||||
selectedNamespace,
|
||||
} = account;
|
||||
const {
|
||||
profiles,
|
||||
} = billing;
|
||||
return {
|
||||
billingProfiles: profiles,
|
||||
currentUser,
|
||||
namespaceObjects,
|
||||
ownedNamespaces,
|
||||
selectedNamespace,
|
||||
};
|
||||
};
|
||||
const dispatcher = {
|
||||
accountFetchUser,
|
||||
accountFetchUserEmails,
|
||||
accountFetchCurrentUser,
|
||||
accountSelectNamespace,
|
||||
billingFetchInvoices,
|
||||
billingFetchPaymentMethods,
|
||||
billingFetchProfile,
|
||||
repositoryFetchOwnedNamespaces,
|
||||
};
|
||||
|
||||
@connect(mapStateToProps, dispatcher)
|
||||
export default class BillingProfile extends Component {
|
||||
static propTypes={
|
||||
billingProfiles: shape({
|
||||
isFetching: bool.isRequired,
|
||||
results: object.isRequired,
|
||||
}),
|
||||
currentUser: object.isRequired,
|
||||
namespaceObjects: object.isRequired,
|
||||
// List of namespace strings owned by User
|
||||
ownedNamespaces: arrayOf(string),
|
||||
selectedNamespace: string.isRequired,
|
||||
// Action Props
|
||||
accountFetchUser: func.isRequired,
|
||||
accountFetchUserEmails: func.isRequired,
|
||||
accountFetchCurrentUser: func.isRequired,
|
||||
accountSelectNamespace: func.isRequired,
|
||||
billingFetchInvoices: func.isRequired,
|
||||
billingFetchPaymentMethods: func.isRequired,
|
||||
billingFetchProfile: func.isRequired,
|
||||
repositoryFetchOwnedNamespaces: func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
currentView: PAYMENTS,
|
||||
isInitializing: true,
|
||||
selectedNamespace: '',
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const {
|
||||
// actions
|
||||
accountFetchCurrentUser: fetchCurrentUser,
|
||||
accountFetchUserEmails: fetchUserEmails,
|
||||
accountSelectNamespace: selectNamespace,
|
||||
billingFetchInvoices: fetchInvoices,
|
||||
billingFetchPaymentMethods: fetchPaymentMethods,
|
||||
billingFetchProfile: fetchBillingProfiles,
|
||||
repositoryFetchOwnedNamespaces: fetchOwnedNamespaces,
|
||||
} = this.props;
|
||||
Promise.when([
|
||||
fetchCurrentUser().then((userRes) => {
|
||||
const { username: namespace, id: docker_id } = userRes.value;
|
||||
return Promise.when([
|
||||
fetchInvoices({ docker_id }),
|
||||
fetchUserEmails({ user: namespace }),
|
||||
selectNamespace({ namespace }),
|
||||
fetchBillingProfiles({ docker_id, isOrg: false }).then((res) => {
|
||||
if (res.value.profile) {
|
||||
fetchPaymentMethods({ docker_id });
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
fetchOwnedNamespaces(),
|
||||
]).then(() => {
|
||||
this.setState({ isInitializing: false });
|
||||
}).catch(() => {
|
||||
this.setState({ isInitializing: false });
|
||||
});
|
||||
}
|
||||
|
||||
onSelectPage = (e, value) => {
|
||||
this.setState({ currentView: value });
|
||||
}
|
||||
|
||||
onSelectNamespace = ({ value }) => {
|
||||
const {
|
||||
currentUser,
|
||||
namespaceObjects,
|
||||
// actions
|
||||
accountFetchUser: fetchUser,
|
||||
accountSelectNamespace: selectNamespace,
|
||||
billingFetchPaymentMethods: fetchPaymentMethods,
|
||||
billingFetchProfile: fetchBillingProfiles,
|
||||
billingFetchInvoices: fetchInvoices,
|
||||
} = this.props;
|
||||
this.setState({ initializing: true });
|
||||
const fetchedNamespaces = namespaceObjects.results;
|
||||
if (fetchedNamespaces[value]) {
|
||||
const userOrg = fetchedNamespaces[value];
|
||||
const { type, id: docker_id } = userOrg;
|
||||
const isOrg = type === 'Organization';
|
||||
return Promise.when([
|
||||
fetchInvoices({ docker_id }),
|
||||
selectNamespace({ namespace: value }),
|
||||
fetchBillingProfiles({ docker_id, isOrg }).then((res) => {
|
||||
if (res.value.profile) {
|
||||
fetchPaymentMethods({ docker_id });
|
||||
}
|
||||
}),
|
||||
]).then(() => {
|
||||
this.setState({
|
||||
isInitializing: false,
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setState({
|
||||
isInitializing: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
const isOrg = value !== currentUser.username;
|
||||
// fetching user info vs. org info locally hits different api's which
|
||||
// requires knowing which endpoint to hit.
|
||||
// unnecessary in production since v2/users will redirect to v2/orgs
|
||||
return fetchUser({ namespace: value, isOrg }).then((userRes) => {
|
||||
const { id: docker_id } = userRes.value;
|
||||
return Promise.when([
|
||||
fetchInvoices({ docker_id }),
|
||||
selectNamespace({ namespace: value }),
|
||||
fetchBillingProfiles({ docker_id, isOrg })
|
||||
.then((billingRes) => {
|
||||
if (billingRes.value.profile) {
|
||||
fetchPaymentMethods({ namespace: value, docker_id });
|
||||
}
|
||||
}),
|
||||
]).then(() => {
|
||||
this.setState({
|
||||
isInitializing: false,
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setState({
|
||||
isInitializing: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
generateSelectOptions(namespaces) {
|
||||
return namespaces.map((option) => {
|
||||
return { value: option, label: option };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
currentView,
|
||||
isInitializing,
|
||||
} = this.state;
|
||||
const {
|
||||
billingProfiles,
|
||||
namespaceObjects,
|
||||
ownedNamespaces,
|
||||
selectedNamespace,
|
||||
} = this.props;
|
||||
const isLoading = namespaceObjects.isFetching || billingProfiles.isFetching;
|
||||
if (isInitializing || isLoading) {
|
||||
return <FullscreenLoading />;
|
||||
}
|
||||
const selectedUser = namespaceObjects.results[selectedNamespace];
|
||||
const billingProfile = billingProfiles.results[selectedUser.id];
|
||||
// IF NAMESPACE HAS NO BILLING PROFILE RENDER EMPTY CARD
|
||||
if (!billingProfile) {
|
||||
// TODO: design - nathan 6/8/16 - MAKE PRETTIER NO CONTENT PAGE
|
||||
return (
|
||||
<div>
|
||||
<div className={css.emptyNav}>
|
||||
<AccountSelect
|
||||
className={css.select}
|
||||
options={this.generateSelectOptions(ownedNamespaces)}
|
||||
onSelectChange={this.onSelectNamespace}
|
||||
selectedNamespace={selectedNamespace}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.content}>
|
||||
<Card shadow>No Billing Profile to display</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let content;
|
||||
if (currentView === PAYMENTS) {
|
||||
content = <InvoicesView selectedUser={selectedUser} />;
|
||||
} else if (currentView === METHODS) {
|
||||
content = (
|
||||
<PaymentMethodsView
|
||||
selectedUser={selectedUser}
|
||||
billingProfile={billingProfile}
|
||||
/>);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={css.navigation}>
|
||||
<Tabs
|
||||
className={css.tabs}
|
||||
selected={currentView}
|
||||
onSelect={this.onSelectPage}
|
||||
>
|
||||
<Tab value={PAYMENTS}>Billing History</Tab>
|
||||
<Tab value={METHODS}>Payment Methods</Tab>
|
||||
</Tabs>
|
||||
<AccountSelect
|
||||
className={css.select}
|
||||
options={this.generateSelectOptions(ownedNamespaces)}
|
||||
onSelectChange={this.onSelectNamespace}
|
||||
selectedNamespace={selectedNamespace}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.content}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
export AccountSelect from '../AccountSelect';
|
||||
export Button from 'common/Button';
|
||||
export Card from 'common/Card';
|
||||
export DownloadIcon from 'common/Icon/lib/Download';
|
||||
export FetchingError from 'common/FetchingError';
|
||||
export FullscreenLoading from 'common/FullscreenLoading';
|
||||
export Input from 'common/Input';
|
||||
export Select from 'common/Select';
|
||||
export Tab from 'common/Tab';
|
||||
export Tabs from 'common/Tabs';
|
||||
export WarningIcon from 'common/Icon/lib/Warning';
|
|
@ -1,17 +0,0 @@
|
|||
const PAYMENTS = 'Payments';
|
||||
const METHODS = 'Methods';
|
||||
const TINY = 'tiny';
|
||||
const SMALL = 'small';
|
||||
const REGULAR = 'regular';
|
||||
const LARGE = 'large';
|
||||
const XLARGE = 'xlarge';
|
||||
|
||||
export default {
|
||||
PAYMENTS,
|
||||
METHODS,
|
||||
TINY,
|
||||
SMALL,
|
||||
REGULAR,
|
||||
LARGE,
|
||||
XLARGE,
|
||||
};
|
|
@ -1,8 +0,0 @@
|
|||
const countries = require('country-list')();
|
||||
|
||||
export const countryOptions = countries.getCodes().map(code => {
|
||||
const label = countries.getName(code);
|
||||
return { label, value: code };
|
||||
});
|
||||
|
||||
export const countryGetCodeFromName = name => countries.getCode(name);
|
|
@ -1 +0,0 @@
|
|||
export const noOp = () => {};
|
|
@ -1,22 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.navigation {
|
||||
@mixin 2columns 6, 6;
|
||||
}
|
||||
|
||||
.emptyNav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: $space-xxl 0;
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
/* eslint-disable max-len */
|
||||
|
||||
/* Evaluate DDC on */
|
||||
|
||||
export const evaluationInstructions = {
|
||||
toInstallDDC: {
|
||||
1: 'Get Docker Toolbox',
|
||||
2: 'Download license into your project directory',
|
||||
3: 'Run the following command in the same directory',
|
||||
},
|
||||
pullCommand: '$ docker run alexmavr/ddc-in-a-box | bash',
|
||||
scriptTiming: 'The script will take approximately 15-20 mins depending on your internet connectivity.',
|
||||
};
|
||||
|
||||
// Right side Additional Information
|
||||
export const additionalInformation = [
|
||||
{
|
||||
title: 'System Requirements',
|
||||
bulletPoints: [
|
||||
'5GB hard drive space',
|
||||
'2GB of RAM',
|
||||
'Internet connectivity (downloads ~4GB)',
|
||||
'docker-machine & bash (included in Docker Toolbox)',
|
||||
'Your license file downloaded to a directory on your machine',
|
||||
],
|
||||
}, {
|
||||
title: 'What this container does',
|
||||
bulletPoints: [
|
||||
'Creates a VM using docker-machine',
|
||||
'Installs Universal Control Plane 1.1',
|
||||
'Installs Trusted Registry 2.0',
|
||||
'Configures authentication in both systems with a default user',
|
||||
'Configures licensing in both products',
|
||||
'Configures UCP to trust the registry service in DTR',
|
||||
],
|
||||
}, {
|
||||
title: 'Advanced Options',
|
||||
bulletPoints: [
|
||||
'You can set 3 environment variables for this script, related to the use of docker-machine:',
|
||||
],
|
||||
environmentVariables: [
|
||||
{
|
||||
variable: 'MACHINE_DRIVER',
|
||||
description: 'The default is \'virtualbox\'',
|
||||
}, {
|
||||
variable: 'MACHINE_DRIVER_FLAGS',
|
||||
description: 'The default is "--virtualbox-memory 2048 --virtualbox-disk-size 16000"',
|
||||
}, {
|
||||
variable: 'MACHINE_NAME',
|
||||
description: 'The default is = \'ddc-eval\'',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* Deploy on */
|
||||
|
||||
export const AWS = {
|
||||
label: 'AWS using Quickstart',
|
||||
url: 'https://console.aws.amazon.com/cloudformation/home?#/stacks/new?stackName=DockerDatacenter&templateURL=https://s3-us-west-2.amazonaws.com/ddc-on-aws-public/ddc_on_aws.json',
|
||||
};
|
||||
|
||||
export const Azure = {
|
||||
label: 'Microsoft Azure from the Marketplace ',
|
||||
url: 'https://azure.microsoft.com/en-us/marketplace/partners/docker/dockerdatacenterdocker-datacenter/',
|
||||
};
|
||||
|
||||
export const Linux = {
|
||||
label: 'Linux',
|
||||
url: 'https://docs.docker.com/docker-trusted-registry/cs-engine/install/',
|
||||
};
|
||||
|
||||
// Links in the right column
|
||||
|
||||
export const UCPGuide = {
|
||||
label: 'Guide for Universal Control Plane',
|
||||
url: 'https://docs.docker.com/ucp/install-sandbox/',
|
||||
};
|
||||
|
||||
export const DTRGuide = {
|
||||
label: 'Guide for Docker Trusted Registry',
|
||||
url: 'https://docs.docker.com/docker-trusted-registry/install/install-dtr/',
|
||||
};
|
||||
|
||||
export const prodInstall = {
|
||||
label: 'Planning a Production Ready Install',
|
||||
url: 'https://docs.docker.com/ucp/installation/plan-production-install/',
|
||||
};
|
|
@ -1,185 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import {
|
||||
ClockIcon,
|
||||
AmazonWebServicesIcon,
|
||||
AzureIcon,
|
||||
AppleIcon,
|
||||
WindowsTextIcon,
|
||||
LinuxIcon,
|
||||
BackButtonArea,
|
||||
CopyPullCommand,
|
||||
Expand,
|
||||
} from 'common';
|
||||
import {
|
||||
additionalInformation,
|
||||
AWS,
|
||||
Azure,
|
||||
DTRGuide,
|
||||
evaluationInstructions,
|
||||
Linux,
|
||||
prodInstall,
|
||||
UCPGuide,
|
||||
} from './constants.js';
|
||||
import map from 'lodash/map';
|
||||
import { DDC_ID } from 'lib/constants/eusa';
|
||||
import css from './styles.css';
|
||||
const { func } = PropTypes;
|
||||
|
||||
export default class DDCInstructions extends Component {
|
||||
static propTypes = {
|
||||
showSubscriptionDetail: func.isRequired,
|
||||
}
|
||||
|
||||
showSubscriptionDetail = () => {
|
||||
this.props.showSubscriptionDetail(DDC_ID);
|
||||
}
|
||||
|
||||
// utility method to generate links
|
||||
linkTo = ({ url, label }) => {
|
||||
return <a href={url} className={css.link} target="_blank">{label}</a>;
|
||||
}
|
||||
|
||||
renderEvaluateDDCOn() {
|
||||
const {
|
||||
toInstallDDC,
|
||||
pullCommand,
|
||||
scriptTiming,
|
||||
} = evaluationInstructions;
|
||||
const clock = <ClockIcon className={css.clock} />;
|
||||
const toolboxLink = 'https://docs.docker.com/toolbox/overview/';
|
||||
const getDockerLink = this.linkTo({
|
||||
label: toInstallDDC[1],
|
||||
url: toolboxLink,
|
||||
});
|
||||
const [sysRequirements, scriptDoes, advOptions] = additionalInformation;
|
||||
const mkBulletPoints = (text, index) => {
|
||||
return <li key={index} className={css.expandContent}>{text}</li>;
|
||||
};
|
||||
const mkEnvVars = ({ variable, description }, index) => {
|
||||
return (
|
||||
<li key={index} className={css.expandContentNoBullet}>
|
||||
<div className={css.envVar}>{variable}</div>
|
||||
<div>{description}</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<div>
|
||||
<div className={css.sectionTitle}>
|
||||
<span className={css.semiBold}>{'Option 1: '}</span>
|
||||
Evaluate a single node on your desktop
|
||||
</div>
|
||||
<div>
|
||||
<AppleIcon className={css.logo} />
|
||||
<WindowsTextIcon className={css.logo} />
|
||||
</div>
|
||||
<div className={css.installSteps}>
|
||||
<div>1. {getDockerLink}</div>
|
||||
<div>2. {toInstallDDC[2]}</div>
|
||||
<div>3. {toInstallDDC[3]}</div>
|
||||
</div>
|
||||
<div className={css.pullCommand}>
|
||||
<CopyPullCommand fullCommand={pullCommand} hasInstruction={false} />
|
||||
</div>
|
||||
<div className={css.licenseHelpText}>{clock} {scriptTiming}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={css.listTitle}>Additional Information</div>
|
||||
<div className={css.expandWrapper}>
|
||||
<Expand title={sysRequirements.title}>
|
||||
<ul className={css.list}>
|
||||
{map(sysRequirements.bulletPoints, mkBulletPoints)}
|
||||
</ul>
|
||||
</Expand>
|
||||
</div>
|
||||
<div className={css.expandWrapper}>
|
||||
<Expand title={scriptDoes.title}>
|
||||
<ul className={css.list}>
|
||||
{map(scriptDoes.bulletPoints, mkBulletPoints)}
|
||||
</ul>
|
||||
</Expand>
|
||||
</div>
|
||||
<div className={css.expandWrapper}>
|
||||
<Expand title={advOptions.title}>
|
||||
<ul className={css.list}>
|
||||
<li className={css.expandContentNoBullet}>
|
||||
{advOptions.bulletPoints[0]}
|
||||
</li>
|
||||
{map(advOptions.environmentVariables, mkEnvVars)}
|
||||
</ul>
|
||||
</Expand>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderGuideLink = ({ url, label }) => {
|
||||
return (
|
||||
<div key={label} className={css.guideLink}>
|
||||
{this.linkTo({ url, label })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderDeployOn = () => {
|
||||
const guideLinks = [
|
||||
UCPGuide,
|
||||
DTRGuide,
|
||||
prodInstall,
|
||||
];
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<div>
|
||||
<div className={css.sectionTitle}>
|
||||
<span className={css.semiBold}>{'Option 2: '}</span>
|
||||
Deploy on
|
||||
</div>
|
||||
<div className={css.deployBlocks}>
|
||||
<div className={css.deployBlock}>
|
||||
<AmazonWebServicesIcon className={css.logo} />
|
||||
{this.linkTo({ url: AWS.url, label: AWS.label })}
|
||||
</div>
|
||||
<div className={css.deployBlock}>
|
||||
<AzureIcon className={css.logo} />
|
||||
{this.linkTo({ url: Azure.url, label: Azure.label })}
|
||||
</div>
|
||||
<div className={css.deployBlock}>
|
||||
<LinuxIcon className={css.logo} />
|
||||
{this.linkTo({ url: Linux.url, label: Linux.label })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className={css.listTitle}>Resources</div>
|
||||
{map(guideLinks, this.renderGuideLink)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const supportText =
|
||||
'Having trouble getting setup? Please contact ';
|
||||
const mailTo = 'mailto:support@docker.com?subject=Docker Datacenter';
|
||||
const supportLink =
|
||||
this.linkTo({ url: mailTo, label: 'support@docker.com' });
|
||||
return (
|
||||
<div>
|
||||
<BackButtonArea
|
||||
onClick={this.showSubscriptionDetail}
|
||||
text="Docker Datacenter"
|
||||
/>
|
||||
<div className={css.title}>
|
||||
Getting Started with Docker Datacenter
|
||||
</div>
|
||||
{this.renderEvaluateDDCOn()}
|
||||
<hr className={css.hr} />
|
||||
{this.renderDeployOn()}
|
||||
<hr className={css.hr} />
|
||||
<div>{supportText} {supportLink}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,119 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.hr {
|
||||
margin-top: $space-xxl;
|
||||
margin-bottom: $space-xxl;
|
||||
}
|
||||
|
||||
.title {
|
||||
@mixin fontSize 6;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
color: $color-fiord;
|
||||
}
|
||||
|
||||
.semiBold {
|
||||
@mixin semiBold;
|
||||
}
|
||||
|
||||
/* must scale svgs with inline styles/width/height set */
|
||||
.logo {
|
||||
height: $icon-size-xlarge !important;
|
||||
width: auto !important;
|
||||
margin-bottom: $space-md;
|
||||
}
|
||||
|
||||
.logo + .logo {
|
||||
margin-left: $space-xxl;
|
||||
}
|
||||
|
||||
.section {
|
||||
@mixin 2columns 8, 4;
|
||||
@mixin fontSize 2;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-bottom: $space-xxl;
|
||||
}
|
||||
|
||||
.listTitle {
|
||||
@mixin fontSize 3;
|
||||
margin-bottom: $space-md;
|
||||
}
|
||||
|
||||
/* Evaluating */
|
||||
.installSteps {
|
||||
margin-bottom: $space-lg;
|
||||
}
|
||||
|
||||
.pullCommand {
|
||||
width: 60%;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.licenseHelpText {
|
||||
@mixin fontSize 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: $color-regent-gray;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.clock {
|
||||
@mixin square $space-md;
|
||||
fill: $color-regent-gray;
|
||||
margin-right: $space-xs;
|
||||
}
|
||||
|
||||
/* Additional Information */
|
||||
$ul-padding: $space-xxxl;
|
||||
$li-padding: $space-sm;
|
||||
|
||||
.expandWrapper {
|
||||
margin-bottom: $space-lg;
|
||||
}
|
||||
|
||||
.expandContent {
|
||||
color: $color-regent-gray;
|
||||
margin-bottom: $space-md;
|
||||
padding-left: $li-padding;
|
||||
}
|
||||
|
||||
.expandContentNoBullet {
|
||||
composes: expandContent;
|
||||
|
||||
/* Hide the bullet point */
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding-left: $ul-padding;
|
||||
}
|
||||
|
||||
.envVar {
|
||||
text-transform: uppercase;
|
||||
color: $color-fiord;
|
||||
}
|
||||
|
||||
/* Deploy and Resources */
|
||||
.guideLink {
|
||||
margin-bottom: $space-md;
|
||||
}
|
||||
|
||||
.deployBlocks {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.deployBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
margin-right: $space-xxxxl;
|
||||
}
|
|
@ -1,587 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
BackButtonArea,
|
||||
Button,
|
||||
Card,
|
||||
CheckIcon,
|
||||
CloseIcon,
|
||||
CopyPullCommand,
|
||||
DownloadIcon,
|
||||
ImageWithFallback,
|
||||
Markdown,
|
||||
Modal,
|
||||
} from 'common';
|
||||
import { FALLBACK_IMAGE_SRC, FALLBACK_ELEMENT } from 'lib/constants/fallbacks';
|
||||
import filter from 'lodash/filter';
|
||||
import find from 'lodash/find';
|
||||
import map from 'lodash/map';
|
||||
import startCase from 'lodash/startCase';
|
||||
// Blob is a polyfill to support older browsers
|
||||
require('lib/utils/blob');
|
||||
import { saveAs } from 'lib/utils/file-saver';
|
||||
import moment from 'moment';
|
||||
import classnames from 'classnames';
|
||||
import getLogo from 'lib/utils/get-largest-logo';
|
||||
import { SMALL } from 'lib/constants/sizes';
|
||||
import isStaging from 'lib/utils/isStaging';
|
||||
import isDev from 'lib/utils/isDevelopment';
|
||||
import { isPullableProduct } from 'lib/utils/product-utils';
|
||||
import { DOCKER, DOCKER_GUID } from 'lib/constants/defaults';
|
||||
import { DDC_TRIAL_PLAN, DDC_ID } from 'lib/constants/eusa';
|
||||
import { ACTIVE, CANCELLED } from 'lib/constants/states/subscriptions';
|
||||
import {
|
||||
billingDeleteSubscription,
|
||||
billingFetchLicenseFile,
|
||||
billingFetchProfileSubscriptions,
|
||||
} from 'actions/billing';
|
||||
import css from './styles.css';
|
||||
const { arrayOf, bool, func, object, shape, string } = PropTypes;
|
||||
|
||||
// Has a subscription been created within the past day?
|
||||
const isNewSubscription = (subscription) => {
|
||||
const YESTERDAY = moment().subtract(1, 'days').startOf('day');
|
||||
return moment(subscription.initial_period_start).isAfter(YESTERDAY);
|
||||
};
|
||||
|
||||
const isExpiredLicense = (expiration) => {
|
||||
return moment().isAfter(expiration);
|
||||
};
|
||||
|
||||
const dispatcher = {
|
||||
deleteSubscription: billingDeleteSubscription,
|
||||
fetchLicenseFile: billingFetchLicenseFile,
|
||||
fetchSubscriptions: billingFetchProfileSubscriptions,
|
||||
};
|
||||
|
||||
@connect(null, dispatcher)
|
||||
export default class SubscriptionDetail extends Component {
|
||||
static propTypes = {
|
||||
deleteInfo: shape({
|
||||
error: string,
|
||||
isDeleting: bool,
|
||||
subscription_id: string,
|
||||
}),
|
||||
deleteSubscription: func.isRequired,
|
||||
error: string,
|
||||
fetchLicenseFile: func.isRequired,
|
||||
fetchSubscriptions: func.isRequired,
|
||||
licenses: shape({
|
||||
isFetching: bool,
|
||||
// Licenses are keyed off of subscription_id
|
||||
results: object,
|
||||
error: string,
|
||||
}),
|
||||
selectedNamespace: string.isRequired,
|
||||
selectedProductSubscriptions: arrayOf(object).isRequired,
|
||||
showDDCInstructions: func.isRequired,
|
||||
showSubscriptionList: func.isRequired,
|
||||
selectedUserOrOrg: object.isRequired,
|
||||
}
|
||||
|
||||
state = {
|
||||
cancelSubscriptionId: '',
|
||||
cancelSubscriptionName: '',
|
||||
showCancelConfirmation: false,
|
||||
downloadLicenseSubscriptionId: '',
|
||||
downloadLicenseError: false,
|
||||
}
|
||||
|
||||
hideCancelConfirmation = () => {
|
||||
this.setState({
|
||||
cancelSubscriptionId: '',
|
||||
cancelSubscriptionName: '',
|
||||
showCancelConfirmation: false,
|
||||
});
|
||||
}
|
||||
|
||||
showCancelConfirmation = ({ subscription_id, subscription_name }) => () => {
|
||||
this.setState({
|
||||
cancelSubscriptionId: subscription_id,
|
||||
cancelSubscriptionName: subscription_name,
|
||||
showCancelConfirmation: true,
|
||||
});
|
||||
}
|
||||
|
||||
cancelSubscription = () => {
|
||||
const { cancelSubscriptionId: subscription_id } = this.state;
|
||||
const {
|
||||
deleteSubscription,
|
||||
fetchSubscriptions,
|
||||
selectedUserOrOrg,
|
||||
} = this.props;
|
||||
deleteSubscription({ subscription_id }).then(() => {
|
||||
fetchSubscriptions({ docker_id: selectedUserOrOrg.id });
|
||||
});
|
||||
}
|
||||
|
||||
downloadLicense = ({
|
||||
subscription_id,
|
||||
subscription_name,
|
||||
product_rate_plan,
|
||||
}) => () => {
|
||||
const { fetchLicenseFile } = this.props;
|
||||
this.setState({
|
||||
downloadLicenseSubscriptionId: subscription_id,
|
||||
downloadLicenseError: false,
|
||||
}, () => {
|
||||
fetchLicenseFile({ subscription_id })
|
||||
.then((res) => {
|
||||
// value contains the API response
|
||||
const downloadContent = JSON.stringify(res.value);
|
||||
this.setState({ downloadLicenseSubscriptionId: '' });
|
||||
const blob = new Blob([downloadContent], {
|
||||
type: 'text/plain;charset=utf-8',
|
||||
});
|
||||
saveAs(blob, `${subscription_name}.lic`, true);
|
||||
analytics.track('download_license', {
|
||||
license_tier: product_rate_plan,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({ downloadLicenseError: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Returns true if the given subscription can be cancelled, false otherwise.
|
||||
//
|
||||
// A subscription may not be cancelled if:
|
||||
// 1. The initiator does not own the subscription OR
|
||||
// 2. The subscription is already in a cancelled state OR
|
||||
// 3. This is an 'offline' subscription.
|
||||
// Note that the presence of a subscription origin implies that this
|
||||
// is an offline subscription.
|
||||
canCancelSubscription(subscription) {
|
||||
const { isOwner } = this.props.selectedUserOrOrg;
|
||||
const { state, origin } = subscription;
|
||||
|
||||
if (!isOwner) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state === CANCELLED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
renderProductSubscriptionSummary = (subs) => {
|
||||
// Subs is an array containing one or more subscriptions for the
|
||||
// same product id. When rendering the *product* information (and
|
||||
// aggregated subscription info), we must look at at least one subscription
|
||||
// to understand the product information. There is guaranteed to be at least
|
||||
// one sub, so we can get the information out of the first one.
|
||||
if (!subs || !subs.length) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
label: productName,
|
||||
links,
|
||||
publisher_id,
|
||||
publisher,
|
||||
} = subs[0];
|
||||
const numActiveSubscriptions =
|
||||
filter(subs, ({ state }) => state === ACTIVE).length;
|
||||
let partnerName;
|
||||
let dockerPublisherID = DOCKER_GUID;
|
||||
if (isStaging() || isDev()) {
|
||||
dockerPublisherID = DOCKER;
|
||||
}
|
||||
if (publisher_id !== dockerPublisherID) {
|
||||
partnerName = (
|
||||
<div className={css.partnerName}>
|
||||
<CheckIcon className={css.check} size={SMALL} />
|
||||
{publisher.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const addS = numActiveSubscriptions === 1 ? '' : 's';
|
||||
const licenses = (
|
||||
<span>{`${numActiveSubscriptions} active subscription${addS}`}</span>
|
||||
);
|
||||
let licenseAgreement;
|
||||
// Add license agreement if it exists in the links
|
||||
if (links) {
|
||||
const link = find(links, ({ label }) => label === 'License Agreement');
|
||||
if (link) {
|
||||
const { label: text, url } = link;
|
||||
licenseAgreement =
|
||||
<span> | <a href={url} target="_blank">{text}</a></span>;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={css.product}>
|
||||
<div className={css.productName}>{productName}</div>
|
||||
{partnerName}
|
||||
</div>
|
||||
<div className={css.nameAndLicense}>
|
||||
{this.props.selectedNamespace} | {licenses} {licenseAgreement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderCancelSubscriptionLine(subscription) {
|
||||
const { isDeleting, subscription_id: deletingId } = this.props.deleteInfo;
|
||||
const { subscription_name, subscription_id } = subscription;
|
||||
|
||||
if (!this.canCancelSubscription(subscription)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDeletingThisSub = isDeleting && deletingId === subscription_id;
|
||||
if (isDeletingThisSub) {
|
||||
return <div>Canceling Subscription...</div>;
|
||||
}
|
||||
const subInfo = { subscription_id, subscription_name };
|
||||
return (
|
||||
<div
|
||||
className={css.underlined}
|
||||
onClick={this.showCancelConfirmation(subInfo)}
|
||||
>
|
||||
Cancel Subscription
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderPullCommand(subscription) {
|
||||
// TODO Kristie 8/17/19 Fix this when we actually set up the new
|
||||
// subscriptions page (and per sub pull commands)
|
||||
// This is a known issue until then
|
||||
const {
|
||||
plans: productPlans,
|
||||
product_rate_plan: currentRatePlanID,
|
||||
} = subscription;
|
||||
// TODO Kristie 8/17/19 This needs to be synced with billing
|
||||
let currentPlanDetail = find(productPlans, ({ id }) => {
|
||||
return id === currentRatePlanID;
|
||||
});
|
||||
// TODO Kristie 8/17/19 Remove this when billing plan ids are in sync with
|
||||
// the product plan ids
|
||||
if (!currentPlanDetail) {
|
||||
currentPlanDetail = productPlans[0];
|
||||
}
|
||||
const { repositories, default_version } = currentPlanDetail;
|
||||
// Assumption: there is only one repo per rate plan - this may change in the
|
||||
// future
|
||||
if (!repositories || !repositories[0]) {
|
||||
return null;
|
||||
}
|
||||
const { namespace, reponame } = repositories[0];
|
||||
return (
|
||||
<CopyPullCommand
|
||||
codeClassName={css.pullText}
|
||||
namespace={namespace}
|
||||
reponame={reponame}
|
||||
tag={default_version && default_version.linux}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderPricingComponents(subscription) {
|
||||
const {
|
||||
pricing_components,
|
||||
// The specific rate plan that this subscription is subscribed to
|
||||
product_rate_plan: sub_rate_plan,
|
||||
// All possible rate plans and their pricing components for this product
|
||||
rate_plans,
|
||||
} = subscription;
|
||||
// Find the rate plan that matches this subscription's rate plan
|
||||
const plan = find(rate_plans, (rp) => rp.name === sub_rate_plan);
|
||||
// Do not show this user cancelled subscriptions if they are not an owner
|
||||
if (!plan || !pricing_components || !pricing_components.length) {
|
||||
return null;
|
||||
}
|
||||
const comps = pricing_components.map((pc) => {
|
||||
// Find the pricing component (with full information) from the billing
|
||||
// plan which matches this subscription's pricing component
|
||||
const priceCompInfo = find(plan.pricing_components, comp => {
|
||||
// Comp is the full information from billing api, pc is this sub's
|
||||
// data with the amount purchased (value)
|
||||
return comp === pc.name;
|
||||
});
|
||||
// Fallback to this subscription's component "name"
|
||||
const label = priceCompInfo && priceCompInfo.label || pc.name;
|
||||
return `${pc.value} ${label}`;
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<div>{plan.label}</div>
|
||||
<div>{comps.join(', ')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderDownloadLicense(subscription) {
|
||||
const { downloadLicenseError, downloadLicenseSubscriptionId } = this.state;
|
||||
const { subscription_id } = subscription;
|
||||
const isDownloadingThisLicense =
|
||||
subscription_id === downloadLicenseSubscriptionId;
|
||||
const downloadError = isDownloadingThisLicense && downloadLicenseError;
|
||||
let text = 'License Key';
|
||||
if (isDownloadingThisLicense && !downloadError) {
|
||||
text = 'Downloading License...';
|
||||
} else if (isDownloadingThisLicense && downloadError) {
|
||||
text = 'Error Downloading License';
|
||||
}
|
||||
const icon = <DownloadIcon size={SMALL} />;
|
||||
const classes = classnames({
|
||||
[css.downloadLicense]: true,
|
||||
[css.downloadLicenseError]: isDownloadingThisLicense && downloadError,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
onClick={this.downloadLicense(subscription)}
|
||||
>
|
||||
{icon} {text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderLicense(subscription) {
|
||||
const { isOwner } = this.props.selectedUserOrOrg;
|
||||
const { subscription_id, eusa, state, product_rate_plan } = subscription;
|
||||
const { results } = this.props.licenses;
|
||||
// Find this license in results
|
||||
const license = results[subscription_id];
|
||||
// Do not render a license for a cancelled subscription
|
||||
if (!license || state === CANCELLED) {
|
||||
return null;
|
||||
}
|
||||
const { expiration } = license;
|
||||
const isLicenseExpired = isExpiredLicense(expiration);
|
||||
const isLegalAccepted = eusa && eusa.accepted;
|
||||
let licenseLine;
|
||||
|
||||
if (!isLegalAccepted) {
|
||||
const text = isOwner ? 'Accept terms to download'
|
||||
: 'Admin must accept terms to download';
|
||||
licenseLine = <div>{text}</div>;
|
||||
} else if (isLicenseExpired) {
|
||||
licenseLine = <div>Expired License</div>;
|
||||
} else {
|
||||
licenseLine = this.renderDownloadLicense(subscription);
|
||||
}
|
||||
let expirationDate;
|
||||
// TODO Kristie 6/10/16 Incorporate other non-recurring subscriptions
|
||||
if (product_rate_plan === DDC_TRIAL_PLAN) {
|
||||
expirationDate = <div>Expires {moment(expiration).format('l')}</div>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{expirationDate}
|
||||
{licenseLine}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A single subscription for this product
|
||||
renderSubscriptionLine = (subscription) => {
|
||||
const { subscription_name, subscription_id, state } = subscription;
|
||||
const { isOwner } = this.props.selectedUserOrOrg;
|
||||
if (!isOwner && state === CANCELLED) {
|
||||
return null;
|
||||
}
|
||||
// TODO Kristie 6/1/16 Include edit sub name
|
||||
// TODO Kristie 6/1/16 Include purchased by or registered
|
||||
return (
|
||||
<div key={subscription_id}>
|
||||
<hr />
|
||||
<div className={css.subscriptionLine} >
|
||||
<div>
|
||||
<div className={css.subscriptionName}>{subscription_name}</div>
|
||||
{this.renderPricingComponents(subscription)}
|
||||
{this.renderCancelSubscriptionLine(subscription)}
|
||||
</div>
|
||||
<div className={css.licenseInformation}>
|
||||
<div className={css.licenseStatus}>{startCase(state)}</div>
|
||||
{this.renderLicense(subscription)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderNewSubscriptionNotification() {
|
||||
const { selectedProductSubscriptions: subs } = this.props;
|
||||
if (!subs || !subs.length) {
|
||||
return null;
|
||||
}
|
||||
const { name, label } = subs[0];
|
||||
// TODO Get hint from API for paid subscription or not
|
||||
const isProductPaid = name === 'devddc';
|
||||
const newSubscriptions = filter(subs, (s) => isNewSubscription(s));
|
||||
if (!newSubscriptions.length) {
|
||||
return null;
|
||||
}
|
||||
let header;
|
||||
const details = 'Your subscription details are below.';
|
||||
if (isProductPaid) {
|
||||
header = `Payment Successful! Thank you for purchasing ${label}`;
|
||||
} else {
|
||||
header = `Thank you for subscribing to ${label}`;
|
||||
}
|
||||
return (
|
||||
<Card className={css.newSubscription} shadow>
|
||||
<div className={css.notificationHeader}>{header}</div>
|
||||
{details}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
renderErrorNotification() {
|
||||
// TODO Kristie 6/2/16 Error dismissal
|
||||
const { error } = this.props.deleteInfo;
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Card className={css.error} shadow>
|
||||
<div className={css.notificationHeader}>Error</div>
|
||||
{['Sorry, we could not cancel your subscription.',
|
||||
'Please try again.'].join(' ')}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
renderCancelSubscriptionModal = () => {
|
||||
const {
|
||||
cancelSubscriptionId,
|
||||
cancelSubscriptionName,
|
||||
showCancelConfirmation,
|
||||
} = this.state;
|
||||
const { isDeleting, subscription_id: deletingId } = this.props.deleteInfo;
|
||||
const isDeletingThisSub = cancelSubscriptionId === deletingId && isDeleting;
|
||||
return (
|
||||
<Modal
|
||||
isOpen={showCancelConfirmation}
|
||||
className={css.modal}
|
||||
onRequestClose={this.hideCancelConfirmation}
|
||||
>
|
||||
<div
|
||||
className={css.closeModal}
|
||||
onClick={this.hideCancelConfirmation}
|
||||
>
|
||||
<CloseIcon size={SMALL} />
|
||||
</div>
|
||||
<div className={css.cancelHeader}>
|
||||
{`Cancel ${cancelSubscriptionName}?`}
|
||||
</div>
|
||||
<div className={css.cancelDetail}>
|
||||
This action cannot be undone.
|
||||
</div>
|
||||
<Button onClick={this.cancelSubscription}>
|
||||
{isDeletingThisSub ? 'Cancelling...' : 'Cancel Subscription'}
|
||||
</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
renderDDCInstructions() {
|
||||
const setupText = 'To install DDC on your machine, VM, or cloud instance ';
|
||||
const instructionsLink = (
|
||||
<div className={css.link} onClick={this.props.showDDCInstructions}>
|
||||
follow these instructions
|
||||
</div>
|
||||
);
|
||||
const supportText =
|
||||
'If you need assistance with your subscription please contact';
|
||||
const mailTo = 'mailto:support@docker.com?subject=Docker Datacenter';
|
||||
const supportLink = (
|
||||
<a href={mailTo} className={css.link}>{'support@docker.com'}</a>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className={css.instructionsSection}>
|
||||
<div className={css.instructionsHeader}>Setup Guide</div>
|
||||
<div className={css.instructionsDetail}>
|
||||
{setupText} {instructionsLink}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.instructionsSection}>
|
||||
<div className={css.instructionsHeader}>Customer Support</div>
|
||||
<div className={css.instructionsDetail}>
|
||||
{supportText} {supportLink}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderInstructions() {
|
||||
const { selectedProductSubscriptions: subs } = this.props;
|
||||
// At least one subscription otherwise render would have returned null
|
||||
const { product_id, instructions } = subs[0];
|
||||
if (product_id === DDC_ID) {
|
||||
return this.renderDDCInstructions();
|
||||
}
|
||||
if (!instructions) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Markdown rawMarkdown={instructions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderSubscriptionHeader = (subs) => {
|
||||
const sampleSub = subs[0];
|
||||
const { logo_url, product_id } = sampleSub;
|
||||
const src = getLogo(logo_url);
|
||||
let pullCommand;
|
||||
if (isPullableProduct(product_id)) {
|
||||
pullCommand = this.renderPullCommand(sampleSub);
|
||||
}
|
||||
return (
|
||||
<div className={css.summaryAndPullCommand}>
|
||||
<div className={css.subscriptionSummary}>
|
||||
<ImageWithFallback
|
||||
src={src}
|
||||
className={css.icon}
|
||||
fallbackImage={FALLBACK_IMAGE_SRC}
|
||||
fallbackElement={FALLBACK_ELEMENT}
|
||||
/>
|
||||
{this.renderProductSubscriptionSummary(subs)}
|
||||
</div>
|
||||
{pullCommand}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedProductSubscriptions: subs,
|
||||
showSubscriptionList,
|
||||
} = this.props;
|
||||
if (!subs || !subs.length) {
|
||||
return null;
|
||||
}
|
||||
const { product_id } = subs[0];
|
||||
const maybeNewSubNotification = this.renderNewSubscriptionNotification();
|
||||
const maybeErrorNotication = this.renderErrorNotification();
|
||||
const maybeCancelSubscriptionModal = this.renderCancelSubscriptionModal();
|
||||
const maybeInstructions = this.renderInstructions();
|
||||
return (
|
||||
<div>
|
||||
<BackButtonArea onClick={showSubscriptionList} text="Subscriptions" />
|
||||
<Card key={product_id} className={css.subscriptionDetailCard} shadow>
|
||||
{maybeNewSubNotification}
|
||||
{maybeErrorNotication}
|
||||
{this.renderSubscriptionHeader(subs)}
|
||||
{map(subs, this.renderSubscriptionLine)}
|
||||
{maybeCancelSubscriptionModal}
|
||||
</Card>
|
||||
{maybeInstructions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,178 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
$card-padding: 64px;
|
||||
|
||||
.product {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: $space-xs;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@mixin storeIcon;
|
||||
margin-right: $space-xl;
|
||||
}
|
||||
|
||||
.nameAndLicense {
|
||||
@mixin fontSize 2;
|
||||
color: $color-regent-gray;
|
||||
}
|
||||
|
||||
.subscriptionSummary,
|
||||
.partnerName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summaryAndPullCommand {
|
||||
@mixin 2columnsResponsive 7, 5;
|
||||
}
|
||||
|
||||
.pullText {
|
||||
@mixin fontSize 1;
|
||||
}
|
||||
|
||||
.productName {
|
||||
@mixin fontSize 4;
|
||||
@mixin semiBold;
|
||||
margin-right: $space-xs;
|
||||
}
|
||||
|
||||
.check {
|
||||
fill: $color-robins-egg-blue;
|
||||
margin-right: $space-xxs;
|
||||
}
|
||||
|
||||
.marketplaceIcon {
|
||||
fill: $color-regent-gray;
|
||||
margin-left: $space-xxs;
|
||||
}
|
||||
|
||||
.subscriptionDetailCard {
|
||||
padding: $card-padding;
|
||||
margin-bottom: $space-xxl;
|
||||
}
|
||||
|
||||
.subscriptionLine {
|
||||
@mixin fontSize 2;
|
||||
color: $color-regent-gray;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: calc($space-xl - 1em);
|
||||
padding-bottom: calc($space-xl - 1em);
|
||||
}
|
||||
|
||||
.subscriptionName,
|
||||
.licenseStatus {
|
||||
@mixin fontSize 3;
|
||||
color: $color-fiord;
|
||||
}
|
||||
|
||||
.subscriptionName {
|
||||
@mixin semiBold;
|
||||
}
|
||||
|
||||
.licenseInformation {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.downloadLicense {
|
||||
@mixin fontSize 2;
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
color: $color-dodger-blue;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
cursor: pointer;
|
||||
& > svg {
|
||||
fill: $color-dodger-blue;
|
||||
margin-right: $space-xxs;
|
||||
}
|
||||
}
|
||||
|
||||
.downloadLicenseError {
|
||||
color: $color-carnation;
|
||||
& > svg {
|
||||
fill: $color-carnation;
|
||||
}
|
||||
}
|
||||
|
||||
.newSubscription {
|
||||
background-color: $color-robins-egg-blue;
|
||||
box-shadow: 0 2px 4px 0 $color-regent-gray;
|
||||
margin-bottom: $space-xxxl;
|
||||
color: $color-white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: $color-carnation;
|
||||
box-shadow: 0 2px 4px 0 $color-regent-gray;
|
||||
margin-bottom: $space-xxxl;
|
||||
color: $color-white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notificationHeader {
|
||||
font-weight: 600;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.closeModal {
|
||||
position: absolute;
|
||||
top: $space-md;
|
||||
right: $space-md;
|
||||
}
|
||||
|
||||
.close {
|
||||
fill: $color-white;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: $color-white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cancelHeader {
|
||||
@mixin fontSize 5;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.cancelDetail {
|
||||
@mixin fontSize 3;
|
||||
color: $color-regent-gray;
|
||||
margin-bottom: $space-xl;
|
||||
}
|
||||
|
||||
.underlined {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* DDC Instructions */
|
||||
.instructionsSection {
|
||||
padding-right: $card-padding;
|
||||
padding-left: $card-padding;
|
||||
margin-bottom: $space-xxl;
|
||||
}
|
||||
|
||||
.instructionsHeader {
|
||||
@mixin fontSize 3;
|
||||
@mixin semiBold;
|
||||
margin-bottom: $space-xl;
|
||||
}
|
||||
|
||||
.instructionsDetail {
|
||||
@mixin fontSize 2;
|
||||
}
|
||||
|
||||
.link {
|
||||
composes: underlined;
|
||||
display: inline-block;
|
||||
color: $color-fiord;
|
||||
}
|
|
@ -1,317 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CheckIcon,
|
||||
ImageWithFallback,
|
||||
FullscreenLoading,
|
||||
FetchingError,
|
||||
Select,
|
||||
} from 'common';
|
||||
import { FALLBACK_IMAGE_SRC, FALLBACK_ELEMENT } from 'lib/constants/fallbacks';
|
||||
import css from './styles.css';
|
||||
import {
|
||||
filter,
|
||||
find,
|
||||
get,
|
||||
map,
|
||||
sortBy,
|
||||
} from 'lodash';
|
||||
import getLogo from 'lib/utils/get-largest-logo';
|
||||
import { SMALL } from 'lib/constants/sizes';
|
||||
import { PRIMARY, PANIC } from 'lib/constants/variants';
|
||||
import isStaging from 'lib/utils/isStaging';
|
||||
import isDev from 'lib/utils/isDevelopment';
|
||||
import { DOCKER, DOCKER_GUID } from 'lib/constants/defaults';
|
||||
import { ACTIVE, CANCELLED } from 'lib/constants/states/subscriptions';
|
||||
import { EUSA_LINK } from 'lib/constants/eusa';
|
||||
import {
|
||||
billingFetchProfileSubscriptions,
|
||||
billingUpdateSubscription,
|
||||
|
||||
BILLING_BASE_URL,
|
||||
} from 'actions/billing';
|
||||
const noOp = () => {};
|
||||
const { array, bool, func, shape, string, object } = PropTypes;
|
||||
|
||||
const dispatcher = {
|
||||
fetchSubscriptions: billingFetchProfileSubscriptions,
|
||||
updateSubscription: billingUpdateSubscription,
|
||||
};
|
||||
@connect(null, dispatcher)
|
||||
export default class SubscriptionList extends Component {
|
||||
static propTypes = {
|
||||
currentUserNamespace: string,
|
||||
dockerSubscriptions: array.isRequired,
|
||||
isFetching: bool,
|
||||
fetchSubscriptions: func.isRequired,
|
||||
namespaceObjects: shape({
|
||||
isFetching: bool,
|
||||
results: object,
|
||||
error: string,
|
||||
}).isRequired,
|
||||
onSelectNamespace: func.isRequired,
|
||||
partnerSubscriptions: array.isRequired,
|
||||
selectedNamespace: string.isRequired,
|
||||
showSubscriptionDetail: func.isRequired,
|
||||
updateInfo: object,
|
||||
updateSubscription: func.isRequired,
|
||||
selectedUserOrOrg: object,
|
||||
error: string,
|
||||
}
|
||||
|
||||
onSubscriptionClick = (productId) => () => {
|
||||
this.props.showSubscriptionDetail(productId);
|
||||
}
|
||||
|
||||
acceptEusa = ({ subscription_id, product_id }) => () => {
|
||||
const payload = { eusa: { accepted: true } };
|
||||
const { id: docker_id } = this.props.selectedUserOrOrg;
|
||||
this.props.updateSubscription({ subscription_id, body: payload })
|
||||
.then(() => {
|
||||
// Fetch all the subscriptions so you have the most updated
|
||||
this.props.fetchSubscriptions({ docker_id }).then(() => {
|
||||
// Transition to the detail page for this product to download license
|
||||
this.props.showSubscriptionDetail(product_id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Returns the EUSA url associated with the given subscription.
|
||||
determineEUSA(subscription) {
|
||||
const { origin } = subscription;
|
||||
|
||||
// Use a partner specific EUSA where applicable.
|
||||
// TODO - This will be obtained from the product catalog in the near future.
|
||||
if (origin === 'hpe') {
|
||||
return `${BILLING_BASE_URL}/eusa/hpe/eusa.pdf`;
|
||||
}
|
||||
|
||||
// Use the standard Docker software EUSA
|
||||
return EUSA_LINK;
|
||||
}
|
||||
|
||||
renderEusaAcceptance(subs) {
|
||||
const {
|
||||
namespaceObjects,
|
||||
selectedNamespace,
|
||||
updateInfo,
|
||||
} = this.props;
|
||||
// All subscriptions that require legal acceptance need to have the legal
|
||||
// terms accepted
|
||||
const { eusa_acceptance_required, product_id } = subs[0];
|
||||
const isOwner =
|
||||
get(namespaceObjects, ['results', selectedNamespace, 'isOwner']);
|
||||
if (!eusa_acceptance_required || !isOwner) {
|
||||
return null;
|
||||
}
|
||||
// See if you have to acccept terms for any subscriptions
|
||||
const subscriptionToAccept = find(subs, ({ eusa, state }) => {
|
||||
return eusa && !eusa.accepted && state !== CANCELLED;
|
||||
});
|
||||
// All subscriptions have accepted terms --> no action needed
|
||||
if (!subscriptionToAccept) {
|
||||
return null;
|
||||
}
|
||||
const { subscription_id } = subscriptionToAccept;
|
||||
// Prevent clicking on the buttons here from clicking on the parent div
|
||||
const stopProp = (e) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
// Grab that subscription's isUpdating and error information
|
||||
const { error, isUpdating } = updateInfo[subscription_id] || {};
|
||||
let buttonText = 'Accept Terms';
|
||||
if (isUpdating && !error) {
|
||||
buttonText = 'Accepting Terms...';
|
||||
} else if (error) {
|
||||
buttonText = 'Error! Please Try Again';
|
||||
}
|
||||
|
||||
const eusaLink = this.determineEUSA(subscriptionToAccept);
|
||||
|
||||
// We only have to cover the EUSA link because all trials will be created
|
||||
// through the online form and will have accepted eval terms
|
||||
return (
|
||||
<div className={css.noClick} onClick={stopProp}>
|
||||
To access your License keys, please accept the
|
||||
<a href={eusaLink} target="_blank" className={css.terms}>
|
||||
Terms of Service
|
||||
</a>
|
||||
<Button
|
||||
onClick={this.acceptEusa({ product_id, subscription_id })}
|
||||
variant={error ? PANIC : PRIMARY}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderProductSubscriptionSummary = (subs) => {
|
||||
if (!subs || !subs.length) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
publisher_id,
|
||||
publisher = {},
|
||||
label,
|
||||
} = subs[0];
|
||||
const numActiveSubscriptions =
|
||||
filter(subs, ({ state }) => state === ACTIVE).length;
|
||||
let partnerName;
|
||||
let dockerPublisherID = DOCKER_GUID;
|
||||
if (isStaging() || isDev()) {
|
||||
dockerPublisherID = DOCKER;
|
||||
}
|
||||
if (publisher_id !== dockerPublisherID) {
|
||||
partnerName = (
|
||||
<div className={css.partnerName}>
|
||||
<CheckIcon className={css.check} size={SMALL} />
|
||||
{publisher.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const addS = numActiveSubscriptions === 1 ? '' : 's';
|
||||
const licenses = (
|
||||
<span>{`${numActiveSubscriptions} active subscription${addS}`}</span>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className={css.product}>
|
||||
<div className={css.productName}>{label}</div>
|
||||
{partnerName}
|
||||
</div>
|
||||
<div className={css.nameAndLicense}>
|
||||
{licenses}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderProductSubscriptions = (subs) => {
|
||||
// Subscription is an array containing one or more subscriptions for the
|
||||
// same product id
|
||||
const { product_id, logo_url } = subs[0];
|
||||
const src = getLogo(logo_url);
|
||||
const maybeEusaAcceptance = this.renderEusaAcceptance(subs);
|
||||
return (
|
||||
<Card
|
||||
key={product_id}
|
||||
className={css.subscriptionCard}
|
||||
hover
|
||||
shadow
|
||||
onClick={this.onSubscriptionClick(product_id)}
|
||||
>
|
||||
<div className={css.subscriptionSummary}>
|
||||
<ImageWithFallback
|
||||
className={css.icon}
|
||||
fallbackElement={FALLBACK_ELEMENT}
|
||||
fallbackImage={FALLBACK_IMAGE_SRC}
|
||||
src={src}
|
||||
/>
|
||||
{this.renderProductSubscriptionSummary(subs)}
|
||||
</div>
|
||||
<div>{maybeEusaAcceptance}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
renderNoSubscriptions(type) {
|
||||
return (
|
||||
<Card shadow>You have no {type} subscriptions.</Card>
|
||||
);
|
||||
}
|
||||
|
||||
renderPartnerSubscriptionList() {
|
||||
const { partnerSubscriptions } = this.props;
|
||||
let subs = map(partnerSubscriptions, this.renderProductSubscriptions);
|
||||
if (!partnerSubscriptions.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={css.sectionTitle}>Docker Partner Services</div>
|
||||
<div>{subs}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderDockerSubscriptionList() {
|
||||
const { dockerSubscriptions } = this.props;
|
||||
let subs = map(dockerSubscriptions, this.renderProductSubscriptions);
|
||||
if (!dockerSubscriptions.length) {
|
||||
subs = this.renderNoSubscriptions('Docker');
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={css.dockerSectionTitle}>
|
||||
<div className={css.sectionTitle}>Docker Services</div>
|
||||
<div>{this.renderNamespaceSelect()}</div>
|
||||
</div>
|
||||
<div>{subs}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderNamespaceSelect() {
|
||||
const { namespaceObjects, currentUserNamespace } = this.props;
|
||||
let options = map(namespaceObjects.results, (userObj) => {
|
||||
const namespace = userObj.username || userObj.orgname;
|
||||
return { value: namespace, label: namespace };
|
||||
});
|
||||
if (!namespaceObjects.results[currentUserNamespace]) {
|
||||
// current user should be included in namespace objects
|
||||
// BUT if it's not, we should add it to the front
|
||||
options.unshift(
|
||||
{ value: currentUserNamespace, label: currentUserNamespace }
|
||||
);
|
||||
}
|
||||
options = sortBy(options, (option) => {
|
||||
return option.value !== currentUserNamespace;
|
||||
});
|
||||
return (
|
||||
<div className={css.namespaceSelectWrapper}>
|
||||
Account
|
||||
<Select
|
||||
className={css.namespaceSelect}
|
||||
clearable={false}
|
||||
ignoreCase
|
||||
onBlur={noOp}
|
||||
onChange={this.props.onSelectNamespace}
|
||||
options={options}
|
||||
placeholder="Account"
|
||||
value={this.props.selectedNamespace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
error,
|
||||
} = this.props;
|
||||
if (isFetching) {
|
||||
return <FullscreenLoading />;
|
||||
} else if (error) {
|
||||
return (
|
||||
<div>
|
||||
<div className={css.empty}>
|
||||
{this.renderNamespaceSelect()}
|
||||
</div>
|
||||
<div className={css.fetchingError}>
|
||||
<FetchingError resource="your subscriptions" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{this.renderDockerSubscriptionList()}
|
||||
{this.renderPartnerSubscriptionList()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.product {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: $space-xs;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@mixin storeIcon;
|
||||
margin-right: $space-xl;
|
||||
}
|
||||
|
||||
.nameAndLicense {
|
||||
@mixin fontSize 2;
|
||||
color: $color-regent-gray;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
@mixin title;
|
||||
padding: $space-xl 0;
|
||||
}
|
||||
|
||||
.dockerSectionTitle {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.partnerName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.productName {
|
||||
@mixin fontSize 4;
|
||||
@mixin semiBold;
|
||||
margin-right: $space-xs;
|
||||
}
|
||||
|
||||
.check {
|
||||
fill: $color-robins-egg-blue;
|
||||
margin-right: $space-xxs;
|
||||
}
|
||||
|
||||
.marketplaceIcon {
|
||||
fill: $color-regent-gray;
|
||||
margin-left: $space-xxs;
|
||||
}
|
||||
|
||||
.subscriptionCard {
|
||||
cursor: pointer;
|
||||
margin-bottom: $space-sm;
|
||||
& > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.subscriptionSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.namespaceSelect {
|
||||
height: $select-height;
|
||||
width: $select-width;
|
||||
margin-left: $space-sm;
|
||||
}
|
||||
|
||||
.namespaceSelectWrapper {
|
||||
@mixin fontSize 2;
|
||||
color: $color-regent-gray;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-left: $space-xxs;
|
||||
margin-right: $space-sm;
|
||||
}
|
||||
|
||||
.noClick {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fetchingError {
|
||||
padding-top: $space-xl;
|
||||
color: $color-regent-gray;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import map from 'lodash/map';
|
||||
|
||||
export const setIsOwnerParam = (fetchedNamespaces, ownedNamespaces) => {
|
||||
const results = {};
|
||||
map(fetchedNamespaces, (userOrg) => {
|
||||
const username = userOrg.username || userOrg.orgname;
|
||||
const isOwner = ownedNamespaces.indexOf(username) >= 0;
|
||||
results[username] = { ...userOrg, isOwner };
|
||||
});
|
||||
return results;
|
||||
};
|
|
@ -1,365 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
FullscreenLoading,
|
||||
} from 'common';
|
||||
import forEach from 'lodash/forEach';
|
||||
import get from 'lodash/get';
|
||||
import isStaging from 'lib/utils/isStaging';
|
||||
import isDev from 'lib/utils/isDevelopment';
|
||||
import { DOCKER, DOCKER_GUID } from 'lib/constants/defaults';
|
||||
import SubscriptionList from './SubscriptionList';
|
||||
import SubscriptionDetail from './SubscriptionDetail';
|
||||
import DDCInstructions from './DDCInstructions';
|
||||
import { setIsOwnerParam } from './helpers';
|
||||
import css from './styles.css';
|
||||
import { isProductBundle } from 'lib/utils/product-utils';
|
||||
import {
|
||||
CANCELLED,
|
||||
DDC_INSTRUCTIONS,
|
||||
SUBSCRIPTION_DETAIL,
|
||||
SUBSCRIPTION_LIST,
|
||||
} from 'lib/constants/states/subscriptions';
|
||||
import {
|
||||
accountFetchCurrentUser,
|
||||
accountFetchUser,
|
||||
accountFetchUserOrgs,
|
||||
accountSelectNamespace,
|
||||
} from 'actions/account';
|
||||
import {
|
||||
billingFetchLicenseDetail,
|
||||
billingFetchProduct,
|
||||
billingFetchProfileSubscriptionsAndProducts,
|
||||
} from 'actions/billing';
|
||||
import { repositoryFetchOwnedNamespaces } from 'actions/repository';
|
||||
import {
|
||||
marketplaceFetchBundleDetail,
|
||||
marketplaceFetchRepositoryDetail,
|
||||
} from 'actions/marketplace';
|
||||
const {
|
||||
array,
|
||||
arrayOf,
|
||||
bool,
|
||||
func,
|
||||
object,
|
||||
objectOf,
|
||||
string,
|
||||
shape,
|
||||
} = PropTypes;
|
||||
|
||||
const mapStateToProps = ({ account, billing, marketplace }) => {
|
||||
const {
|
||||
currentUser,
|
||||
// object of all fetched namespace objects (owned & orgs)
|
||||
namespaceObjects,
|
||||
ownedNamespaces,
|
||||
selectedNamespace,
|
||||
} = account;
|
||||
const currentUserNamespace = currentUser.username || '';
|
||||
// Set the isOwner property for each user / org object in results
|
||||
namespaceObjects.results =
|
||||
setIsOwnerParam(namespaceObjects.results, ownedNamespaces);
|
||||
// Is this user an owner for the _currently selected_ namespace?
|
||||
const isOwner =
|
||||
get(namespaceObjects, ['results', selectedNamespace, 'isOwner']);
|
||||
const { subscriptions, products: billingProducts } = billing;
|
||||
const { bundles, images } = marketplace;
|
||||
const {
|
||||
delete: deleteInfo,
|
||||
error,
|
||||
isFetching,
|
||||
results,
|
||||
update: updateInfo,
|
||||
} = subscriptions;
|
||||
const dockerSubscriptions = [];
|
||||
const partnerSubscriptions = [];
|
||||
const subscriptionsByProductId = {};
|
||||
if (!isFetching) {
|
||||
let dockerPublisherID = DOCKER_GUID;
|
||||
if (isStaging() || isDev()) {
|
||||
dockerPublisherID = DOCKER;
|
||||
}
|
||||
// Group each subscription by product
|
||||
forEach(results, (subscription) => {
|
||||
// Look up the product id associated with this subscription id
|
||||
const { product_id, state } = subscription;
|
||||
if (!isOwner && state === CANCELLED) {
|
||||
// Non owners cannot view cancelled subscriptions
|
||||
return;
|
||||
}
|
||||
// Get the billing product info for that product (rate_plans, etc.)
|
||||
const billingProductInfo = billingProducts[product_id];
|
||||
// Get the product catalog information for that product (ex. logo, name)
|
||||
let productCatalogInfo;
|
||||
if (isProductBundle(product_id)) {
|
||||
productCatalogInfo = bundles[product_id] || {};
|
||||
} else {
|
||||
productCatalogInfo = images.certified[product_id] || {};
|
||||
}
|
||||
|
||||
// If we CANNOT get the billing product or product catalog information,
|
||||
// do NOT display this subscription
|
||||
const productErr = productCatalogInfo && productCatalogInfo.error;
|
||||
const billingErr = billingProductInfo && billingProductInfo.error;
|
||||
if (productErr || billingErr) {
|
||||
return;
|
||||
}
|
||||
// Combine the subscription info and product info collision on `name`)
|
||||
const allInfo = {
|
||||
// Subscription info and billing product info both have name
|
||||
subscription_name: subscription.name,
|
||||
// Product catalog & subscription both have eusa (we want subscription)
|
||||
...productCatalogInfo,
|
||||
...billingProductInfo,
|
||||
...subscription,
|
||||
// TODO Kristie 8/17/16 Separate this info better so that there are no
|
||||
// collisions
|
||||
name: productCatalogInfo.name,
|
||||
};
|
||||
// Check if this product exists in the subscriptions already
|
||||
const existingSub = subscriptionsByProductId[product_id];
|
||||
// Add this subscription + product info to the list of subscriptions for
|
||||
// this product, or create a list if none exists
|
||||
const newSubs = existingSub ? [...existingSub, allInfo] : [allInfo];
|
||||
subscriptionsByProductId[product_id] = newSubs;
|
||||
});
|
||||
// Separate each product (and its array of subscriptions) by vendor
|
||||
forEach(subscriptionsByProductId, (productSubs) => {
|
||||
// Guaranteed to be at least one subscription for this product
|
||||
const { publisher_id } = productSubs[0];
|
||||
if (publisher_id === dockerPublisherID) {
|
||||
dockerSubscriptions.push(productSubs);
|
||||
} else {
|
||||
partnerSubscriptions.push(productSubs);
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
currentUserNamespace,
|
||||
deleteInfo,
|
||||
dockerSubscriptions,
|
||||
error,
|
||||
isFetching,
|
||||
licenses: subscriptions && subscriptions.licenses,
|
||||
namespaceObjects,
|
||||
partnerSubscriptions,
|
||||
selectedNamespace,
|
||||
subscriptionsByProductId,
|
||||
updateInfo,
|
||||
};
|
||||
};
|
||||
|
||||
const dispatcher = {
|
||||
accountFetchCurrentUser,
|
||||
accountFetchUser,
|
||||
accountFetchUserOrgs,
|
||||
accountSelectNamespace,
|
||||
billingFetchLicenseDetail,
|
||||
billingFetchProduct,
|
||||
billingFetchProfileSubscriptionsAndProducts,
|
||||
marketplaceFetchBundleDetail,
|
||||
marketplaceFetchRepositoryDetail,
|
||||
repositoryFetchOwnedNamespaces,
|
||||
};
|
||||
|
||||
/*
|
||||
* Subscriptions is the connected component that handles showing either
|
||||
* a SubscriptionList or a SubscriptionDetail page. Subscriptions is only
|
||||
* shown within the context of the MagicCarpet by clicking on the Subscriptions
|
||||
* link in the Nav dropdown. There is no route within the app.
|
||||
*/
|
||||
@connect(mapStateToProps, dispatcher)
|
||||
export default class Subscriptions extends Component {
|
||||
static propTypes = {
|
||||
accountFetchCurrentUser: func.isRequired,
|
||||
accountFetchUser: func.isRequired,
|
||||
accountFetchUserOrgs: func.isRequired,
|
||||
accountSelectNamespace: func.isRequired,
|
||||
billingFetchLicenseDetail: func.isRequired,
|
||||
billingFetchProduct: func.isRequired,
|
||||
billingFetchProfileSubscriptionsAndProducts: func.isRequired,
|
||||
marketplaceFetchBundleDetail: func.isRequired,
|
||||
marketplaceFetchRepositoryDetail: func.isRequired,
|
||||
repositoryFetchOwnedNamespaces: func.isRequired,
|
||||
|
||||
currentUserNamespace: string,
|
||||
error: string,
|
||||
// Object of all fetched namespace objects keyed off of namespace
|
||||
namespaceObjects: shape({
|
||||
isFetching: bool,
|
||||
results: object,
|
||||
error: string,
|
||||
}).isRequired,
|
||||
isFetching: bool,
|
||||
selectedNamespace: string,
|
||||
// Values are arrays containing subscription(s) for a product (key)
|
||||
subscriptionsByProductId: objectOf(array),
|
||||
|
||||
// The following props are not being used in this component
|
||||
// but being passed down through to it's children
|
||||
deleteInfo: object,
|
||||
// Array of arrays containing subscription(s) for a docker product
|
||||
dockerSubscriptions: arrayOf(array),
|
||||
licenses: shape({
|
||||
isFetching: bool,
|
||||
// Licenses are keyed off of subscription_id
|
||||
results: object,
|
||||
error: string,
|
||||
}),
|
||||
// Array of arrays containing subscription(s) for a product
|
||||
partnerSubscriptions: arrayOf(array),
|
||||
updateInfo: object,
|
||||
}
|
||||
|
||||
state = {
|
||||
// View list of subscriptions (grouped by product) or a detail view of all
|
||||
// subscriptions for a selected product
|
||||
currentView: SUBSCRIPTION_LIST,
|
||||
isInitializing: true,
|
||||
selectedProductId: '',
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const {
|
||||
accountFetchCurrentUser: fetchCurrentUser,
|
||||
accountFetchUserOrgs: fetchUserOrgs,
|
||||
accountSelectNamespace: selectNamespace,
|
||||
billingFetchProfileSubscriptionsAndProducts: fetchProfileSubscriptions,
|
||||
repositoryFetchOwnedNamespaces: fetchOwnedNamespaces,
|
||||
} = this.props;
|
||||
Promise.when([
|
||||
fetchOwnedNamespaces(),
|
||||
fetchUserOrgs(),
|
||||
fetchCurrentUser().then((res) => {
|
||||
const { username: namespace, id: docker_id } = res.value;
|
||||
Promise.all([
|
||||
fetchProfileSubscriptions({ docker_id }),
|
||||
selectNamespace({ namespace }),
|
||||
]);
|
||||
}).catch(() => { this.setState({ isInitializing: false }); }),
|
||||
]).then(() => {
|
||||
this.setState({
|
||||
isInitializing: false,
|
||||
});
|
||||
}).catch(() => {
|
||||
this.setState({ isInitializing: false });
|
||||
});
|
||||
}
|
||||
|
||||
onSelectNamespace = ({ value: namespace }) => {
|
||||
const {
|
||||
accountFetchUser: fetchUser,
|
||||
accountSelectNamespace: selectNamespace,
|
||||
currentUserNamespace,
|
||||
billingFetchProfileSubscriptionsAndProducts: fetchProfileSubscriptions,
|
||||
namespaceObjects,
|
||||
} = this.props;
|
||||
// NOTE
|
||||
// currently all options are being populated from already fetched namespaces
|
||||
// BUT just in case - this will fetch the namespaceObject
|
||||
if (!namespaceObjects.results[namespace]) {
|
||||
const isOrg = namespace !== currentUserNamespace;
|
||||
return fetchUser({ namespace, isOrg }).then((userRes) => {
|
||||
const { id: docker_id } = userRes.value;
|
||||
Promise.all([
|
||||
selectNamespace({ namespace }),
|
||||
fetchProfileSubscriptions({ docker_id }),
|
||||
]);
|
||||
});
|
||||
}
|
||||
const { id } = namespaceObjects.results[namespace];
|
||||
return Promise.all([
|
||||
selectNamespace({ namespace }),
|
||||
fetchProfileSubscriptions({ docker_id: id }),
|
||||
]);
|
||||
}
|
||||
|
||||
showSubscriptionDetail = (productId) => {
|
||||
this.setState({
|
||||
currentView: SUBSCRIPTION_DETAIL,
|
||||
selectedProductId: productId,
|
||||
});
|
||||
}
|
||||
|
||||
showSubscriptionList = () => {
|
||||
this.setState({ currentView: SUBSCRIPTION_LIST });
|
||||
}
|
||||
|
||||
showDDCInstructions = () => {
|
||||
this.setState({ currentView: DDC_INSTRUCTIONS });
|
||||
}
|
||||
|
||||
generateSelectOptions(namespaceObjects) {
|
||||
const namespaceArray = [];
|
||||
forEach(namespaceObjects, (val, key) => {
|
||||
namespaceArray.push({ value: key, label: key });
|
||||
});
|
||||
return namespaceArray;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
currentView,
|
||||
isInitializing,
|
||||
selectedProductId,
|
||||
} = this.state;
|
||||
const {
|
||||
currentUserNamespace,
|
||||
deleteInfo,
|
||||
dockerSubscriptions,
|
||||
error,
|
||||
isFetching,
|
||||
licenses,
|
||||
namespaceObjects,
|
||||
partnerSubscriptions,
|
||||
selectedNamespace,
|
||||
subscriptionsByProductId,
|
||||
updateInfo,
|
||||
} = this.props;
|
||||
const selectedUserOrOrg = namespaceObjects.results[selectedNamespace];
|
||||
const selectedProductSubs = subscriptionsByProductId[selectedProductId];
|
||||
let content;
|
||||
if (isInitializing || isFetching) {
|
||||
content = <FullscreenLoading />;
|
||||
} else if (currentView === SUBSCRIPTION_LIST) {
|
||||
content = (
|
||||
<SubscriptionList
|
||||
currentUserNamespace={currentUserNamespace}
|
||||
selectedUserOrOrg={selectedUserOrOrg}
|
||||
dockerSubscriptions={dockerSubscriptions}
|
||||
namespaceObjects={namespaceObjects}
|
||||
onSelectNamespace={this.onSelectNamespace}
|
||||
partnerSubscriptions={partnerSubscriptions}
|
||||
selectedNamespace={selectedNamespace}
|
||||
showSubscriptionDetail={this.showSubscriptionDetail}
|
||||
updateInfo={updateInfo}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
} else if (currentView === DDC_INSTRUCTIONS) {
|
||||
content = (
|
||||
<DDCInstructions
|
||||
showSubscriptionDetail={this.showSubscriptionDetail}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<SubscriptionDetail
|
||||
selectedUserOrOrg={selectedUserOrOrg}
|
||||
deleteInfo={deleteInfo}
|
||||
error={error}
|
||||
licenses={licenses}
|
||||
selectedNamespace={selectedNamespace}
|
||||
selectedProductSubscriptions={selectedProductSubs}
|
||||
showDDCInstructions={this.showDDCInstructions}
|
||||
showSubscriptionList={this.showSubscriptionList}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={css.content}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
@import "utilities";
|
||||
|
||||
.content {
|
||||
margin: $space-xxl 0;
|
||||
}
|
|
@ -1,122 +0,0 @@
|
|||
import React, { Component, PropTypes } from 'react';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import { Input, Button } from 'components/common';
|
||||
import css from './styles.css';
|
||||
|
||||
class AdminHomeCreateRepositoryForm extends Component {
|
||||
static propTypes = {
|
||||
fields: PropTypes.object.isRequired,
|
||||
submitting: PropTypes.bool.isRequired,
|
||||
handleSubmit: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
fields: {
|
||||
display_name,
|
||||
namespace,
|
||||
reponame,
|
||||
publisher,
|
||||
},
|
||||
submitting,
|
||||
handleSubmit,
|
||||
} = this.props;
|
||||
|
||||
const repoInputStyle = { width: '200px' };
|
||||
const inputStyle = { width: '256px' };
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<Input
|
||||
id={'display_name_input'}
|
||||
placeholder="Display Name"
|
||||
style={inputStyle}
|
||||
errorText={display_name.touched && display_name.error}
|
||||
{...display_name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
store / <Input
|
||||
id={'namespace_input'}
|
||||
errorText={namespace.touched && namespace.error}
|
||||
placeholder="namespace"
|
||||
style={repoInputStyle}
|
||||
{...namespace}
|
||||
/> / <Input
|
||||
id={'reponame_input'}
|
||||
errorText={reponame.touched && reponame.error}
|
||||
placeholder="reponame"
|
||||
style={repoInputStyle}
|
||||
{...reponame}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id={'publisher_id_input'}
|
||||
placeholder="Publisher Docker ID"
|
||||
style={inputStyle}
|
||||
errorText={publisher.id.touched && publisher.id.error}
|
||||
{...publisher.id}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
id={'publisher_name_input'}
|
||||
placeholder="Publisher Name"
|
||||
style={inputStyle}
|
||||
errorText={
|
||||
publisher.name.touched && publisher.name.error
|
||||
}
|
||||
{...publisher.name}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.buttons}>
|
||||
<Button type="submit" disabled={submitting}>Add Repository</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default reduxForm({
|
||||
form: 'adminHomeCreateRepository',
|
||||
fields: [
|
||||
'display_name',
|
||||
'namespace',
|
||||
'reponame',
|
||||
'publisher.id',
|
||||
'publisher.name',
|
||||
],
|
||||
validate: values => {
|
||||
const errors = {};
|
||||
if (!values.display_name) {
|
||||
errors.display_name = 'Required';
|
||||
}
|
||||
|
||||
if (!values.namespace) {
|
||||
errors.namespace = 'Required';
|
||||
} else if (!/^[a-z0-9_]+$/.test(values.namespace)) {
|
||||
errors.namespace = 'Invalid namespace';
|
||||
}
|
||||
|
||||
if (!values.reponame) {
|
||||
errors.reponame = 'Required';
|
||||
} else if (!/^[a-zA-Z0-9-_.]+$/.test(values.reponame)) {
|
||||
errors.reponame = 'Invalid reponame';
|
||||
}
|
||||
|
||||
errors.publisher = {};
|
||||
if (!values.publisher.id) {
|
||||
errors.publisher.id = 'Required';
|
||||
} else if (!/^[a-z0-9_]+$/.test(values.publisher.id)) {
|
||||
errors.publisher.id = 'Invalid Publisher ID';
|
||||
}
|
||||
|
||||
if (!values.publisher.name) {
|
||||
errors.publisher.name = 'Required';
|
||||
}
|
||||
|
||||
return errors;
|
||||
},
|
||||
})(AdminHomeCreateRepositoryForm);
|
|
@ -1,4 +0,0 @@
|
|||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
|
@ -1,176 +0,0 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import { Table,
|
||||
TableBody,
|
||||
TableHeader,
|
||||
TableHeaderColumn,
|
||||
TableRow,
|
||||
TableRowColumn,
|
||||
} from 'material-ui/Table';
|
||||
import {
|
||||
Link,
|
||||
} from 'react-router';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Card,
|
||||
} from 'components/common';
|
||||
import {
|
||||
marketplaceCreateRepository,
|
||||
marketplaceDeleteRepository,
|
||||
} from 'actions/marketplace';
|
||||
import { billingCreateProduct } from 'actions/billing';
|
||||
import { connect } from 'react-redux';
|
||||
import routes from 'lib/constants/routes';
|
||||
import AdminHomeCreateRepositoryForm from './AdminHomeCreateRepositoryForm';
|
||||
import get from 'lodash/get';
|
||||
import css from './styles.css';
|
||||
|
||||
const { func, object, array } = PropTypes;
|
||||
|
||||
const mapStateToProps = ({ marketplace }, { params }) => {
|
||||
const summaries = get(marketplace, ['search', 'pages', 1, 'results'], []);
|
||||
return {
|
||||
params,
|
||||
summaries,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatch = {
|
||||
billingCreateProduct,
|
||||
marketplaceCreateRepository,
|
||||
marketplaceDeleteRepository,
|
||||
};
|
||||
|
||||
@connect(mapStateToProps, mapDispatch)
|
||||
export default class Admin extends React.Component {
|
||||
static propTypes = {
|
||||
params: object,
|
||||
summaries: array,
|
||||
billingCreateProduct: func,
|
||||
marketplaceCreateRepository: func,
|
||||
marketplaceDeleteRepository: func,
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
router: object.isRequired,
|
||||
}
|
||||
|
||||
state = { isModalOpen: false }
|
||||
|
||||
handleSubmit = (values) => {
|
||||
const defaultValues = {
|
||||
categories: [],
|
||||
platforms: [],
|
||||
default_version: {},
|
||||
};
|
||||
this.props.marketplaceCreateRepository({
|
||||
...defaultValues,
|
||||
...values,
|
||||
source: 'official',
|
||||
}).then(res => {
|
||||
const id = res.value.product_id;
|
||||
return this.props.billingCreateProduct({ id, body: {
|
||||
name: `${values.namespace}-${values.reponame}`,
|
||||
label: values.display_name,
|
||||
publisher_id: values.publisher.id,
|
||||
rate_plans: [{
|
||||
name: 'free',
|
||||
label: 'Free',
|
||||
duration: 1,
|
||||
duration_period: 'year',
|
||||
trial: 0,
|
||||
trial_period: 'none',
|
||||
currency: 'USD',
|
||||
}],
|
||||
} }).then(() => {
|
||||
this.context.router.push(routes.adminRepository({ id }));
|
||||
});
|
||||
}).catch(err => {
|
||||
// TODO (jmorgan): show error text in form if creating a product failed
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
openModal = () => {
|
||||
this.setState({ isModalOpen: true });
|
||||
}
|
||||
|
||||
closeModal = () => {
|
||||
this.setState({ isModalOpen: false });
|
||||
}
|
||||
|
||||
deleteRepository = (id) => () => {
|
||||
const result = confirm('Are you sure you want to delete this repository?');
|
||||
if (result) {
|
||||
this.props.marketplaceDeleteRepository({ id });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div className={css.header}>
|
||||
<h2>{this.props.summaries.length} Repositories</h2>
|
||||
<div className={css.new}>
|
||||
<div className={css.buttonwrapper}>
|
||||
<Button onClick={this.openModal} className={css.nomargin}>
|
||||
Add Repository
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Table selectable={false}>
|
||||
<TableHeader displaySelectAll={false} adjustForCheckbox={false}>
|
||||
<TableRow>
|
||||
<TableHeaderColumn>Name</TableHeaderColumn>
|
||||
<TableHeaderColumn>Publisher</TableHeaderColumn>
|
||||
<TableHeaderColumn>Repository</TableHeaderColumn>
|
||||
<TableHeaderColumn className={css.smallColumn}>
|
||||
Status
|
||||
</TableHeaderColumn>
|
||||
<TableHeaderColumn className={css.smallColumn} />
|
||||
<TableHeaderColumn className={css.smallColumn} />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody displayRowCheckbox={false}>
|
||||
{this.props.summaries.map(s => {
|
||||
return (
|
||||
<TableRow key={s.id}>
|
||||
<TableRowColumn>{s.display_name}</TableRowColumn>
|
||||
<TableRowColumn>{s.publisher.name}</TableRowColumn>
|
||||
<TableRowColumn className={css.monospace}>
|
||||
store/{s.namespace}/{s.reponame}
|
||||
</TableRowColumn>
|
||||
<TableRowColumn className={css.smallColumn}>
|
||||
LIVE
|
||||
</TableRowColumn>
|
||||
<TableRowColumn className={css.smallColumn}>
|
||||
<Link to={routes.adminRepository({ id: s.id })}>
|
||||
Edit →
|
||||
</Link>
|
||||
</TableRowColumn>
|
||||
<TableRowColumn className={css.smallColumn}>
|
||||
<a href="#" onClick={this.deleteRepository(s.id)}>
|
||||
Delete ×
|
||||
</a>
|
||||
</TableRowColumn>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Modal
|
||||
isOpen={this.state.isModalOpen}
|
||||
onRequestClose={this.closeModal}
|
||||
className={css.modal}
|
||||
>
|
||||
<Card title="Add Repository to the Store">
|
||||
<AdminHomeCreateRepositoryForm
|
||||
onSubmit={this.handleSubmit}
|
||||
/>
|
||||
</Card>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.new {
|
||||
flex: 1 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
.nomargin {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 0;
|
||||
width: 600px !important;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.smallColumn {
|
||||
width: 110px !important;
|
||||
}
|
|
@ -1,353 +0,0 @@
|
|||
const request = require('superagent-promise')(require('superagent'), Promise);
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { reduxForm } from 'redux-form';
|
||||
import { DOWNLOAD_ATTRIBUTES_READABLE } from 'lib/constants/eusa';
|
||||
import { Input, Button, LabelField, Checkbox } from 'components/common';
|
||||
import uuid from 'uuid-v4';
|
||||
import css from './styles.css';
|
||||
import { jwt } from 'lib/utils/authHeaders';
|
||||
|
||||
class AdminRepositoryEditMetadataForm extends Component {
|
||||
static propTypes = {
|
||||
categories: PropTypes.object.isRequired,
|
||||
platforms: PropTypes.object.isRequired,
|
||||
fields: PropTypes.object.isRequired,
|
||||
submitting: PropTypes.bool.isRequired,
|
||||
handleSubmit: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
onChangeCategory = (name) => (e) => {
|
||||
let categories = this.props.fields.categories.value || [];
|
||||
if (e.target.checked) {
|
||||
categories.push({ name, label: this.props.categories[name] });
|
||||
} else {
|
||||
categories = categories.filter(c => c.name !== name);
|
||||
}
|
||||
this.props.fields.categories.onChange(categories);
|
||||
}
|
||||
|
||||
onChangePlatform = (name) => (e) => {
|
||||
let platforms = this.props.fields.platforms.value || [];
|
||||
if (e.target.checked) {
|
||||
platforms.push({ name, label: this.props.platforms[name] });
|
||||
} else {
|
||||
platforms = platforms.filter(c => c.name !== name);
|
||||
}
|
||||
this.props.fields.platforms.onChange(platforms);
|
||||
}
|
||||
|
||||
onChangePreviewFile = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = file.name.split('.').pop();
|
||||
if (extension !== 'png') {
|
||||
console.err('Need png extension');
|
||||
return;
|
||||
}
|
||||
|
||||
request
|
||||
.get('/api/ui/admin/signed-s3-put')
|
||||
.set(jwt())
|
||||
.accept('application/json')
|
||||
.query({
|
||||
'file-name':
|
||||
`${this.props.fields.id.value}-${uuid()}-logo_large.${extension}`,
|
||||
'file-type': file.type,
|
||||
})
|
||||
.end()
|
||||
.then((res) => {
|
||||
const data = JSON.parse(res.text);
|
||||
return request
|
||||
.put(data.signedRequest)
|
||||
.set('Content-Type', file.type)
|
||||
.send(file)
|
||||
.end().then(() => {
|
||||
this.props.fields.logo_url.large.onChange(data.url);
|
||||
});
|
||||
}).catch(err => {
|
||||
// TODO (jmorgan): show error text in form if creating a product failed
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
onChangeScreenshotFile = (screenshot) => (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extension = file.name.split('.').pop();
|
||||
if (extension !== 'png') {
|
||||
console.err('Need png extension');
|
||||
return;
|
||||
}
|
||||
|
||||
request
|
||||
.get('/api/ui/admin/signed-s3-put')
|
||||
.accept('application/json')
|
||||
.query({
|
||||
'file-name':
|
||||
// eslint-disable-next-line
|
||||
`${this.props.fields.id.value}-${uuid()}-screenshot_large.${extension}`,
|
||||
'file-type': file.type,
|
||||
})
|
||||
.end()
|
||||
.then((res) => {
|
||||
const data = JSON.parse(res.text);
|
||||
return request
|
||||
.put(data.signedRequest)
|
||||
.set('Content-Type', file.type)
|
||||
.send(file)
|
||||
.end().then(() => {
|
||||
screenshot.url.onChange(data.url);
|
||||
});
|
||||
}).catch(err => {
|
||||
// TODO (jmorgan): show error text in form if creating a product failed
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
fields: {
|
||||
display_name,
|
||||
namespace,
|
||||
reponame,
|
||||
publisher,
|
||||
short_description,
|
||||
full_description,
|
||||
categories,
|
||||
platforms,
|
||||
logo_url,
|
||||
screenshots,
|
||||
links,
|
||||
eusa,
|
||||
download_attribute,
|
||||
instructions,
|
||||
},
|
||||
submitting,
|
||||
handleSubmit,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={css.repositoryform}>
|
||||
<LabelField label="Display Name">
|
||||
<Input
|
||||
disabled
|
||||
id={'display_name_input'}
|
||||
placeholder="Display Name"
|
||||
{ ...display_name}
|
||||
/>
|
||||
</LabelField>
|
||||
<LabelField label="Store Repository">
|
||||
<div className={css.monospace}>
|
||||
store /
|
||||
<Input
|
||||
disabled
|
||||
id={'namespace_input'}
|
||||
placeholder="namespace"
|
||||
{ ...namespace }
|
||||
/>
|
||||
/
|
||||
<Input
|
||||
disabled
|
||||
id={'reponame_input'}
|
||||
placeholder="reponame"
|
||||
{ ...reponame }
|
||||
/>
|
||||
</div>
|
||||
</LabelField>
|
||||
<LabelField label="Publisher Docker ID">
|
||||
<Input
|
||||
disabled
|
||||
id={'publisher_id_input'}
|
||||
placeholder="Publisher Docker ID"
|
||||
{ ...publisher.id }
|
||||
/>
|
||||
</LabelField>
|
||||
<LabelField label="Publisher Name">
|
||||
<Input
|
||||
disabled
|
||||
id={'publisher_name_input'}
|
||||
placeholder="Publisher Name"
|
||||
{ ...publisher.name }
|
||||
/>
|
||||
</LabelField>
|
||||
<LabelField label="Entitlement">
|
||||
<select
|
||||
{ ...download_attribute }
|
||||
value={download_attribute.value || ''}
|
||||
>
|
||||
<option></option>
|
||||
{Object.keys(DOWNLOAD_ATTRIBUTES_READABLE).map(k => (
|
||||
<option key={k} value={k}>
|
||||
{DOWNLOAD_ATTRIBUTES_READABLE[k]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</LabelField>
|
||||
<LabelField label="Logo">
|
||||
<div className={css.logo}>
|
||||
<img
|
||||
alt=""
|
||||
className={css.preview}
|
||||
src={logo_url.large.value}
|
||||
/>
|
||||
<input
|
||||
id={'logo-input'}
|
||||
type="file"
|
||||
onChange={this.onChangePreviewFile}
|
||||
/>
|
||||
<label htmlFor="logo-input">{'Size must be 256x256 pixels'}</label>
|
||||
</div>
|
||||
</LabelField>
|
||||
<LabelField label="Screenshots">
|
||||
<div>
|
||||
{!screenshots.length && <div>No Screenshots</div>}
|
||||
{screenshots.map((screenshot, index) =>
|
||||
<div key={index}>
|
||||
<h3>Screenshot {index + 1}</h3>
|
||||
<div>
|
||||
<div className={css.screenshotwrapper}>
|
||||
<img
|
||||
alt=""
|
||||
className={css.screenshotpreview}
|
||||
src={screenshot.url.value}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id={`screenshot-input-${index}`}
|
||||
type="text"
|
||||
placeholder="Label"
|
||||
{ ...screenshot.label }
|
||||
/> <input
|
||||
id={index}
|
||||
type="file"
|
||||
onChange={this.onChangeScreenshotFile(screenshot)}
|
||||
/>
|
||||
<label htmlFor="index">
|
||||
Size must be 1920 by 1200 pixels or larger
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button type="button" onClick={() => screenshots.addField()}>
|
||||
Add Screenshot
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</LabelField>
|
||||
<LabelField label="Short Description">
|
||||
<textarea { ...short_description} />
|
||||
</LabelField>
|
||||
<LabelField className={css.fulldescription} label="Full Description">
|
||||
<textarea { ...full_description} />
|
||||
</LabelField>
|
||||
<LabelField label="Instructions">
|
||||
<textarea placeholder="" { ...instructions } />
|
||||
</LabelField>
|
||||
<LabelField label="End-User License Agreement">
|
||||
<textarea placeholder="" { ...eusa } />
|
||||
</LabelField>
|
||||
<LabelField label="Links">
|
||||
<div>
|
||||
{!links.length && <div>No Links</div>}
|
||||
{links.map((link, index) =>
|
||||
<div key={index}>
|
||||
<div>
|
||||
<Input
|
||||
id={`links-label-input-${index}`}
|
||||
type="text"
|
||||
placeholder="Label"
|
||||
{ ...link.label }
|
||||
/>
|
||||
|
||||
<Input
|
||||
id={`links-url-input-${index}`}
|
||||
type="text"
|
||||
placeholder="https://..."
|
||||
{ ...link.url }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button type="button" onClick={() => links.addField()}>
|
||||
Add Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</LabelField>
|
||||
<LabelField label="Categories">
|
||||
<div>
|
||||
{Object.keys(this.props.categories).map(k =>
|
||||
<Checkbox
|
||||
key={k}
|
||||
label={this.props.categories[k]}
|
||||
onCheck={this.onChangeCategory(k)}
|
||||
checked={categories.value ?
|
||||
categories.value.filter(c => c.name === k).length > 0 :
|
||||
false
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LabelField>
|
||||
<LabelField label="Platforms">
|
||||
<div>
|
||||
{Object.keys(this.props.platforms).map(k =>
|
||||
<Checkbox
|
||||
key={k}
|
||||
label={this.props.platforms[k]}
|
||||
onCheck={this.onChangePlatform(k)}
|
||||
checked={platforms.value ?
|
||||
!!platforms.value.filter(p => p.name === k).length :
|
||||
false
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LabelField>
|
||||
<div className={css.savebutton}>
|
||||
<Button disabled={submitting} className={css.nomargin}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default reduxForm({
|
||||
form: 'adminRepositoryEditMetadataForm',
|
||||
fields: [
|
||||
'id',
|
||||
'display_name',
|
||||
'namespace',
|
||||
'reponame',
|
||||
'publisher.id',
|
||||
'publisher.name',
|
||||
'short_description',
|
||||
'full_description',
|
||||
'logo_url.large',
|
||||
'screenshots[].label',
|
||||
'screenshots[].url',
|
||||
'links[].label',
|
||||
'links[].url',
|
||||
'eusa',
|
||||
'download_attribute',
|
||||
'instructions',
|
||||
|
||||
// TODO (jmorgan): change this to use x[].y redux-form syntax
|
||||
'categories',
|
||||
'platforms',
|
||||
],
|
||||
validate: () => {
|
||||
const errors = {};
|
||||
return errors;
|
||||
},
|
||||
})(AdminRepositoryEditMetadataForm);
|