зеркало из https://github.com/mozilla/pontoon.git
Refactor tag-admin to use functional components, stop testing its internals, replace webpack with rollup (#2432)
* refactor(tag-admin): Use functional components, stop testing internals * style(tag-admin): Satisfy Prettier * chore(tag-admin): Add & configure build with Rollup * chore: Drop webpack dependencies as unused terser is included in the root package.json to ensure its availability for pipeline during the Django build via TERSER_BINARY. * chore(tag-admin): Add npm install & build targets to Makefile Also sets engine-strict = true in .npmrc, so attempts to use npm cli versions which do not support workspaces will fail. * chore(tag-admin): Update from rollup-plugin-replace to @rollup/plugin-replace * docs: Update prerequisites
This commit is contained in:
Родитель
dd7c494ab6
Коммит
8d2714d5db
|
@ -5,6 +5,7 @@ pip-log.txt
|
|||
docs/_gh-pages
|
||||
build.py
|
||||
build
|
||||
dist
|
||||
.DS_Store
|
||||
node_modules
|
||||
*-min.css
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
engine-strict = true
|
|
@ -2,6 +2,7 @@
|
|||
*.min.js
|
||||
*.min.css
|
||||
frontend/build
|
||||
tag-admin/dist
|
||||
coverage/
|
||||
|
||||
# libraries
|
||||
|
|
|
@ -21,15 +21,26 @@ Report a `new issue <https://github.com/mozilla/pontoon/issues/new>`_:
|
|||
Docker
|
||||
======
|
||||
|
||||
Everything runs in a Docker container. Thus Pontoon requires fewer things to get
|
||||
started and you're guaranteed to have the same setup as everyone else and it
|
||||
solves some other problems, too.
|
||||
While the front-end (JavaScript) build and tests use the host environment for development,
|
||||
the back-end systems (Python/Django, databases, etc.) run in Docker containers.
|
||||
For production use, also the front-end is built in a container.
|
||||
Thus Pontoon requires fewer things to get started
|
||||
and you're guaranteed to have the same server setup as everyone else.
|
||||
|
||||
If you're not familiar with `Docker <https://docs.docker.com/>`_ and
|
||||
`docker-compose <https://docs.docker.com/compose/overview/>`_, it's worth
|
||||
reading up on.
|
||||
|
||||
|
||||
JavaScript setup
|
||||
================
|
||||
|
||||
For working on the front-end, you need at least Node.js 14 and npm 7
|
||||
(`installation instructions <https://docs.npmjs.com/downloading-and-installing-node-js-and-npm>`_).
|
||||
Parts of the front-end use `npm workspaces <https://docs.npmjs.com/cli/v7/using-npm/workspaces>`_,
|
||||
which are not supported by earlier npm versions.
|
||||
|
||||
|
||||
Database
|
||||
========
|
||||
|
||||
|
@ -217,8 +228,8 @@ Style nits should be covered by linting as much as possible.
|
|||
Code reviews should review the changes in the context of the rest of the system.
|
||||
|
||||
|
||||
Dependencies
|
||||
============
|
||||
Python Dependencies
|
||||
===================
|
||||
|
||||
Direct dependencies for Pontoon are distributed across four files:
|
||||
|
||||
|
@ -389,7 +400,7 @@ steps, as they don't affect your setup if nothing has changed:
|
|||
Building front-end resources
|
||||
============================
|
||||
|
||||
We use webpack to build our JavaScript files for some pages
|
||||
We use Rollup to build our JavaScript files for some pages
|
||||
(currently only the tag admin UI).
|
||||
While `make build` will build those files for you,
|
||||
you might want to rebuild them while programming on the front.
|
||||
|
@ -397,11 +408,11 @@ To build the files just once, run:
|
|||
|
||||
.. code-block:: shell
|
||||
|
||||
$ make build-tagadmin
|
||||
$ npm run build
|
||||
|
||||
If you want to have those files be built automatically when you make changes,
|
||||
you can run:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ make build-tagadmin-w
|
||||
$ npm run build-w
|
||||
|
|
30
Makefile
30
Makefile
|
@ -17,6 +17,7 @@ help:
|
|||
@echo "The list of commands for local development:\n"
|
||||
@echo " build Builds the docker images for the docker-compose setup"
|
||||
@echo " build-frontend Builds just the frontend image"
|
||||
@echo " build-tagadmin Builds just the tag-admin frontend component"
|
||||
@echo " build-server Builds just the Django server image"
|
||||
@echo " server-env Regenerates the env variable file used by server"
|
||||
@echo " setup Configures a local instance after a fresh build"
|
||||
|
@ -26,6 +27,7 @@ help:
|
|||
@echo " ci Test and lint both frontend and server"
|
||||
@echo " test Runs both frontend and server test suites"
|
||||
@echo " test-frontend Runs the translate frontend test suite (Jest)"
|
||||
@echo " test-tagadmin Runs the tag-admin test suite (Jest)"
|
||||
@echo " test-server Runs the server test suite (Pytest)"
|
||||
@echo " format Runs formatters for both the frontend and Python code"
|
||||
@echo " lint Runs linters for both the frontend and Python code"
|
||||
|
@ -41,20 +43,27 @@ help:
|
|||
@echo " dropdb Completely remove the postgres container and its data"
|
||||
@echo " dumpdb Create a postgres database dump with timestamp used as file name"
|
||||
@echo " loaddb Load a database dump into postgres, file name in DB_DUMP_FILE"
|
||||
@echo " build-tagadmin Builds the tag_admin frontend static files"
|
||||
@echo " build-tagadmin-w Watches the tag_admin frontend static files and builds on change"
|
||||
@echo " sync-projects Runs the synchronization task on all projects"
|
||||
@echo " requirements Compiles all requirements files with pip-compile\n"
|
||||
|
||||
.frontend-build:
|
||||
make build-frontend
|
||||
tag-admin/dist:
|
||||
make build-tagadmin
|
||||
.server-build:
|
||||
make build-server
|
||||
node_modules:
|
||||
npm install
|
||||
|
||||
build: build-frontend build-tagadmin build-server
|
||||
|
||||
build: build-frontend build-server
|
||||
build-frontend: server-env
|
||||
"${DC}" build frontend
|
||||
touch .frontend-build
|
||||
|
||||
build-tagadmin: node_modules
|
||||
npm run build -w tag-admin
|
||||
|
||||
build-server: server-env
|
||||
"${DC}" build --build-arg USER_ID=$(USER_ID) --build-arg GROUP_ID=$(GROUP_ID) server
|
||||
touch .server-build
|
||||
|
@ -66,23 +75,26 @@ server-env:
|
|||
setup: .server-build
|
||||
"${DC}" run server //app/docker/server_setup.sh
|
||||
|
||||
run: .frontend-build .server-build
|
||||
run: .frontend-build tag-admin/dist .server-build
|
||||
"${DC}" up
|
||||
|
||||
clean:
|
||||
rm -f .docker-build .frontend-build .server-build
|
||||
rm -rf .docker-build .frontend-build tag-admin/dist .server-build
|
||||
|
||||
shell:
|
||||
"${DC}" run --rm server //bin/bash
|
||||
|
||||
ci: test lint
|
||||
|
||||
test: test-server test-frontend
|
||||
test: test-server test-frontend test-tagadmin
|
||||
|
||||
test-frontend: jest
|
||||
jest:
|
||||
"${DC}" run --rm -w //frontend frontend yarn test
|
||||
|
||||
test-tagadmin:
|
||||
npm test -w tag-admin
|
||||
|
||||
test-server: pytest
|
||||
pytest:
|
||||
"${DC}" run ${run_opts} --rm server pytest --cov-report=xml:pontoon/coverage.xml --cov=. $(opts)
|
||||
|
@ -139,12 +151,6 @@ loaddb:
|
|||
# use docker here instead.
|
||||
"${DOCKER}" exec -i `"${DC}" ps -q postgresql` pg_restore -U pontoon -d pontoon -O < "${DB_DUMP_FILE}"
|
||||
|
||||
build-tagadmin:
|
||||
"${DC}" run --rm server npm run build
|
||||
|
||||
build-tagadmin-w:
|
||||
"${DC}" run --rm server npm run build-w
|
||||
|
||||
sync-projects:
|
||||
"${DC}" run --rm server .//manage.py sync_projects $(opts)
|
||||
|
||||
|
|
|
@ -69,7 +69,7 @@ COPY --chown=pontoon:pontoon . /app/
|
|||
|
||||
# Create the folder for front-end assets
|
||||
RUN mkdir -p assets
|
||||
# Run webpack to compile JS files
|
||||
# Build JS files
|
||||
RUN npm run build
|
||||
|
||||
COPY --from=frontend /frontend/ ./frontend/
|
||||
|
|
|
@ -16,7 +16,7 @@ This guide assumes you have already installed and set up the following:
|
|||
|
||||
1. Git_
|
||||
2. `Python 2.7`_, pip_, and virtualenv_
|
||||
3. `Node.js`_ and npm_ v7 or later
|
||||
3. `Node.js 14`_ and npm_ v7 or later
|
||||
4. `Postgres 9.4 or 9.5`_
|
||||
|
||||
These docs assume a Unix-like operating system, although the site should, in
|
||||
|
|
|
@ -17,7 +17,9 @@ Prerequisites
|
|||
2. Install `docker-compose <https://docs.docker.com/compose/install/>`_. You need
|
||||
1.10 or higher.
|
||||
|
||||
3. Install `make <https://www.gnu.org/software/make/>`_ using either your
|
||||
3. Install `Node.js 14 and npm 7 or later <https://docs.npmjs.com/downloading-and-installing-node-js-and-npm>`_.
|
||||
|
||||
4. Install `make <https://www.gnu.org/software/make/>`_ using either your
|
||||
system's package manager (Linux) or Xcode command line developer tools (OSX).
|
||||
On Windows, you can use `MozillaBuild <https://wiki.mozilla.org/MozillaBuild>`_.
|
||||
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -37,6 +37,7 @@
|
|||
"eslint": "^8.9.0",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"prettier": "^2.5.1",
|
||||
"terser": "^5.10.0",
|
||||
"yuglify": "^2.0.0"
|
||||
},
|
||||
"prettier": {
|
||||
|
|
|
@ -347,5 +347,4 @@
|
|||
|
||||
{% block extend_js %}
|
||||
{% javascript 'admin_project' %}
|
||||
{{ render_bundle('tag_admin') }}
|
||||
{% endblock %}
|
||||
|
|
|
@ -65,6 +65,7 @@ if not DEV and not DEBUG:
|
|||
DATABASES["default"]["OPTIONS"]["sslmode"] = "require"
|
||||
|
||||
FRONTEND_DIR = os.path.join(ROOT, "frontend")
|
||||
TAGADMIN_DIR = os.path.join(ROOT, "tag-admin")
|
||||
|
||||
# Absolute path to the directory static files should be collected to.
|
||||
# Don't put anything in this directory yourself; store your static files
|
||||
|
@ -171,7 +172,6 @@ INSTALLED_APPS = (
|
|||
"allauth.socialaccount.providers.gitlab",
|
||||
"notifications",
|
||||
"graphene_django",
|
||||
"webpack_loader",
|
||||
"django_ace",
|
||||
)
|
||||
|
||||
|
@ -241,7 +241,6 @@ TEMPLATES = [
|
|||
"django_jinja.builtins.extensions.StaticFilesExtension",
|
||||
"django_jinja.builtins.extensions.DjangoFiltersExtension",
|
||||
"pipeline.jinja2.PipelineExtension",
|
||||
"webpack_loader.contrib.jinja2ext.WebpackExtension",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -291,6 +290,7 @@ PIPELINE_CSS = {
|
|||
"source_filenames": (
|
||||
"css/double_list_selector.css",
|
||||
"css/admin_project.css",
|
||||
"tag_admin.css",
|
||||
),
|
||||
"output_filename": "css/admin_project.min.css",
|
||||
},
|
||||
|
@ -425,6 +425,7 @@ PIPELINE_JS = {
|
|||
"js/lib/jquery-ui.js",
|
||||
"js/double_list_selector.js",
|
||||
"js/admin_project.js",
|
||||
"tag_admin.js",
|
||||
),
|
||||
"output_filename": "js/admin_project.min.js",
|
||||
},
|
||||
|
@ -573,6 +574,7 @@ STATICFILES_FINDERS = (
|
|||
STATICFILES_DIRS = [
|
||||
path("assets"),
|
||||
os.path.join(FRONTEND_DIR, "build", "static"),
|
||||
os.path.join(TAGADMIN_DIR, "dist"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -22,11 +22,11 @@ from pontoon.base.models import (
|
|||
|
||||
|
||||
def static_serve_dev(request, path):
|
||||
"""Proxy missing static files to the webpack server.
|
||||
"""Proxy missing static files to the frontend server.
|
||||
|
||||
This view replaces django's static files serve view. When a file is
|
||||
missing from django's paths, then we make a proxy request to the
|
||||
webpack server to see if it's a front-end file.
|
||||
frontend server to see if it's a front-end file.
|
||||
|
||||
Note that to enable this view, you need to run your django with the
|
||||
nostatic option, like this::
|
||||
|
@ -39,7 +39,7 @@ def static_serve_dev(request, path):
|
|||
return serve(request, path, insecure=True)
|
||||
except Http404:
|
||||
# If the file couldn't be found in django's static files, then we
|
||||
# try to proxy it to the webpack server.
|
||||
# try to proxy it to the frontend server.
|
||||
return catchall_dev(request)
|
||||
|
||||
|
||||
|
@ -50,10 +50,10 @@ def catchall_dev(request, context=None):
|
|||
The implementation is very basic e.g. it doesn't handle HTTP headers.
|
||||
|
||||
"""
|
||||
# URL to the development webpack server, used to redirect front-end requests.
|
||||
# URL to the development frontend server, used to redirect front-end requests.
|
||||
upstream_url = settings.FRONTEND_URL + request.path
|
||||
|
||||
# Redirect websocket requests directly to the webpack server.
|
||||
# Redirect websocket requests directly to the frontend server.
|
||||
if request.META.get("HTTP_UPGRADE", "").lower() == "websocket":
|
||||
return http.HttpResponseRedirect(upstream_url)
|
||||
|
||||
|
|
|
@ -29,7 +29,6 @@ django-guardian==2.3.0
|
|||
django-jinja==2.7.0
|
||||
django-notifications-hq==1.6.0
|
||||
django-pipeline==2.0.6
|
||||
django-webpack-loader==0.5.0
|
||||
graphene-django==2.13.0
|
||||
gunicorn==19.9.0
|
||||
jsonfield==3.1.0
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pontoon-tag-admin",
|
||||
"name": "tag-admin",
|
||||
"private": true,
|
||||
"author": "Mozilla",
|
||||
"license": "BSD",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "webpack",
|
||||
"build-w": "webpack -w"
|
||||
"build": "rollup -c",
|
||||
"build-w": "rollup -cw"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^16.14.0",
|
||||
|
@ -14,15 +14,16 @@
|
|||
"react-table": "^6.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-loader": "^8.2.2",
|
||||
"css-loader": "^3.6.0",
|
||||
"@rollup/plugin-babel": "^5.3.0",
|
||||
"@rollup/plugin-commonjs": "^21.0.1",
|
||||
"@rollup/plugin-node-resolve": "^13.1.3",
|
||||
"@rollup/plugin-replace": "^3.1.0",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.6",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^27.5.1",
|
||||
"mini-css-extract-plugin": "^0.4.5",
|
||||
"webpack": "^4.46.0",
|
||||
"webpack-bundle-tracker": "^0.4.3",
|
||||
"webpack-cli": "^3.3.12"
|
||||
"rollup": "^2.67.2",
|
||||
"rollup-plugin-css-only": "^3.1.0",
|
||||
"rollup-plugin-terser": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import path from 'path';
|
||||
import babel from '@rollup/plugin-babel';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import css from 'rollup-plugin-css-only';
|
||||
|
||||
/** @type {import('rollup').RollupOptions} */
|
||||
export default {
|
||||
input: { tag_admin: 'src/index.js' },
|
||||
output: { dir: 'dist/' },
|
||||
plugins: [
|
||||
replace({
|
||||
preventAssignment: true,
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}),
|
||||
resolve(),
|
||||
babel({
|
||||
babelHelpers: 'runtime',
|
||||
configFile: path.resolve('.babelrc'),
|
||||
}),
|
||||
commonjs(),
|
||||
css({ output: 'tag_admin.css' }),
|
||||
],
|
||||
};
|
|
@ -1,45 +1,22 @@
|
|||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { TagResourceManager } from './manager.js';
|
||||
|
||||
export default class TagResourcesButton extends React.Component {
|
||||
state = { open: false };
|
||||
export function TagResourcesButton(props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const message = open
|
||||
? 'Hide the resource manager for this tag'
|
||||
: 'Manage resources for this tag';
|
||||
|
||||
handleClick = (evt) => {
|
||||
evt.preventDefault();
|
||||
this.setState((prevState) => ({ open: !prevState.open }));
|
||||
const toggle = (ev) => {
|
||||
ev.preventDefault();
|
||||
setOpen((open) => !open);
|
||||
};
|
||||
|
||||
renderButton() {
|
||||
const { open } = this.state;
|
||||
let message = 'Manage resources for this tag';
|
||||
if (open) {
|
||||
message = 'Hide the resource manager for this tag';
|
||||
}
|
||||
return <button onClick={this.handleClick}>{message}</button>;
|
||||
}
|
||||
|
||||
renderResourceManager() {
|
||||
const { open } = this.state;
|
||||
if (!open) {
|
||||
return '';
|
||||
}
|
||||
return (
|
||||
<TagResourceManager
|
||||
tag={this.props.tag}
|
||||
api={this.props.api}
|
||||
requestMethod='post'
|
||||
project={this.props.project}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderButton()}
|
||||
{this.renderResourceManager()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<button onClick={toggle}>{message}</button>
|
||||
{open ? <TagResourceManager {...props} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import { shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { TagResourcesButton } from './button.js';
|
||||
|
||||
test('TagResourcesButton renders only button initially', () => {
|
||||
const button = shallow(
|
||||
<TagResourcesButton tag='foo' api='bar' project='baz' />,
|
||||
);
|
||||
expect(button.html()).toBe(
|
||||
'<div><button>Manage resources for this tag</button></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('TagResourcesButton shows TagResourceManager when clicked', () => {
|
||||
const button = shallow(
|
||||
<TagResourcesButton tag='foo' api='bar' project='baz' />,
|
||||
);
|
||||
|
||||
const preventDefault = jest.fn();
|
||||
button.find('button').simulate('click', { preventDefault });
|
||||
expect(button.html()).toMatch('<div class="tag-resource-widget">');
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('TagResourcesButton renders only Button when clicked twice', () => {
|
||||
const button = shallow(
|
||||
<TagResourcesButton tag='foo' api='bar' project='baz' />,
|
||||
);
|
||||
|
||||
button.find('button').simulate('click', { preventDefault: () => {} });
|
||||
expect(button.html()).toMatch('Hide the resource manager for this tag');
|
||||
|
||||
button.find('button').simulate('click', { preventDefault: () => {} });
|
||||
expect(button.html()).toMatch('Manage resources for this tag');
|
||||
});
|
|
@ -1,16 +1,13 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import TagResourcesButton from './button.js';
|
||||
import { TagResourcesButton } from './button.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.js-tag-resources').forEach((node) => {
|
||||
const { api, project, tag } = node.dataset;
|
||||
ReactDOM.render(
|
||||
<TagResourcesButton
|
||||
project={node.dataset.project}
|
||||
tag={node.dataset.tag}
|
||||
api={node.dataset.api}
|
||||
/>,
|
||||
<TagResourcesButton api={api} project={project} tag={tag} />,
|
||||
node,
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,51 +1,46 @@
|
|||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import TagResourceSearch from './search.js';
|
||||
import TagResourceTable from './table.js';
|
||||
import { ErrorList } from './widgets/errors.js';
|
||||
import { TagResourceSearch } from './search.js';
|
||||
import { post } from './utils/http-post.js';
|
||||
import { CheckboxTable } from './widgets/checkbox-table.js';
|
||||
import { ErrorList } from './widgets/error-list.js';
|
||||
|
||||
import './tag-resources.css';
|
||||
|
||||
import { dataManager } from './utils/data.js';
|
||||
export function TagResourceManager({ api }) {
|
||||
const [data, setData] = useState([]);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [type, setType] = useState('assoc');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
export class TagResourceManagerWidget extends React.Component {
|
||||
state = { type: 'assoc', search: '' };
|
||||
const handleChange = useCallback(
|
||||
async (params) => {
|
||||
const response = await post(api, params);
|
||||
const json = await response.json();
|
||||
if (response.status === 200) {
|
||||
setData(json.data || []);
|
||||
setErrors({});
|
||||
} else {
|
||||
setErrors(json.errors || {});
|
||||
}
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
this.props.refreshData({ ...this.state });
|
||||
}
|
||||
useEffect(() => {
|
||||
handleChange({ search, type });
|
||||
}, [search, type]);
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevState !== this.state) {
|
||||
this.props.refreshData({ ...this.state });
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchChange = (change) => {
|
||||
this.setState(change);
|
||||
};
|
||||
|
||||
handleSubmit = (checked) => {
|
||||
return this.props.handleSubmit(Object.assign({}, this.state, checked));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { data, errors } = this.props;
|
||||
const { type } = this.state;
|
||||
return (
|
||||
<div className='tag-resource-widget'>
|
||||
<TagResourceSearch
|
||||
handleSearchChange={this.handleSearchChange}
|
||||
/>
|
||||
<ErrorList errors={errors || {}} />
|
||||
<TagResourceTable
|
||||
data={data || []}
|
||||
type={type}
|
||||
handleSubmit={this.handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const message = type === 'assoc' ? 'Unlink resources' : 'Link resources';
|
||||
return (
|
||||
<div className='tag-resource-widget'>
|
||||
<TagResourceSearch onSearch={setSearch} onType={setType} />
|
||||
<ErrorList errors={errors} />
|
||||
<CheckboxTable
|
||||
data={data}
|
||||
onSubmit={({ data }) => handleChange({ data, search, type })}
|
||||
submitMessage={message}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TagResourceManager = dataManager(TagResourceManagerWidget);
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
import { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { TagResourceManager } from './manager.js';
|
||||
|
||||
// async fetch response handler needs a tick to process
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('TagResourceManager search', async () => {
|
||||
document.querySelector = jest.fn(() => ({ value: '73' }));
|
||||
window.fetch = jest.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
const manager = mount(<TagResourceManager api='x' />);
|
||||
|
||||
// Enter 'FOO' in the search input
|
||||
manager
|
||||
.find('input.search-tag-resources')
|
||||
.simulate('change', { target: { value: 'FOO' } });
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const { calls } = window.fetch.mock;
|
||||
expect(calls).toHaveLength(2);
|
||||
|
||||
expect(Array.from(calls[0][1].body.entries())).toMatchObject([
|
||||
['search', ''],
|
||||
['type', 'assoc'],
|
||||
['csrfmiddlewaretoken', '73'],
|
||||
]);
|
||||
|
||||
expect(Array.from(calls[1][1].body.entries())).toMatchObject([
|
||||
['search', 'FOO'],
|
||||
['type', 'assoc'],
|
||||
['csrfmiddlewaretoken', '73'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('TagResourceManager checkboxing', async () => {
|
||||
document.querySelector = jest.fn(() => ({ value: '73' }));
|
||||
window.fetch = jest.fn(async () => ({
|
||||
status: 200,
|
||||
json: async () => ({ data: [['foo'], ['bar']] }),
|
||||
}));
|
||||
|
||||
const manager = mount(<TagResourceManager api='y' />);
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
manager.update();
|
||||
|
||||
// Check the 'foo' checkbox
|
||||
manager
|
||||
.find('input[name="foo"]')
|
||||
.simulate('change', { target: { name: 'foo', checked: true } });
|
||||
|
||||
// Click on 'Unlink resources'
|
||||
manager
|
||||
.find('button.tag-resources-associate')
|
||||
.simulate('click', { preventDefault: () => {} });
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const { calls } = window.fetch.mock;
|
||||
expect(calls).toHaveLength(2);
|
||||
|
||||
expect(Array.from(calls[0][1].body.entries())).toMatchObject([
|
||||
['search', ''],
|
||||
['type', 'assoc'],
|
||||
['csrfmiddlewaretoken', '73'],
|
||||
]);
|
||||
|
||||
expect(Array.from(calls[1][1].body.entries())).toMatchObject([
|
||||
['data', 'foo'],
|
||||
['search', ''],
|
||||
['type', 'assoc'],
|
||||
['csrfmiddlewaretoken', '73'],
|
||||
]);
|
||||
});
|
|
@ -1,47 +1,44 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Columns } from './widgets/columns.js';
|
||||
|
||||
export default class TagResourceSearch extends React.PureComponent {
|
||||
get columns() {
|
||||
return [
|
||||
[this.renderSearchInput(), 3],
|
||||
[this.renderSearchSelect(), 2],
|
||||
];
|
||||
}
|
||||
|
||||
handleChange = (evt) => {
|
||||
return this.props.handleSearchChange({
|
||||
[evt.target.name]: evt.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
renderSearchInput() {
|
||||
return (
|
||||
<input
|
||||
type='text'
|
||||
className='search-tag-resources'
|
||||
name='search'
|
||||
onChange={this.handleChange}
|
||||
placeholder='Search for resources'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderSearchSelect() {
|
||||
return (
|
||||
<select
|
||||
className='search-tag-resource-type'
|
||||
name='type'
|
||||
onChange={this.handleChange}
|
||||
>
|
||||
<option value='assoc'>Linked</option>
|
||||
<option value='nonassoc'>Not linked</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Columns columns={this.columns} />;
|
||||
}
|
||||
}
|
||||
export const TagResourceSearch = ({ onSearch, onType }) => (
|
||||
<div
|
||||
className='container'
|
||||
style={{ content: '', display: 'table', width: '100%' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
float: 'left',
|
||||
boxSizing: 'border-box',
|
||||
width: '60%',
|
||||
}}
|
||||
>
|
||||
<div className='column'>
|
||||
<input
|
||||
type='text'
|
||||
className='search-tag-resources'
|
||||
name='search'
|
||||
onChange={(ev) => onSearch(ev.target.value)}
|
||||
placeholder='Search for resources'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
float: 'left',
|
||||
boxSizing: 'border-box',
|
||||
width: '40%',
|
||||
}}
|
||||
>
|
||||
<div className='column'>
|
||||
<select
|
||||
className='search-tag-resource-type'
|
||||
name='type'
|
||||
onChange={(ev) => onType(ev.target.value)}
|
||||
>
|
||||
<option value='assoc'>Linked</option>
|
||||
<option value='nonassoc'>Not linked</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { TagResourceSearch } from './search.js';
|
||||
|
||||
test('TagResourceSearch renders search input', () => {
|
||||
const search = mount(<TagResourceSearch />);
|
||||
const input = search.find('input.search-tag-resources');
|
||||
|
||||
expect(input).toHaveLength(1);
|
||||
expect(input.html()).toMatch('placeholder="Search for resources"');
|
||||
});
|
||||
|
||||
test('TagResourceSearch renders select', () => {
|
||||
const search = mount(<TagResourceSearch />);
|
||||
const options = search.find('select.search-tag-resource-type option');
|
||||
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options.at(0).html()).toMatch('"assoc"');
|
||||
expect(options.at(1).html()).toMatch('"nonassoc"');
|
||||
});
|
||||
|
||||
test('TagResourceSearch onChange', async () => {
|
||||
const search = jest.fn();
|
||||
const type = jest.fn();
|
||||
const wrapper = mount(
|
||||
<TagResourceSearch onSearch={search} onType={type} />,
|
||||
);
|
||||
|
||||
wrapper.find('input').simulate('change', { target: { value: 'FOO' } });
|
||||
wrapper.find('select').simulate('change', { target: { value: 'BAR' } });
|
||||
|
||||
expect(search.mock.calls).toEqual([['FOO']]);
|
||||
expect(type.mock.calls).toEqual([['BAR']]);
|
||||
});
|
|
@ -1,39 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import CheckboxTable from './widgets/checkbox-table.js';
|
||||
|
||||
export default class TagResourceTable extends React.PureComponent {
|
||||
submitClass = 'tag-resources-associate';
|
||||
|
||||
get columnResource() {
|
||||
return {
|
||||
Header: 'Resource',
|
||||
id: 'type',
|
||||
Cell: this.renderResource,
|
||||
};
|
||||
}
|
||||
|
||||
get columns() {
|
||||
return [this.columnResource];
|
||||
}
|
||||
|
||||
get submitMessage() {
|
||||
const { type } = this.props;
|
||||
return type === 'assoc' ? 'Unlink resources' : 'Link resources';
|
||||
}
|
||||
|
||||
renderResource(item) {
|
||||
return <span>{item.original[0]}</span>;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CheckboxTable
|
||||
columns={this.columns}
|
||||
submitClass={this.submitClass}
|
||||
submitMessage={this.submitMessage}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,288 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Columns } from './widgets/columns.js';
|
||||
|
||||
import CheckboxTable from './widgets/checkbox-table.js';
|
||||
|
||||
import TagResourcesButton from './button.js';
|
||||
import { TagResourceManagerWidget } from './manager.js';
|
||||
import TagResourceSearch from './search.js';
|
||||
import TagResourceTable from './table.js';
|
||||
|
||||
test('TagResourcesButton render', () => {
|
||||
// shallow render of TagResourcesButton to ensure that
|
||||
// renderButton and renderResourceManager are called in
|
||||
// render method
|
||||
|
||||
class MockTagResourcesButton extends TagResourcesButton {
|
||||
renderButton() {
|
||||
return 7;
|
||||
}
|
||||
|
||||
renderResourceManager() {
|
||||
return 23;
|
||||
}
|
||||
}
|
||||
const button = shallow(<MockTagResourcesButton />);
|
||||
// just the 2 components
|
||||
expect(button.text()).toBe('723');
|
||||
});
|
||||
|
||||
test('TagResourcesButton handleClick', () => {
|
||||
// test that TagResourceButton component state is updated
|
||||
// appropriately when button is clicked
|
||||
|
||||
const button = shallow(<TagResourcesButton />);
|
||||
const evt = { preventDefault: jest.fn() };
|
||||
|
||||
// button is closed by default
|
||||
expect(button.state().open).toBe(false);
|
||||
|
||||
// click the button
|
||||
button.instance().handleClick(evt);
|
||||
|
||||
// component state is not `open`
|
||||
expect(button.state().open).toBe(true);
|
||||
|
||||
// evt.preventDefault was called
|
||||
expect(evt.preventDefault.mock.calls).toEqual([[]]);
|
||||
|
||||
// click the button again
|
||||
button.instance().handleClick(evt);
|
||||
|
||||
// button was closed again
|
||||
expect(button.state().open).toBe(false);
|
||||
|
||||
// evt.preventDefault has been called again
|
||||
expect(evt.preventDefault.mock.calls).toEqual([[], []]);
|
||||
});
|
||||
|
||||
test('TagResourcesButton renderButton', () => {
|
||||
// test that renderButton creates component with expected props and
|
||||
// displays appropriate message according to whether its open/cosed
|
||||
|
||||
const button = shallow(<TagResourcesButton />);
|
||||
let result = button.instance().renderButton();
|
||||
|
||||
// by default prompt is to manage resources
|
||||
expect(result.props.children).toBe('Manage resources for this tag');
|
||||
|
||||
// and the button is set up with the handler
|
||||
expect(result.props.onClick).toBe(button.instance().handleClick);
|
||||
|
||||
button.setState({ open: true });
|
||||
result = button.instance().renderButton();
|
||||
|
||||
// prompt is now to hide the resource manager
|
||||
expect(result.props.children).toBe(
|
||||
'Hide the resource manager for this tag',
|
||||
);
|
||||
|
||||
// and the handler is still set on the component
|
||||
expect(result.props.onClick).toBe(button.instance().handleClick);
|
||||
});
|
||||
|
||||
test('TagResourcesButton renderResourceManager', () => {
|
||||
// Shallow rendering of the resource manager widget
|
||||
|
||||
const button = shallow(
|
||||
<TagResourcesButton tag='foo' api='bar' project='baz' />,
|
||||
);
|
||||
let result = button.instance().renderResourceManager();
|
||||
|
||||
// nothing there as the resource manager is hidden by default
|
||||
expect(result).toBe('');
|
||||
button.setState({ open: true });
|
||||
|
||||
// now its open - result is an instance of TagResourceManager
|
||||
result = button.instance().renderResourceManager();
|
||||
|
||||
// and the TagResourceManager has been inited with the magic props
|
||||
expect(result.props.tag).toBe('foo');
|
||||
expect(result.props.api).toBe('bar');
|
||||
expect(result.props.project).toBe('baz');
|
||||
});
|
||||
|
||||
test('TagResourceManagerWidget componentDidUpdate', async () => {
|
||||
const refreshData = jest.fn();
|
||||
const manager = shallow(
|
||||
<TagResourceManagerWidget refreshData={refreshData} />,
|
||||
);
|
||||
refreshData.mockClear();
|
||||
|
||||
// state is the same, refreshData not called
|
||||
manager.instance().componentDidUpdate(undefined, manager.state());
|
||||
expect(refreshData.mock.calls).toEqual([]);
|
||||
|
||||
// state "changed", refreshData called with current state
|
||||
const prevState = Object.assign({}, manager.state(), { foo: 7, bar: 23 });
|
||||
manager.instance().componentDidUpdate(undefined, prevState);
|
||||
expect(refreshData.mock.calls).toEqual([[manager.state()]]);
|
||||
});
|
||||
|
||||
test('TagResourceManagerWidget handleSubmit', async () => {
|
||||
// Tests that handleSubmit calls this.props.handleSubmit with state and the
|
||||
// calling object
|
||||
|
||||
const handleSubmit = jest.fn(async () => 23);
|
||||
const refreshData = jest.fn();
|
||||
const manager = shallow(
|
||||
<TagResourceManagerWidget
|
||||
refreshData={refreshData}
|
||||
handleSubmit={handleSubmit}
|
||||
/>,
|
||||
);
|
||||
manager.setState({ foo: 7, bar: 23 });
|
||||
expect(await manager.instance().handleSubmit({ bar: 43, baz: 73 })).toBe(
|
||||
23,
|
||||
);
|
||||
expect(handleSubmit.mock.calls).toEqual([
|
||||
[{ bar: 43, baz: 73, foo: 7, search: '', type: 'assoc' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('TagResourceManagerWidget handleSearchChange', async () => {
|
||||
// Tests what happens when the user makes a change to the search furniture
|
||||
// Its expected to call refreshData, after updating state appropriately.
|
||||
|
||||
const refreshData = jest.fn(async () => 23);
|
||||
const manager = shallow(
|
||||
<TagResourceManagerWidget refreshData={refreshData} />,
|
||||
);
|
||||
|
||||
// something changed...
|
||||
manager.instance().handleSearchChange({ foo: 7, search: 23 });
|
||||
|
||||
// state was updated
|
||||
expect(manager.state()).toEqual({ foo: 7, search: 23, type: 'assoc' });
|
||||
});
|
||||
|
||||
test('TagResourceSearch render', () => {
|
||||
// tests that the search widget (shallow) renders as expected
|
||||
|
||||
const search = shallow(<TagResourceSearch />);
|
||||
|
||||
// its a container
|
||||
expect(search.text()).toBe('<Columns />');
|
||||
let container = search.find(Columns);
|
||||
|
||||
// it has 2 columns
|
||||
expect(container.props().columns.length).toBe(2);
|
||||
});
|
||||
|
||||
test('TagResourceSearch renderSearchInput', () => {
|
||||
// tests the search textinput widget (shallow) renders as expected
|
||||
|
||||
const search = shallow(<TagResourceSearch />);
|
||||
let input = search.instance().renderSearchInput();
|
||||
|
||||
// its an input
|
||||
expect(input.type).toBe('input');
|
||||
|
||||
// which has a CSS selector
|
||||
expect(input.props.className).toBe('search-tag-resources');
|
||||
|
||||
// and a placeholder prompt for some searchy
|
||||
expect(input.props.placeholder).toBe('Search for resources');
|
||||
|
||||
// it fires props.handleSearchChange when changed
|
||||
expect(input.props.onChange).toBe(search.instance().handleChange);
|
||||
});
|
||||
|
||||
test('TagResourceSearch renderSearchSelect', () => {
|
||||
// Renders the search select widget for changing action
|
||||
// - ie dis/associate resources to tag
|
||||
|
||||
const search = shallow(<TagResourceSearch />);
|
||||
let select = search.instance().renderSearchSelect();
|
||||
|
||||
// its a select widget
|
||||
expect(select.type).toBe('select');
|
||||
|
||||
// with some CSS selectors
|
||||
expect(select.props.className).toBe('search-tag-resource-type');
|
||||
|
||||
// it fires props.handleSearchChange when changed
|
||||
expect(select.props.onChange).toBe(search.instance().handleChange);
|
||||
|
||||
// it has 2 options with the expected action values
|
||||
expect(select.props.children.length).toBe(2);
|
||||
expect(select.props.children[0].props.value).toBe('assoc');
|
||||
expect(select.props.children[1].props.value).toBe('nonassoc');
|
||||
});
|
||||
|
||||
test('TagResourceSearch handleChange', async () => {
|
||||
const handleChange = jest.fn(async () => 23);
|
||||
const search = shallow(
|
||||
<TagResourceSearch handleSearchChange={handleChange} />,
|
||||
);
|
||||
|
||||
// onChange was called with search and returned
|
||||
const evt = { target: { name: 'SEARCH', value: 'FOO' } };
|
||||
let result = await search.instance().handleChange(evt);
|
||||
expect(result).toBe(23);
|
||||
expect(handleChange.mock.calls).toEqual([[{ SEARCH: 'FOO' }]]);
|
||||
|
||||
handleChange.mockClear();
|
||||
|
||||
// onChange was called with type and returned
|
||||
evt.target = { name: 'TYPE', value: 'BAR' };
|
||||
result = await search.instance().handleChange(evt);
|
||||
expect(result).toBe(23);
|
||||
expect(handleChange.mock.calls).toEqual([[{ TYPE: 'BAR' }]]);
|
||||
});
|
||||
|
||||
test('TagResourceTable render', () => {
|
||||
// Tests (shallow) rendering of TagResourceTable
|
||||
|
||||
let table = shallow(<TagResourceTable />);
|
||||
|
||||
// we should have a single CheckboxTable here
|
||||
expect(table.text()).toBe('<CheckboxTable />');
|
||||
let checkboxTable = table.find(CheckboxTable);
|
||||
expect(checkboxTable.length).toBe(1);
|
||||
expect(checkboxTable.props().submitMessage).toBe('Link resources');
|
||||
|
||||
// reactTable was created with the correct column descriptors
|
||||
expect(checkboxTable.props().columns).toEqual(table.instance().columns);
|
||||
|
||||
// lets render another table with different action
|
||||
table = shallow(<TagResourceTable type='assoc' />);
|
||||
|
||||
// prompt text has changed
|
||||
// but we still get our table
|
||||
expect(table.text()).toBe('<CheckboxTable />');
|
||||
checkboxTable = table.find(CheckboxTable);
|
||||
expect(checkboxTable.length).toBe(1);
|
||||
expect(checkboxTable.props().submitMessage).toBe('Unlink resources');
|
||||
|
||||
// and the expected columns
|
||||
expect(checkboxTable.props().columns).toEqual(table.instance().columns);
|
||||
});
|
||||
|
||||
test('TagResourceTable columns', () => {
|
||||
// tests the column descriptors for the resource table
|
||||
|
||||
let table = shallow(<TagResourceTable />);
|
||||
let checkboxTable = table.find(CheckboxTable);
|
||||
|
||||
// Cell[1] is the resource itself.
|
||||
expect(checkboxTable.props().columns[0].Cell).toBe(
|
||||
table.instance().renderResource,
|
||||
);
|
||||
});
|
||||
|
||||
test('TagResourceTable renderResource', () => {
|
||||
// When an item is rendered, apart from creating the necessary
|
||||
// component, we also expect the pageSize to be updated
|
||||
// appropriately, and for the resource to take its place in the
|
||||
// visible index.
|
||||
|
||||
const table = shallow(<TagResourceTable />);
|
||||
let resource = table.instance().renderResource({ original: ['FOO'] });
|
||||
|
||||
// and our resource has happily rendered
|
||||
expect(resource.props.children).toEqual('FOO');
|
||||
});
|
|
@ -1,185 +0,0 @@
|
|||
import { strip } from './strip.js';
|
||||
import { getCSRFToken } from './csrf.js';
|
||||
|
||||
export class DjangoAjax {
|
||||
/**
|
||||
* This is a wrapper for window.fetch, adding the Headers and form vars
|
||||
* required for Django's xhr/csrf protection
|
||||
*
|
||||
* It only injects headers if it deems the target URL to be "same-origin"
|
||||
* to prevent leaking of csrf data
|
||||
*/
|
||||
|
||||
get csrf() {
|
||||
/**
|
||||
* This is the canonical Django way to retrieve csrf:
|
||||
* return cookies.get('csrftoken');
|
||||
* which is not implemented 8/
|
||||
*/
|
||||
throw new Error('Django csrf not implemented');
|
||||
}
|
||||
|
||||
get headers() {
|
||||
/**
|
||||
* default Headers are set with Django's xhr/csrf
|
||||
* and have `accept` set to "application/json"
|
||||
*/
|
||||
return new Headers({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': this.csrf,
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
appendParams(container, data) {
|
||||
/**
|
||||
* For a given data container - either FormData/URLSearchParams
|
||||
* appends data from an object
|
||||
*
|
||||
* If any of the object values are arrayish, it will append k=v[n]
|
||||
* for each item n of v
|
||||
*/
|
||||
Object.entries(data || {}).forEach(([k, v]) => {
|
||||
if (Array.isArray(v)) {
|
||||
v.forEach((item) => container.append(k, item));
|
||||
} else {
|
||||
container.append(k, v);
|
||||
}
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
asGetParams(data) {
|
||||
/**
|
||||
* Mangle a data object to URLSearchParams
|
||||
*/
|
||||
return this.appendParams(new URLSearchParams(), data);
|
||||
}
|
||||
|
||||
asMultipartForm(data) {
|
||||
/**
|
||||
* Mangle a data object to FormData
|
||||
*/
|
||||
return this.appendParams(new FormData(), data);
|
||||
}
|
||||
|
||||
fetch(url, data, params) {
|
||||
/**
|
||||
* This is a convenience method to allow the API
|
||||
* to be programatically called.
|
||||
*
|
||||
* Defaults to `get`, and currently only implements `get`
|
||||
* and `post`
|
||||
*/
|
||||
let { method } = params || {};
|
||||
method = method || 'get';
|
||||
if (['get', 'GET'].indexOf(method) !== -1) {
|
||||
return this.get(url, data, params);
|
||||
} else if (['post', 'POST'].indexOf(method) !== -1) {
|
||||
return this.post(url, data, params);
|
||||
} else {
|
||||
throw new Error('Unrecognized fetch command: ' + method);
|
||||
}
|
||||
}
|
||||
|
||||
get(url, data, options) {
|
||||
/**
|
||||
* Calls window.fetch with method=get, and request params
|
||||
* as provided by getRequest
|
||||
*
|
||||
*/
|
||||
options = options || {};
|
||||
options.method = 'GET';
|
||||
options.params = this.asGetParams(data);
|
||||
return window.fetch(url, this.getRequest(url, options));
|
||||
}
|
||||
|
||||
getCredentials(url) {
|
||||
/**
|
||||
* Gets the "credentials" - ie "same-origin", "cors" etc
|
||||
*
|
||||
* Matches window URL with requested URL to determine
|
||||
* credentials.
|
||||
*
|
||||
*/
|
||||
if (this.isSameOrigin(url)) {
|
||||
return 'same-origin';
|
||||
}
|
||||
}
|
||||
|
||||
getRequest(url, options) {
|
||||
/**
|
||||
* Builds a request object as expected by window.fetch
|
||||
*
|
||||
*/
|
||||
return Object.assign(
|
||||
{ credentials: this.getCredentials(url), headers: this.headers },
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
isSameOrigin(url) {
|
||||
/**
|
||||
* Matches the protocol, domain and port
|
||||
*
|
||||
* If URLs are relative to the domain they will be
|
||||
* regared as same-origin
|
||||
*
|
||||
*/
|
||||
let { origin, domain, port } = this._parsedLocation;
|
||||
const parsedURL = this._parseURL(url, port);
|
||||
return (
|
||||
parsedURL === domain ||
|
||||
parsedURL === origin ||
|
||||
!/^(\/\/|http:|https:).*/.test(parsedURL)
|
||||
);
|
||||
}
|
||||
|
||||
post(url, data, options) {
|
||||
/**
|
||||
* Calls window.fetch with method=post, and request params
|
||||
* as provided by getRequest
|
||||
*
|
||||
*/
|
||||
options = options || {};
|
||||
options.method = 'POST';
|
||||
if (this.isSameOrigin(url)) {
|
||||
data.csrfmiddlewaretoken = this.csrf;
|
||||
}
|
||||
options.body = this.asMultipartForm(data);
|
||||
return window.fetch(url, this.getRequest(url, options));
|
||||
}
|
||||
|
||||
_parseURL(url, port) {
|
||||
const parser = document.createElement('a');
|
||||
parser.href = url;
|
||||
let parsedURL = parser.protocol + '//' + parser.hostname;
|
||||
if (parser.port !== '') {
|
||||
parsedURL = parsedURL + ':' + parser.port;
|
||||
} else if (!/^(\/\/|http:|https:).*/.test(url) && port) {
|
||||
parsedURL = parsedURL + ':' + port;
|
||||
}
|
||||
return parsedURL;
|
||||
}
|
||||
|
||||
get _parsedLocation() {
|
||||
let hostname = strip.rstrip(window.location.hostname, '/');
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
if (port) {
|
||||
hostname += ':' + port;
|
||||
}
|
||||
const origin = '//' + hostname;
|
||||
const domain = protocol + origin;
|
||||
return { origin, domain, port };
|
||||
}
|
||||
}
|
||||
|
||||
export class PontoonDjangoAjax extends DjangoAjax {
|
||||
get csrf() {
|
||||
// this is a bit sketchy but the only afaict way due to session_csrf
|
||||
return getCSRFToken();
|
||||
}
|
||||
}
|
||||
|
||||
export const ajax = new PontoonDjangoAjax();
|
|
@ -1,219 +0,0 @@
|
|||
import { DjangoAjax, PontoonDjangoAjax, ajax } from './ajax.js';
|
||||
|
||||
test('PontoonDjangoAjax instance', () => {
|
||||
expect(ajax instanceof DjangoAjax).toBe(true);
|
||||
expect(ajax instanceof PontoonDjangoAjax).toBe(true);
|
||||
});
|
||||
|
||||
test('DjangoAjax csrf', () => {
|
||||
// Not implemented
|
||||
const _ajax = new DjangoAjax();
|
||||
expect(() => {
|
||||
_ajax.csrf;
|
||||
}).toThrow(Error);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax csrf', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
document.querySelector = jest.fn(() => ({ value: 'rhubarb' }));
|
||||
expect(_ajax.csrf).toBe('rhubarb');
|
||||
expect(document.querySelector.mock.calls).toEqual([
|
||||
['input[name=csrfmiddlewaretoken]'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax headers', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
document.querySelector = jest.fn(() => ({ value: 'crumble' }));
|
||||
window.Headers = jest.fn(() => ({ 'X-Foo-Header': 'bar' }));
|
||||
expect(_ajax.headers).toEqual({ 'X-Foo-Header': 'bar' });
|
||||
expect(window.Headers.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
Accept: 'application/json',
|
||||
'X-CSRFToken': 'crumble',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax asGetParams', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
const parameters = { append: jest.fn() };
|
||||
window.URLSearchParams = jest.fn(() => parameters);
|
||||
expect(_ajax.asGetParams({ foo: 7, bar: 23, baz: 43 })).toBe(parameters);
|
||||
expect(parameters.append.mock.calls).toEqual([
|
||||
['foo', 7],
|
||||
['bar', 23],
|
||||
['baz', 43],
|
||||
]);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax asMultipartForm', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
const parameters = { append: jest.fn() };
|
||||
window.FormData = jest.fn(() => parameters);
|
||||
expect(_ajax.asMultipartForm({ foo: 17, bar: 73, baz: 117 })).toBe(
|
||||
parameters,
|
||||
);
|
||||
expect(parameters.append.mock.calls).toEqual([
|
||||
['foo', 17],
|
||||
['bar', 73],
|
||||
['baz', 117],
|
||||
]);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax isSameOrigin', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
// default url is set to https://nowhere.com/at/all
|
||||
expect(_ajax.isSameOrigin('https://somwhere.else/all/together')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(_ajax.isSameOrigin('https://somwhere.else/')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('http://somwhere.else/')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('//somwhere.else/')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('http://nowhere.com/')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('http://nowhere.com/like/this')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('https://nowhere.org/and/this')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('https://nowhere.org')).toBe(false);
|
||||
|
||||
expect(_ajax.isSameOrigin('https://nowhere.com/but/this')).toBe(true);
|
||||
expect(_ajax.isSameOrigin('https://nowhere.com/and/this')).toBe(true);
|
||||
expect(_ajax.isSameOrigin('https://nowhere.com/')).toBe(true);
|
||||
expect(_ajax.isSameOrigin('https://nowhere.com')).toBe(true);
|
||||
expect(_ajax.isSameOrigin('//nowhere.com/or/this')).toBe(true);
|
||||
expect(_ajax.isSameOrigin('/else/this')).toBe(true);
|
||||
expect(_ajax.isSameOrigin('even/this')).toBe(true);
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: {
|
||||
href: 'https://nowhere.com:2323/some/where',
|
||||
protocol: 'https:',
|
||||
hostname: 'nowhere.com',
|
||||
host: 'nowhere.com:2323',
|
||||
port: '2323',
|
||||
},
|
||||
});
|
||||
expect(_ajax.isSameOrigin('https://nowhere.com/but/this')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('https://nowhere.com/and/this')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('https://nowhere.com/')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('https://nowhere.com')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('//nowhere.com/or/this')).toBe(false);
|
||||
expect(_ajax.isSameOrigin('https://nowhere.com:2323')).toBe(true);
|
||||
expect(_ajax.isSameOrigin('//nowhere.com:2323/or/this')).toBe(true);
|
||||
expect(_ajax.isSameOrigin('/still/this')).toBe(true);
|
||||
expect(_ajax.isSameOrigin('and/even/this')).toBe(true);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax credentials', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
_ajax.isSameOrigin = jest.fn(() => true);
|
||||
expect(_ajax.getCredentials('X')).toBe('same-origin');
|
||||
expect(_ajax.isSameOrigin.mock.calls).toEqual([['X']]);
|
||||
_ajax.isSameOrigin = jest.fn(() => false);
|
||||
expect(_ajax.getCredentials('Y')).toBe(undefined);
|
||||
expect(_ajax.isSameOrigin.mock.calls).toEqual([['Y']]);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax getRequest', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
_ajax.getCredentials = jest.fn(() => 23);
|
||||
window.Headers = jest.fn(() => ({ 'X-Bar-Header': 'baz' }));
|
||||
expect(_ajax.getRequest('x.com', { foo: 'foo0' })).toEqual({
|
||||
credentials: 23,
|
||||
foo: 'foo0',
|
||||
headers: { 'X-Bar-Header': 'baz' },
|
||||
});
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax fetch', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
|
||||
// default method is get
|
||||
_ajax.get = jest.fn(() => 13);
|
||||
_ajax.post = jest.fn(() => 11);
|
||||
expect(_ajax.fetch('foo.bar', { some: 'data' }, { other: 117 })).toBe(13);
|
||||
expect(_ajax.get.mock.calls).toEqual([
|
||||
['foo.bar', { some: 'data' }, { other: 117 }],
|
||||
]);
|
||||
expect(_ajax.post.mock.calls).toEqual([]);
|
||||
|
||||
// get
|
||||
_ajax.get = jest.fn(() => 7);
|
||||
_ajax.post = jest.fn(() => 23);
|
||||
expect(
|
||||
_ajax.fetch('foo.bar', { some: 'data' }, { method: 'get', other: 117 }),
|
||||
).toBe(7);
|
||||
expect(_ajax.get.mock.calls).toEqual([
|
||||
['foo.bar', { some: 'data' }, { method: 'get', other: 117 }],
|
||||
]);
|
||||
expect(_ajax.post.mock.calls).toEqual([]);
|
||||
|
||||
// post
|
||||
_ajax.get = jest.fn(() => 7);
|
||||
_ajax.post = jest.fn(() => 23);
|
||||
expect(
|
||||
_ajax.fetch('foo.bar', { some: 'data' }, { method: 'post', other: 43 }),
|
||||
).toBe(23);
|
||||
expect(_ajax.get.mock.calls).toEqual([]);
|
||||
expect(_ajax.post.mock.calls).toEqual([
|
||||
['foo.bar', { some: 'data' }, { method: 'post', other: 43 }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax fetch bad method', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
expect(() => {
|
||||
_ajax.fetch('foo.bar', {}, { method: 'baz' });
|
||||
}).toThrow(Error);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax get', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
_ajax.asGetParams = jest.fn(() => 17);
|
||||
_ajax.getRequest = jest.fn(() => 43);
|
||||
window.fetch = jest.fn(() => 73);
|
||||
expect(_ajax.get('foo', 'bar', { baz: 13 })).toBe(73);
|
||||
expect(_ajax.asGetParams.mock.calls).toEqual([['bar']]);
|
||||
expect(_ajax.getRequest.mock.calls).toEqual([
|
||||
['foo', { baz: 13, method: 'GET', params: 17 }],
|
||||
]);
|
||||
expect(window.fetch.mock.calls).toEqual([['foo', 43]]);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax post', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
_ajax.asMultipartForm = jest.fn(() => 17);
|
||||
_ajax.getRequest = jest.fn(() => 43);
|
||||
window.fetch = jest.fn(() => 73);
|
||||
document.querySelector = jest.fn(() => ({ value: '37' }));
|
||||
expect(_ajax.post('foo', { bar: 11 }, { baz: 13 })).toBe(73);
|
||||
expect(_ajax.asMultipartForm.mock.calls).toEqual([
|
||||
[{ bar: 11, csrfmiddlewaretoken: '37' }],
|
||||
]);
|
||||
expect(_ajax.getRequest.mock.calls).toEqual([
|
||||
['foo', { baz: 13, body: 17, method: 'POST' }],
|
||||
]);
|
||||
expect(window.fetch.mock.calls).toEqual([['foo', 43]]);
|
||||
});
|
||||
|
||||
test('PontoonDjangoAjax appendParams', () => {
|
||||
const _ajax = new PontoonDjangoAjax();
|
||||
let container = { append: jest.fn(() => 111) };
|
||||
expect(_ajax.appendParams(container, { foo: 3, bar: 7 })).toBe(container);
|
||||
expect(container.append.mock.calls).toEqual([
|
||||
['foo', 3],
|
||||
['bar', 7],
|
||||
]);
|
||||
container = { append: jest.fn(() => 111) };
|
||||
expect(_ajax.appendParams(container, { foo: 3, bar: [1, 3, 7] })).toBe(
|
||||
container,
|
||||
);
|
||||
expect(container.append.mock.calls).toEqual([
|
||||
['foo', 3],
|
||||
['bar', 1],
|
||||
['bar', 3],
|
||||
['bar', 7],
|
||||
]);
|
||||
});
|
|
@ -1,3 +0,0 @@
|
|||
export const getCSRFToken = () => {
|
||||
return document.querySelector('input[name=csrfmiddlewaretoken]').value;
|
||||
};
|
|
@ -1,81 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { ajax } from './ajax.js';
|
||||
|
||||
export class DataManager {
|
||||
constructor(state) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
get data() {
|
||||
return this.state.data;
|
||||
}
|
||||
|
||||
get errors() {
|
||||
return this.state.errors;
|
||||
}
|
||||
}
|
||||
|
||||
export function dataManager(WrappedComponent, Manager, data) {
|
||||
return class Wrapper extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { errors: {}, data: data };
|
||||
}
|
||||
|
||||
get api() {
|
||||
return this.props.api;
|
||||
}
|
||||
|
||||
get requestMethod() {
|
||||
return this.props.requestMethod || 'get';
|
||||
}
|
||||
|
||||
get submitMethod() {
|
||||
return this.props.submitMethod || this.requestMethod;
|
||||
}
|
||||
|
||||
refreshData = async (params) => {
|
||||
return this.handleResponse(
|
||||
await ajax.fetch(this.api, params, {
|
||||
method: this.requestMethod,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
handleResponse = async (response) => {
|
||||
const { status } = response;
|
||||
const json = await response.json();
|
||||
if (status === 200) {
|
||||
const { data } = json;
|
||||
this.setState({ data, errors: {} });
|
||||
return;
|
||||
}
|
||||
const { errors } = json;
|
||||
this.setState({ errors });
|
||||
};
|
||||
|
||||
handleSubmit = async (params) => {
|
||||
return this.handleResponse(
|
||||
await ajax.fetch(this.api, params, {
|
||||
method: this.submitMethod,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
Manager = Manager || DataManager;
|
||||
const manager = new Manager(this.state);
|
||||
return (
|
||||
<WrappedComponent
|
||||
manager={manager}
|
||||
errors={manager.errors}
|
||||
data={manager.data}
|
||||
handleSubmit={this.handleSubmit}
|
||||
refreshData={this.refreshData}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,171 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { ajax } from './ajax.js';
|
||||
|
||||
import { DataManager, dataManager } from './data.js';
|
||||
|
||||
class MockComponent extends React.PureComponent {
|
||||
render() {
|
||||
return <div />;
|
||||
}
|
||||
}
|
||||
|
||||
test('DataManager constructor', () => {
|
||||
const Manager = dataManager(MockComponent);
|
||||
const manager = shallow(<Manager />);
|
||||
expect(manager.state()).toEqual({ data: undefined, errors: {} });
|
||||
expect(manager.instance().requestMethod).toBe('get');
|
||||
expect(manager.instance().submitMethod).toBe('get');
|
||||
});
|
||||
|
||||
test('DataManager requestMethod', () => {
|
||||
// requestMethod can be overriden in props
|
||||
const Manager = dataManager(MockComponent);
|
||||
const manager = shallow(<Manager requestMethod={23} />);
|
||||
expect(manager.instance().requestMethod).toBe(23);
|
||||
expect(manager.instance().submitMethod).toBe(23);
|
||||
});
|
||||
|
||||
test('DataManager submitMethod', () => {
|
||||
// submitMethod can be overriden in props
|
||||
const Manager = dataManager(MockComponent);
|
||||
const manager = shallow(<Manager submitMethod={7} />);
|
||||
expect(manager.instance().requestMethod).toBe('get');
|
||||
expect(manager.instance().submitMethod).toBe(7);
|
||||
});
|
||||
|
||||
test('DataManager refreshData', async () => {
|
||||
// refreshData calls ajax and gives the child components a shakedown
|
||||
// with the results. Its called in componentDidMount as well as in
|
||||
// response to user actions.
|
||||
|
||||
const Manager = dataManager(MockComponent);
|
||||
const manager = shallow(<Manager api={43} />);
|
||||
manager.instance().handleResponse = jest.fn(async () => 23);
|
||||
ajax.get = jest.fn(async () => 37);
|
||||
let result = await manager.instance().refreshData({ foo: 'BAR' });
|
||||
|
||||
// we get back the Promise of a handled Response.
|
||||
expect(result).toBe(23);
|
||||
|
||||
// handleResponse was called with the awaited content from ajax.get
|
||||
expect(manager.instance().handleResponse.mock.calls).toEqual([[37]]);
|
||||
|
||||
// and ajax.get was called with the expected vars
|
||||
expect(ajax.get.mock.calls).toEqual([
|
||||
[43, { foo: 'BAR' }, { method: 'get' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('DataManager handleSubmit', async () => {
|
||||
// Tests what happens when user clicks handleSubmit
|
||||
|
||||
const Manager = dataManager(MockComponent);
|
||||
const manager = shallow(<Manager api={43} submitMethod='post' />);
|
||||
|
||||
const response = { json: async () => '113' };
|
||||
ajax.post = jest.fn(async () => response);
|
||||
|
||||
manager.instance().handleResponse = jest.fn(async () => 23);
|
||||
|
||||
// ajax gets fired from componentDidMount, so lets clear it
|
||||
ajax.post.mockClear();
|
||||
|
||||
let result = await manager.instance().handleSubmit(7);
|
||||
|
||||
// we expect the async response from handleResponse to be returned
|
||||
expect(result).toBe(23);
|
||||
|
||||
// handleResponse gets called with the Promise of some json from ajax
|
||||
expect(manager.instance().handleResponse.mock.calls).toEqual([[response]]);
|
||||
|
||||
// and ajax got called with all of the expected post data.
|
||||
expect(ajax.post.mock.calls).toEqual([[43, 7, { method: 'post' }]]);
|
||||
});
|
||||
|
||||
test('DataManager handleResponse errors', async () => {
|
||||
// Tests error handling in the ResourceManager widget
|
||||
|
||||
const Manager = dataManager(MockComponent);
|
||||
const manager = shallow(<Manager requestMethod={23} />);
|
||||
|
||||
manager.instance().setState = jest.fn(() => 23);
|
||||
|
||||
// trigger handleResponse with bad status
|
||||
const response = { status: 500, json: jest.fn(async () => ({})) };
|
||||
await manager.instance().handleResponse(response);
|
||||
|
||||
// setState was called with our errors, which we didnt specify, but thats ok.
|
||||
expect(manager.instance().setState.mock.calls).toEqual([
|
||||
[{ errors: undefined }],
|
||||
]);
|
||||
|
||||
// lets see what happens when we specify errors.
|
||||
response.json = jest.fn(async () => ({
|
||||
errors: { foo: 'bad', bar: 'stuff' },
|
||||
}));
|
||||
await manager.instance().handleResponse(response);
|
||||
|
||||
// but this time setState was called with the errors
|
||||
expect(manager.instance().setState.mock.calls[1]).toEqual([
|
||||
{ errors: { bar: 'stuff', foo: 'bad' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('DataManager handleResponse', async () => {
|
||||
// Tests what happens when the manager component receives a response
|
||||
// from ajax requests.
|
||||
// This can either be the result of a search operation or as a
|
||||
// consequence of dis/associating resources to a tag.
|
||||
|
||||
const Manager = dataManager(MockComponent);
|
||||
const manager = shallow(<Manager requestMethod={23} />);
|
||||
|
||||
manager.instance().setState = jest.fn(() => 23);
|
||||
|
||||
// lets trigger an event with good status
|
||||
const response = { status: 200, json: jest.fn(async () => ({})) };
|
||||
await manager.instance().handleResponse(response);
|
||||
|
||||
// we didnt specify any data, but thats OK, and we dont expect any errors
|
||||
expect(manager.instance().setState.mock.calls).toEqual([
|
||||
[{ data: undefined, errors: {} }],
|
||||
]);
|
||||
|
||||
// lets handle a response with some actual data
|
||||
response.json = jest.fn(async () => ({
|
||||
data: { foo: 'good', bar: 'stuff' },
|
||||
}));
|
||||
await manager.instance().handleResponse(response);
|
||||
|
||||
// and setState was called with some lovely data.
|
||||
expect(manager.instance().setState.mock.calls[1]).toEqual([
|
||||
{ data: { bar: 'stuff', foo: 'good' }, errors: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
test('dataManager component', () => {
|
||||
class MockManager extends DataManager {
|
||||
get data() {
|
||||
return 7;
|
||||
}
|
||||
|
||||
get errors() {
|
||||
return 23;
|
||||
}
|
||||
}
|
||||
|
||||
const Manager = dataManager(MockComponent, MockManager);
|
||||
const manager = shallow(<Manager api={43} foo={43} bar={73} />);
|
||||
const wrapped = manager.find(MockComponent);
|
||||
expect(wrapped.length).toBe(1);
|
||||
expect(wrapped.props().manager).toEqual(new MockManager(manager.state()));
|
||||
expect(wrapped.props().refreshData).toBe(manager.instance().refreshData);
|
||||
expect(wrapped.props().handleSubmit).toBe(manager.instance().handleSubmit);
|
||||
expect(wrapped.props().data).toBe(7);
|
||||
expect(wrapped.props().errors).toBe(23);
|
||||
expect(wrapped.props().foo).toBe(43);
|
||||
expect(wrapped.props().bar).toBe(73);
|
||||
});
|
|
@ -0,0 +1,89 @@
|
|||
function asFormData(data) {
|
||||
/**
|
||||
* Mangle a data object to FormData
|
||||
*
|
||||
* If any of the object values are arrayish, it will append k=v[n]
|
||||
* for each item n of v
|
||||
*/
|
||||
const formData = new FormData();
|
||||
if (data) {
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) formData.append(k, item);
|
||||
} else {
|
||||
formData.append(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
function getLocation() {
|
||||
let hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
const port = window.location.port;
|
||||
if (port) {
|
||||
hostname += ':' + port;
|
||||
}
|
||||
const origin = '//' + hostname;
|
||||
const domain = protocol + origin;
|
||||
return { origin, domain, port };
|
||||
}
|
||||
|
||||
function parseURL(url, port) {
|
||||
const parser = document.createElement('a');
|
||||
parser.href = url;
|
||||
let parsedURL = parser.protocol + '//' + parser.hostname;
|
||||
if (parser.port !== '') {
|
||||
parsedURL += ':' + parser.port;
|
||||
} else if (!/^(\/\/|http:|https:).*/.test(url) && port) {
|
||||
parsedURL += ':' + port;
|
||||
}
|
||||
return parsedURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the protocol, domain and port
|
||||
*
|
||||
* If URLs are relative to the domain they will be
|
||||
* regared as same-origin
|
||||
*/
|
||||
export function isSameOrigin(url) {
|
||||
const { origin, domain, port } = getLocation();
|
||||
const parsedURL = parseURL(url, port);
|
||||
return (
|
||||
parsedURL === domain ||
|
||||
parsedURL === origin ||
|
||||
!/^(\/\/|http:|https:).*/.test(parsedURL)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a wrapper for window.fetch, adding the Headers and form vars
|
||||
* required for Django's xhr/csrf protection
|
||||
*
|
||||
* It only injects headers if it deems the target URL to be "same-origin"
|
||||
* to prevent leaking of csrf data
|
||||
*/
|
||||
export function post(url, data) {
|
||||
// this is a bit sketchy but the only afaict way due to session_csrf
|
||||
const csrf = document.querySelector(
|
||||
'input[name=csrfmiddlewaretoken]',
|
||||
).value;
|
||||
|
||||
const init = {
|
||||
body: asFormData(data),
|
||||
headers: new Headers({
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': csrf,
|
||||
}),
|
||||
method: 'POST',
|
||||
};
|
||||
if (isSameOrigin(url)) {
|
||||
init.body.append('csrfmiddlewaretoken', csrf);
|
||||
init.credentials = 'same-origin';
|
||||
}
|
||||
|
||||
return window.fetch(url, init);
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
import { isSameOrigin, post } from './http-post.js';
|
||||
|
||||
test('isSameOrigin', () => {
|
||||
// default url is set to https://nowhere.com/at/all
|
||||
expect(isSameOrigin('https://somwhere.else/all/together')).toBe(false);
|
||||
expect(isSameOrigin('https://somwhere.else/')).toBe(false);
|
||||
expect(isSameOrigin('http://somwhere.else/')).toBe(false);
|
||||
expect(isSameOrigin('//somwhere.else/')).toBe(false);
|
||||
expect(isSameOrigin('http://nowhere.com/')).toBe(false);
|
||||
expect(isSameOrigin('http://nowhere.com/like/this')).toBe(false);
|
||||
expect(isSameOrigin('https://nowhere.org/and/this')).toBe(false);
|
||||
expect(isSameOrigin('https://nowhere.org')).toBe(false);
|
||||
|
||||
expect(isSameOrigin('https://nowhere.com/but/this')).toBe(true);
|
||||
expect(isSameOrigin('https://nowhere.com/and/this')).toBe(true);
|
||||
expect(isSameOrigin('https://nowhere.com/')).toBe(true);
|
||||
expect(isSameOrigin('https://nowhere.com')).toBe(true);
|
||||
expect(isSameOrigin('//nowhere.com/or/this')).toBe(true);
|
||||
expect(isSameOrigin('/else/this')).toBe(true);
|
||||
expect(isSameOrigin('even/this')).toBe(true);
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: {
|
||||
href: 'https://nowhere.com:2323/some/where',
|
||||
protocol: 'https:',
|
||||
hostname: 'nowhere.com',
|
||||
host: 'nowhere.com:2323',
|
||||
port: '2323',
|
||||
},
|
||||
});
|
||||
|
||||
expect(isSameOrigin('https://nowhere.com/but/this')).toBe(false);
|
||||
expect(isSameOrigin('https://nowhere.com/and/this')).toBe(false);
|
||||
expect(isSameOrigin('https://nowhere.com/')).toBe(false);
|
||||
expect(isSameOrigin('https://nowhere.com')).toBe(false);
|
||||
expect(isSameOrigin('//nowhere.com/or/this')).toBe(false);
|
||||
expect(isSameOrigin('https://nowhere.com:2323')).toBe(true);
|
||||
expect(isSameOrigin('//nowhere.com:2323/or/this')).toBe(true);
|
||||
expect(isSameOrigin('/still/this')).toBe(true);
|
||||
expect(isSameOrigin('and/even/this')).toBe(true);
|
||||
});
|
||||
|
||||
test('http post', () => {
|
||||
window.fetch = jest.fn(() => 73);
|
||||
document.querySelector = jest.fn(() => ({ value: '37' }));
|
||||
expect(post('foo', { bar: 11 })).toBe(73);
|
||||
|
||||
const { calls } = window.fetch.mock;
|
||||
expect(calls).toMatchObject([
|
||||
['foo', { credentials: 'same-origin', method: 'POST' }],
|
||||
]);
|
||||
expect(document.querySelector.mock.calls).toEqual([
|
||||
['input[name=csrfmiddlewaretoken]'],
|
||||
]);
|
||||
|
||||
const { body, headers } = calls[0][1];
|
||||
expect(Array.from(body.entries())).toMatchObject([
|
||||
['bar', '11'],
|
||||
['csrfmiddlewaretoken', '37'],
|
||||
]);
|
||||
expect(Array.from(headers.entries())).toMatchObject([
|
||||
['accept', 'application/json'],
|
||||
['x-csrftoken', '37'],
|
||||
['x-requested-with', 'XMLHttpRequest'],
|
||||
]);
|
||||
});
|
|
@ -1,27 +0,0 @@
|
|||
export class Strip {
|
||||
/**
|
||||
* This provides a utility for stripping chars from beginning or end
|
||||
* of strings.
|
||||
*
|
||||
* By default it will strip whitespace - spaces,
|
||||
*/
|
||||
|
||||
strip(str, char) {
|
||||
return this.rstrip(this.lstrip(str, char), char);
|
||||
}
|
||||
|
||||
lstrip(str, char) {
|
||||
return str.replace(new RegExp('^' + (char || '\\s') + '+'), '');
|
||||
}
|
||||
|
||||
rstrip(str, char) {
|
||||
const match = new RegExp('^[^' + (char || '\\s') + ']');
|
||||
for (var i = str.length - 1; i >= 0; i--) {
|
||||
if (match.test(str.charAt(i))) {
|
||||
return str.substring(0, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const strip = new Strip();
|
|
@ -1,44 +0,0 @@
|
|||
import { Strip, strip } from './strip.js';
|
||||
|
||||
test('Strip instance', () => {
|
||||
expect(strip instanceof Strip).toBe(true);
|
||||
});
|
||||
|
||||
test('Strip lstrip', () => {
|
||||
const _strip = new Strip();
|
||||
expect(_strip.lstrip('aaaxyz', 'a')).toBe('xyz');
|
||||
expect(_strip.lstrip('aaaxyz', 'b')).toBe('aaaxyz');
|
||||
expect(_strip.lstrip('abaxyz', 'b')).toBe('abaxyz');
|
||||
expect(_strip.lstrip('bbaxyz', 'b')).toBe('axyz');
|
||||
expect(_strip.lstrip('bbaxyzbbb', 'b')).toBe('axyzbbb');
|
||||
expect(_strip.lstrip(' bbaxyz')).toBe('bbaxyz');
|
||||
expect(_strip.lstrip(' bb axyz')).toBe('bb axyz');
|
||||
expect(_strip.lstrip(' bb axyz ')).toBe('bb axyz ');
|
||||
});
|
||||
|
||||
test('Strip rstrip', () => {
|
||||
const _strip = new Strip();
|
||||
expect(_strip.rstrip('xyzaaa', 'a')).toBe('xyz');
|
||||
expect(_strip.rstrip('xyzaaa', 'b')).toBe('xyzaaa');
|
||||
expect(_strip.rstrip('xyzaba', 'b')).toBe('xyzaba');
|
||||
expect(_strip.rstrip('xyzbba', 'b')).toBe('xyzbba');
|
||||
expect(_strip.rstrip('bbaxyzbbb', 'b')).toBe('bbaxyz');
|
||||
expect(_strip.rstrip('bbaxyz ')).toBe('bbaxyz');
|
||||
expect(_strip.rstrip('bb axyz ')).toBe('bb axyz');
|
||||
expect(_strip.rstrip(' bb axyz ')).toBe(' bb axyz');
|
||||
});
|
||||
|
||||
test('Strip strip', () => {
|
||||
const _strip = new Strip();
|
||||
expect(_strip.strip('xyzaaa', 'a')).toBe('xyz');
|
||||
expect(_strip.strip('xyzaaa', 'b')).toBe('xyzaaa');
|
||||
expect(_strip.strip('xyzaba', 'b')).toBe('xyzaba');
|
||||
expect(_strip.strip('xyzbba', 'b')).toBe('xyzbba');
|
||||
expect(_strip.strip('bbaxyzbbb', 'b')).toBe('axyz');
|
||||
expect(_strip.strip('bbabbaxyzbbabbb', 'b')).toBe('abbaxyzbba');
|
||||
expect(_strip.strip('bbaxyz ')).toBe('bbaxyz');
|
||||
expect(_strip.strip('bb axyz ')).toBe('bb axyz');
|
||||
expect(_strip.strip(' bbaxyz')).toBe('bbaxyz');
|
||||
expect(_strip.strip(' bb axyz')).toBe('bb axyz');
|
||||
expect(_strip.strip(' bb axyz ')).toBe('bb axyz');
|
||||
});
|
|
@ -1,170 +1,109 @@
|
|||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import ReactTable from 'react-table';
|
||||
|
||||
import 'react-table/react-table.css';
|
||||
|
||||
import Checkbox from './checkbox.js';
|
||||
import { Checkbox } from './checkbox.js';
|
||||
|
||||
export default class CheckboxTable extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.visible = [];
|
||||
this.state = { checked: new Set() };
|
||||
}
|
||||
// Returns a copy of the `checked` set with only resource paths that are in `visible`
|
||||
const prune = (checked, visible) =>
|
||||
new Set([...checked].filter((v) => visible.includes(v)));
|
||||
|
||||
get columns() {
|
||||
return [this.columnSelect].concat(this.props.columns || []);
|
||||
}
|
||||
export function CheckboxTable({ data, onSubmit, submitMessage }) {
|
||||
const visible = useRef([]);
|
||||
const [checked, setChecked] = useState(new Set());
|
||||
const clearChecked = () => setChecked(new Set());
|
||||
const pruneChecked = () =>
|
||||
setChecked((checked) => prune(checked, visible.current));
|
||||
|
||||
get columnSelect() {
|
||||
return {
|
||||
Header: this.renderSelectAllCheckbox,
|
||||
Cell: this.renderCheckbox,
|
||||
sortable: false,
|
||||
width: 45,
|
||||
};
|
||||
}
|
||||
useEffect(() => {
|
||||
visible.current.length = 0;
|
||||
clearChecked();
|
||||
}, [data]);
|
||||
|
||||
get defaultPageSize() {
|
||||
return this.props.defaultPageSize || 5;
|
||||
}
|
||||
const selectAll = useCallback(() => {
|
||||
setChecked((checked) => {
|
||||
if (checked.size > 0) return new Set();
|
||||
else return new Set([...visible.current.filter(Boolean)]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
get selected() {
|
||||
// get the pruned list of selected resources, returns all=true/false
|
||||
// if all visible resources are selected
|
||||
// some rows can be empty strings if there are more visible rows than
|
||||
// resources
|
||||
const checked = this.prune(this.state);
|
||||
const selectOne = useCallback(({ target }) => {
|
||||
setChecked((checked) => {
|
||||
const next = new Set(checked);
|
||||
if (target.checked) next.add(target.name);
|
||||
else next.delete(target.name);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const Header = () => {
|
||||
const pruned = prune(checked, visible.current);
|
||||
// some rows can be empty strings if there are more visible rows than resources
|
||||
const some = pruned.size > 0;
|
||||
const all =
|
||||
checked.size > 0 &&
|
||||
checked.size === this.visible.filter((v) => v !== '').length;
|
||||
return { all, checked };
|
||||
}
|
||||
some && pruned.size === visible.current.filter(Boolean).length;
|
||||
|
||||
clearTable() {
|
||||
this.visible.length = 0;
|
||||
this.setState({ checked: new Set() });
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.data !== this.props.data) {
|
||||
this.clearTable();
|
||||
}
|
||||
}
|
||||
|
||||
handleCheckboxClick = (evt) => {
|
||||
// adds/removes paths for submission
|
||||
const { name, checked: targetChecked } = evt.target;
|
||||
this.setState((prevState) => {
|
||||
let { checked } = prevState;
|
||||
checked = new Set(checked);
|
||||
targetChecked ? checked.add(name) : checked.delete(name);
|
||||
return { checked };
|
||||
});
|
||||
};
|
||||
|
||||
handleSelectAll = () => {
|
||||
// user clicked the select all checkbox...
|
||||
// if there are some resources checked already, all are removed
|
||||
// otherwise all visible are checked.
|
||||
this.setState((prevState) => {
|
||||
let { checked } = prevState;
|
||||
checked =
|
||||
checked.size > 0
|
||||
? new Set()
|
||||
: new Set([...this.visible.filter((x) => x)]);
|
||||
return { checked };
|
||||
});
|
||||
};
|
||||
|
||||
handleSubmit = async (evt) => {
|
||||
// after emitting handleSubmit to parent with list of currently
|
||||
// checked, clears the checkboxes
|
||||
evt.preventDefault();
|
||||
const { checked } = this.state;
|
||||
await this.props.handleSubmit({ data: [...checked] });
|
||||
this.setState({ checked: new Set() });
|
||||
};
|
||||
|
||||
handleTableChange = () => {
|
||||
this.clearTable();
|
||||
};
|
||||
|
||||
handleTableResize = (pageSize) => {
|
||||
this.visible.length = pageSize;
|
||||
this.setState((prevState) => ({ checked: this.prune(prevState) }));
|
||||
};
|
||||
|
||||
handleTableSortChange = () => {
|
||||
this.setState((prevState) => ({ checked: this.prune(prevState) }));
|
||||
};
|
||||
|
||||
prune(state) {
|
||||
// Returns a copy of the checked set with any resource paths that are
|
||||
// not in `this.visible` removed
|
||||
let { checked } = state;
|
||||
return new Set(
|
||||
[...checked].filter((v) => this.visible.indexOf(v) !== -1),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderTable()}
|
||||
{this.renderSubmit()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderCheckbox = (item) => {
|
||||
const { checked } = this.state;
|
||||
this.visible.length = item.pageSize;
|
||||
this.visible[item.viewIndex] = item.original[0];
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked.has(item.original[0])}
|
||||
name={item.original[0]}
|
||||
onChange={this.handleCheckboxClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderSelectAllCheckbox = () => {
|
||||
// renders a select all checkbox, sets the check to
|
||||
// indeterminate if only some of the visible resources
|
||||
// are checked
|
||||
let { all, checked } = this.selected;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={all}
|
||||
indeterminate={!all && checked.size > 0}
|
||||
onClick={this.handleSelectAll}
|
||||
indeterminate={some && !all}
|
||||
onChange={selectAll}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderSubmit() {
|
||||
return (
|
||||
<button
|
||||
className={this.props.submitClass}
|
||||
onClick={this.handleSubmit}
|
||||
>
|
||||
{this.props.submitMessage}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const Cell = (item) => {
|
||||
const name = item.original[0];
|
||||
visible.current.length = item.pageSize;
|
||||
visible.current[item.viewIndex] = name;
|
||||
|
||||
renderTable() {
|
||||
return (
|
||||
<ReactTable
|
||||
defaultPageSize={this.defaultPageSize}
|
||||
className='-striped -highlight'
|
||||
data={this.props.data}
|
||||
onPageChange={this.handleTableChange}
|
||||
onPageSizeChange={this.handleTableResize}
|
||||
onSortedChange={this.handleTableSortChange}
|
||||
columns={this.columns}
|
||||
<Checkbox
|
||||
checked={checked.has(name)}
|
||||
name={name}
|
||||
onChange={selectOne}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ Header, Cell, sortable: false, width: 45 },
|
||||
{
|
||||
Header: 'Resource',
|
||||
id: 'type',
|
||||
Cell: (item) => <span>{item.original[0]}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSubmit = async (evt) => {
|
||||
// after emitting handleSubmit to parent with list of currently
|
||||
// checked, clears the checkboxes
|
||||
evt.preventDefault();
|
||||
await onSubmit({ data: [...checked] });
|
||||
clearChecked();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactTable
|
||||
defaultPageSize={5}
|
||||
className='-striped -highlight'
|
||||
data={data}
|
||||
onPageChange={() => {
|
||||
visible.current.length = 0;
|
||||
clearChecked();
|
||||
}}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
visible.current.length = pageSize;
|
||||
pruneChecked();
|
||||
}}
|
||||
onSortedChange={pruneChecked}
|
||||
columns={columns}
|
||||
/>
|
||||
<button className='tag-resources-associate' onClick={handleSubmit}>
|
||||
{submitMessage}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,331 +1,204 @@
|
|||
import { mount, shallow } from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import CheckboxTable from './checkbox-table.js';
|
||||
import { CheckboxTable } from './checkbox-table.js';
|
||||
|
||||
test('CheckboxTable render', () => {
|
||||
// Test the rendering of the checkboxes table widget
|
||||
|
||||
const table = shallow(<CheckboxTable />);
|
||||
expect(table.text()).toBe('<ReactTable />');
|
||||
expect(table.instance().columnSelect).toEqual({
|
||||
Cell: table.instance().renderCheckbox,
|
||||
Header: table.instance().renderSelectAllCheckbox,
|
||||
sortable: false,
|
||||
width: 45,
|
||||
});
|
||||
expect(table.instance().defaultPageSize).toBe(5);
|
||||
});
|
||||
|
||||
test('CheckboxTable renderCheckbox', () => {
|
||||
// Test the rendering of the checkboxes that control the resources
|
||||
test('CheckboxTable render checkboxes', () => {
|
||||
const table = mount(<CheckboxTable data={[]} />);
|
||||
expect(table.find('input[type="checkbox"]')).toHaveLength(1);
|
||||
|
||||
const table = shallow(<CheckboxTable />);
|
||||
let checkbox = table
|
||||
.instance()
|
||||
.renderCheckbox({ original: ['FOO'], pageSize: 5 });
|
||||
table.setProps({ data: [['foo'], ['bar']] });
|
||||
table.update();
|
||||
expect(table.find('input[type="checkbox"]')).toHaveLength(3);
|
||||
|
||||
// not checked by default
|
||||
expect(checkbox.props.checked).toBe(false);
|
||||
|
||||
// name has been taken from `original` list (the row data)
|
||||
expect(checkbox.props.name).toBe('FOO');
|
||||
|
||||
// when the the checkBox changes it fires the handleCheckboxClick evt
|
||||
expect(checkbox.props.onChange).toBe(table.instance().handleCheckboxClick);
|
||||
|
||||
// lets setState with some checked items and render one of them
|
||||
table.setState({ checked: new Set(['FOO', 'BAR']) });
|
||||
checkbox = table
|
||||
.instance()
|
||||
.renderCheckbox({ original: ['FOO'], pageSize: 5 });
|
||||
|
||||
// the checkbox's props are set appropriately
|
||||
expect(checkbox.props.checked).toBe(true);
|
||||
expect(checkbox.props.name).toBe('FOO');
|
||||
expect(checkbox.props.onChange).toBe(table.instance().handleCheckboxClick);
|
||||
expect(table.find('input[name="foo"]').getDOMNode().checked).toBe(false);
|
||||
expect(table.find('input[name="bar"]').getDOMNode().checked).toBe(false);
|
||||
});
|
||||
|
||||
test('CheckboxTable selected', () => {
|
||||
// tests the selected attribute of the CheckboxTable.
|
||||
// the selected attribute returns the union of visible and checked
|
||||
// and responds as to whether all currently visible items are checked
|
||||
test('CheckboxTable select checkboxes', () => {
|
||||
const table = mount(<CheckboxTable data={[['foo'], ['bar']]} />);
|
||||
const inputs = table.find('input[type="checkbox"]');
|
||||
expect(inputs).toHaveLength(3);
|
||||
let checkbox = inputs.at(0).getDOMNode();
|
||||
|
||||
const table = shallow(<CheckboxTable />);
|
||||
let result = table.instance().selected;
|
||||
// Start with no selections
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(checkbox.indeterminate).toBe(false);
|
||||
|
||||
// default is for there to be no checked
|
||||
expect(result).toEqual({ all: false, checked: new Set([]) });
|
||||
// Select 'foo'
|
||||
table
|
||||
.find('input[name="foo"]')
|
||||
.simulate('change', { target: { name: 'foo', checked: true } });
|
||||
checkbox = table.find('input[type="checkbox"]').at(0).getDOMNode();
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(checkbox.indeterminate).toBe(true);
|
||||
|
||||
// set some visible items
|
||||
table.instance().visible = [4, 5, 6];
|
||||
// Select also 'bar'
|
||||
table
|
||||
.find('input[name="bar"]')
|
||||
.simulate('change', { target: { name: 'bar', checked: true } });
|
||||
checkbox = table.find('input[type="checkbox"]').at(0).getDOMNode();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
expect(checkbox.indeterminate).toBe(false);
|
||||
|
||||
// set checked items
|
||||
table.instance().setState({ checked: new Set([1, 2, 3]) });
|
||||
result = table.instance().selected;
|
||||
// Unselect all
|
||||
inputs.at(0).simulate('change', {});
|
||||
expect(table.find('input[name="foo"]').getDOMNode().checked).toBe(false);
|
||||
expect(table.find('input[name="bar"]').getDOMNode().checked).toBe(false);
|
||||
|
||||
// only visible items can be selected
|
||||
expect(result).toEqual({ all: false, checked: new Set([]) });
|
||||
// Select all
|
||||
inputs.at(0).simulate('change', {});
|
||||
expect(table.find('input[name="foo"]').getDOMNode().checked).toBe(true);
|
||||
expect(table.find('input[name="bar"]').getDOMNode().checked).toBe(true);
|
||||
|
||||
// set more visible items, including the checked ones
|
||||
table.instance().visible = [1, 2, 3, 4, 5, 6];
|
||||
result = table.instance().selected;
|
||||
expect(result).toEqual({ all: false, checked: new Set([1, 2, 3]) });
|
||||
|
||||
// set *all* visible items to checked
|
||||
table.instance().visible = [1, 2, 3];
|
||||
result = table.instance().selected;
|
||||
expect(result).toEqual({ all: true, checked: new Set([1, 2, 3]) });
|
||||
// Unselect 'foo'
|
||||
table
|
||||
.find('input[name="foo"]')
|
||||
.simulate('change', { target: { name: 'foo', checked: false } });
|
||||
checkbox = table.find('input[type="checkbox"]').at(0).getDOMNode();
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(checkbox.indeterminate).toBe(true);
|
||||
});
|
||||
|
||||
test('CheckboxTable renderSelectAllCheckbox', () => {
|
||||
// tests the selectAll checkbox is properly (shallow) rendered
|
||||
|
||||
const selected = { all: false, checked: new Set() };
|
||||
|
||||
class MockCheckboxTable extends CheckboxTable {
|
||||
get selected() {
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
|
||||
const table = shallow(<MockCheckboxTable />);
|
||||
let checkbox = table.instance().renderSelectAllCheckbox();
|
||||
|
||||
// by default its not indeterminate or checked, but it does fire
|
||||
// this.handleSelectAll when clicked.
|
||||
expect(checkbox.props.checked).toBe(false);
|
||||
expect(checkbox.props.indeterminate).toBe(false);
|
||||
expect(checkbox.props.onClick).toBe(table.instance().handleSelectAll);
|
||||
|
||||
// if some are selected, indeterminate is true but checked is still false
|
||||
selected.checked = new Set([1, 2, 3]);
|
||||
checkbox = table.instance().renderSelectAllCheckbox();
|
||||
expect(checkbox.props.checked).toBe(false);
|
||||
expect(checkbox.props.indeterminate).toBe(true);
|
||||
expect(checkbox.props.onClick).toBe(table.instance().handleSelectAll);
|
||||
|
||||
// if all are selected then indeterminate is false, and checked is true
|
||||
selected.all = true;
|
||||
checkbox = table.instance().renderSelectAllCheckbox();
|
||||
expect(checkbox.props.checked).toBe(true);
|
||||
expect(checkbox.props.indeterminate).toBe(false);
|
||||
expect(checkbox.props.onClick).toBe(table.instance().handleSelectAll);
|
||||
});
|
||||
|
||||
test('CheckboxTable handleSelectAll', () => {
|
||||
// Tests that when select all is clicked, the checkboxes are checked
|
||||
// or cleared appropriately
|
||||
|
||||
const table = shallow(<CheckboxTable />);
|
||||
table.instance().setState = jest.fn(() => 23);
|
||||
|
||||
table.instance().handleSelectAll();
|
||||
|
||||
// setState was called with an empty checked set - ie cleared
|
||||
// as there are no visible resources
|
||||
let stateFunc = table.instance().setState.mock.calls[0][0];
|
||||
expect(stateFunc(table.state())).toEqual({ checked: new Set([]) });
|
||||
|
||||
// lets add some visible resources
|
||||
table.instance().visible = [1, 2, 3];
|
||||
|
||||
// fire handleSelectAll again...
|
||||
table.instance().handleSelectAll();
|
||||
|
||||
// this time setState is called with all of the visible items
|
||||
stateFunc = table.instance().setState.mock.calls[1][0];
|
||||
expect(stateFunc(table.state())).toEqual({ checked: new Set([1, 2, 3]) });
|
||||
|
||||
// calling handleSelectAll will clear any checked items
|
||||
// if they are not visible
|
||||
table.instance().state.checked = new Set([4, 5]);
|
||||
table.instance().handleSelectAll();
|
||||
stateFunc = table.instance().setState.mock.calls[2][0];
|
||||
expect(stateFunc(table.state())).toEqual({ checked: new Set() });
|
||||
});
|
||||
|
||||
test('CheckboxTable handleTableSortChange', () => {
|
||||
test('CheckboxTable sort change', () => {
|
||||
// Tests what happens when the table sort is changed.
|
||||
// The expectation is that any items in state.checked are
|
||||
// removed if they are no longer visible
|
||||
|
||||
const table = shallow(<CheckboxTable />);
|
||||
const table = mount(
|
||||
<CheckboxTable
|
||||
data={[['1'], ['2'], ['3'], ['4'], ['5'], ['6'], ['7']]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// only 23 is now in both visible and checked
|
||||
table.instance().visible = [23, 113];
|
||||
table.setState({ checked: new Set([23, 43]) });
|
||||
table.instance().setState = jest.fn(() => 113);
|
||||
// Select '2' and '3'
|
||||
table
|
||||
.find('input[name="2"]')
|
||||
.simulate('change', { target: { name: '2', checked: true } });
|
||||
table
|
||||
.find('input[name="3"]')
|
||||
.simulate('change', { target: { name: '3', checked: true } });
|
||||
|
||||
table.instance().handleTableSortChange();
|
||||
// Click twice to use descending sort, which sets checkboxes 7..3 visible
|
||||
const th = table.find('.rt-th.-cursor-pointer');
|
||||
th.simulate('click', {});
|
||||
th.simulate('click', {});
|
||||
|
||||
// setState is called with a checked set containing just 23
|
||||
let stateFunc = table.instance().setState.mock.calls[0][0];
|
||||
expect(stateFunc(table.state())).toEqual({ checked: new Set([23]) });
|
||||
expect(table.find('input[name="2"]')).toHaveLength(0);
|
||||
expect(table.find('input[name="3"]').getDOMNode().checked).toBe(true);
|
||||
|
||||
// Switch back to ascending sort
|
||||
th.simulate('click', {});
|
||||
|
||||
expect(table.find('input[name="2"]').getDOMNode().checked).toBe(false);
|
||||
expect(table.find('input[name="3"]').getDOMNode().checked).toBe(true);
|
||||
});
|
||||
|
||||
test('CheckboxTable handleTableChange', () => {
|
||||
// Tests what happens when the table pagination changes
|
||||
// expectation is for the checked set to be cleared
|
||||
// and the visible list to be truncated
|
||||
test('CheckboxTable page change', () => {
|
||||
const table = mount(
|
||||
<CheckboxTable
|
||||
data={[['1'], ['2'], ['3'], ['4'], ['5'], ['6'], ['7']]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const table = shallow(<CheckboxTable />);
|
||||
table.instance().visible = [23, 43];
|
||||
table.setState({ checked: new Set([23, 43]) });
|
||||
table.instance().setState = jest.fn(() => 113);
|
||||
expect(table.find('input[name="2"]')).toHaveLength(1);
|
||||
expect(table.find('input[name="6"]')).toHaveLength(0);
|
||||
|
||||
table.instance().handleTableChange();
|
||||
// Click 'Next' button
|
||||
const buttons = table.find('button.-btn');
|
||||
expect(buttons).toHaveLength(2);
|
||||
buttons.at(1).simulate('click', {});
|
||||
|
||||
// visible.length is 0
|
||||
expect(table.instance().visible.length).toBe(0);
|
||||
|
||||
// setState was called with an empty set
|
||||
expect(table.instance().setState.mock.calls).toEqual([
|
||||
[{ checked: new Set() }],
|
||||
]);
|
||||
expect(table.find('input[name="2"]')).toHaveLength(0);
|
||||
expect(table.find('input[name="6"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('CheckboxTable handleTableResize', () => {
|
||||
// Tests what happens when the table is resized
|
||||
// The expectation is that any items in the checked set
|
||||
// that are no longer visible are removed and the visible
|
||||
// list is resized accordingly
|
||||
test('CheckboxTable resize', () => {
|
||||
const table = mount(
|
||||
<CheckboxTable
|
||||
data={[['1'], ['2'], ['3'], ['4'], ['5'], ['6'], ['7']]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const table = shallow(<CheckboxTable />);
|
||||
// Resize to 10 rows
|
||||
const select = table.find('.-pageSizeOptions select');
|
||||
select.simulate('change', { target: { value: '10' } });
|
||||
|
||||
// visible and checked have 2 items in common
|
||||
table.instance().visible = [23, 43];
|
||||
table.setState({ checked: new Set([23, 43, 73]) });
|
||||
table.instance().setState = jest.fn(() => 113);
|
||||
// Select '2' and '6'
|
||||
table
|
||||
.find('input[name="2"]')
|
||||
.simulate('change', { target: { name: '2', checked: true } });
|
||||
table
|
||||
.find('input[name="6"]')
|
||||
.simulate('change', { target: { name: '6', checked: true } });
|
||||
|
||||
table.instance().handleTableResize(5);
|
||||
// Resize to 5 rows
|
||||
select.simulate('change', { target: { value: '5' } });
|
||||
|
||||
// visible is filled to pageSize
|
||||
expect(table.instance().visible).toEqual([
|
||||
23,
|
||||
43,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
expect(table.find('input[name="2"]').getDOMNode().checked).toBe(true);
|
||||
expect(table.find('input[name="6"]')).toHaveLength(0);
|
||||
|
||||
// state.checked gets the common items
|
||||
let stateFunc = table.instance().setState.mock.calls[0][0];
|
||||
expect(stateFunc(table.state())).toEqual({ checked: new Set([23, 43]) });
|
||||
// Resize to 10 rows
|
||||
select.simulate('change', { target: { value: '10' } });
|
||||
|
||||
expect(table.find('input[name="2"]').getDOMNode().checked).toBe(true);
|
||||
expect(table.find('input[name="6"]').getDOMNode().checked).toBe(false);
|
||||
});
|
||||
|
||||
test('CheckboxTable prune', () => {
|
||||
// Tests pruning the checked set from the visible list
|
||||
test('CheckboxTable submit', async () => {
|
||||
const spy = jest.fn();
|
||||
const table = mount(
|
||||
<CheckboxTable
|
||||
data={[['1'], ['2'], ['3'], ['4'], ['5'], ['6'], ['7']]}
|
||||
onSubmit={spy}
|
||||
/>,
|
||||
);
|
||||
|
||||
const table = shallow(<CheckboxTable />);
|
||||
let result = table.instance().prune(table.state());
|
||||
// Select '2' and '3'
|
||||
table
|
||||
.find('input[name="2"]')
|
||||
.simulate('change', { target: { name: '2', checked: true } });
|
||||
table
|
||||
.find('input[name="3"]')
|
||||
.simulate('change', { target: { name: '3', checked: true } });
|
||||
|
||||
// visible is empty so result is empty set
|
||||
expect(result).toEqual(new Set([]));
|
||||
// Submit changes
|
||||
table
|
||||
.find('button.tag-resources-associate')
|
||||
.simulate('click', { preventDefault: () => {} });
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
table.update();
|
||||
|
||||
// lets add some items to checked and visible with
|
||||
// some in common
|
||||
table.instance().visible = [1, 3, 5];
|
||||
table.setState({ checked: new Set([0, 1, 2, 3]) });
|
||||
|
||||
// pruned returns the common set
|
||||
expect(table.instance().prune(table.state())).toEqual(new Set([1, 3]));
|
||||
expect(spy.mock.calls).toEqual([[{ data: ['2', '3'] }]]);
|
||||
expect(table.find('input[name="2"]').getDOMNode().checked).toBe(false);
|
||||
expect(table.find('input[name="3"]').getDOMNode().checked).toBe(false);
|
||||
});
|
||||
|
||||
test('CheckboxTable handleSubmit', async () => {
|
||||
// Checks what happens when the form is submitted, to add
|
||||
// or remove associated resources
|
||||
test('CheckboxTable props change', () => {
|
||||
const data = [['1'], ['2'], ['3'], ['4'], ['5'], ['6'], ['7']];
|
||||
const table = mount(<CheckboxTable data={data} />);
|
||||
|
||||
const handleSubmit = jest.fn(async () => 23);
|
||||
const table = shallow(<CheckboxTable handleSubmit={handleSubmit} />);
|
||||
const evt = { preventDefault: jest.fn() };
|
||||
// Select '2' and '3'
|
||||
table
|
||||
.find('input[name="2"]')
|
||||
.simulate('change', { target: { name: '2', checked: true } });
|
||||
table
|
||||
.find('input[name="3"]')
|
||||
.simulate('change', { target: { name: '3', checked: true } });
|
||||
|
||||
// handleSubmit returns a Promise
|
||||
await table.instance().handleSubmit(evt);
|
||||
expect(table.find('input[name="2"]').getDOMNode().checked).toBe(true);
|
||||
expect(table.find('input[name="3"]').getDOMNode().checked).toBe(true);
|
||||
|
||||
// evt.preventDefault was called
|
||||
expect(evt.preventDefault.mock.calls).toEqual([[]]);
|
||||
// Re-setting props clears checkboxes
|
||||
table.setProps({ data: [...data] });
|
||||
table.update();
|
||||
|
||||
// the call was bubbled to props.handleSubmit with an empty list for data...
|
||||
expect(handleSubmit.mock.calls).toEqual([[{ data: [] }]]);
|
||||
|
||||
// ... as state.checked is empty
|
||||
expect(table.state().checked).toEqual(new Set());
|
||||
|
||||
// lets add some checked items and fire again
|
||||
table.instance().state.checked = new Set([1, 2, 3]);
|
||||
await table.instance().handleSubmit(evt);
|
||||
|
||||
// this time props.handleSubmit gets called with an array
|
||||
// from the set, and evt.preventDefault was fired again
|
||||
expect(handleSubmit.mock.calls[1]).toEqual([{ data: [1, 2, 3] }]);
|
||||
expect(evt.preventDefault.mock.calls[1]).toEqual([]);
|
||||
|
||||
// state.checked got cleared
|
||||
expect(table.state().checked).toEqual(new Set());
|
||||
});
|
||||
|
||||
test('CheckboxTable handleCheckboxClick', () => {
|
||||
// Tests that when a checkbox is clicked it is added/remove
|
||||
// from the state.checked set, and that evt.preventDefault is fired
|
||||
|
||||
const table = shallow(<CheckboxTable />);
|
||||
|
||||
// create a fake evt
|
||||
const evt = {
|
||||
preventDefault: jest.fn(),
|
||||
target: { checked: false, name: 23 },
|
||||
};
|
||||
|
||||
// fire handleCheckboxClick with it...
|
||||
table.instance().handleCheckboxClick(evt);
|
||||
|
||||
// nothing was added as checked=false
|
||||
const checked = new Set();
|
||||
expect(table.state()).toEqual({ checked });
|
||||
|
||||
// lets make checked=true and handle again...
|
||||
evt.target.checked = true;
|
||||
table.instance().handleCheckboxClick(evt);
|
||||
checked.add(23);
|
||||
|
||||
// this time we get the named item in the checked set
|
||||
expect(table.state()).toEqual({ checked });
|
||||
|
||||
// firing again does nothing
|
||||
table.instance().handleCheckboxClick(evt);
|
||||
expect(table.state()).toEqual({ checked });
|
||||
|
||||
// calling with checked=false removes the item
|
||||
evt.target.checked = false;
|
||||
table.instance().handleCheckboxClick(evt);
|
||||
checked.delete(23);
|
||||
expect(table.state()).toEqual({ checked });
|
||||
});
|
||||
|
||||
test('CheckboxTable componentWillReceiveProps', () => {
|
||||
// tests the CheckboxTable componentWillReceiveProps method
|
||||
|
||||
const data = [23];
|
||||
const table = shallow(<CheckboxTable data={data} />);
|
||||
|
||||
// visible is the (tracked) currently visible resources
|
||||
table.instance().visible = [1, 2, 3];
|
||||
|
||||
// lets setState with some "checked" data
|
||||
table.setState({ checked: new Set(['a', 'b', 'c']) });
|
||||
table.instance().UNSAFE_componentWillReceiveProps({ data });
|
||||
|
||||
// checked has been updated, an visible has remained the same
|
||||
expect(table.state().checked).toEqual(new Set(['a', 'b', 'c']));
|
||||
expect(table.instance().visible).toEqual([1, 2, 3]);
|
||||
|
||||
// this time lets update the *data* (like when new data comes back
|
||||
// from ajax).
|
||||
table.instance().UNSAFE_componentWillReceiveProps({ data: [7] });
|
||||
|
||||
// nothing checked or visible round here no more
|
||||
expect(table.state().checked).toEqual(new Set([]));
|
||||
expect(table.instance().visible).toEqual([]);
|
||||
expect(table.find('input[name="2"]').getDOMNode().checked).toBe(false);
|
||||
expect(table.find('input[name="3"]').getDOMNode().checked).toBe(false);
|
||||
});
|
||||
|
|
|
@ -1,33 +1,12 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export default class Checkbox extends React.Component {
|
||||
/* A checkbox which you can set `indeterminate` on
|
||||
*
|
||||
*/
|
||||
/** A checkbox which you can set `indeterminate` on */
|
||||
export function Checkbox({ indeterminate, ...props }) {
|
||||
const ref = useRef();
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.el = {};
|
||||
}
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.indeterminate = !!indeterminate;
|
||||
}, [indeterminate]);
|
||||
|
||||
get indeterminate() {
|
||||
return this.props.indeterminate ? true : false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.el.indeterminate = this.indeterminate;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.indeterminate !== this.props.indeterminate) {
|
||||
this.el.indeterminate = this.indeterminate;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { indeterminate, ...props } = this.props;
|
||||
return (
|
||||
<input {...props} type='checkbox' ref={(el) => (this.el = el)} />
|
||||
);
|
||||
}
|
||||
return <input {...props} type='checkbox' ref={ref} />;
|
||||
}
|
||||
|
|
|
@ -1,37 +1,22 @@
|
|||
import { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { mount, shallow } from 'enzyme';
|
||||
|
||||
import Checkbox from './checkbox.js';
|
||||
import { Checkbox } from './checkbox.js';
|
||||
|
||||
test('Checkbox render', () => {
|
||||
let checkbox = shallow(<Checkbox />);
|
||||
expect(checkbox.text()).toBe('');
|
||||
expect(checkbox.instance().el.indeterminate).toBe(false);
|
||||
|
||||
checkbox = shallow(<Checkbox indeterminate='true' />);
|
||||
expect(checkbox.text()).toBe('');
|
||||
expect(checkbox.instance().el.indeterminate).toBe(true);
|
||||
expect(checkbox.instance().el.nodeName).toBe(undefined);
|
||||
|
||||
checkbox = mount(<Checkbox />);
|
||||
// this time el is an HTML node
|
||||
expect(checkbox.instance().el.indeterminate).toBe(false);
|
||||
expect(checkbox.instance().el.nodeName).toEqual('INPUT');
|
||||
|
||||
checkbox = mount(<Checkbox indeterminate={true} />);
|
||||
expect(checkbox.instance().el.indeterminate).toBe(true);
|
||||
expect(checkbox.instance().el.nodeName).toEqual('INPUT');
|
||||
|
||||
let prevProp = checkbox.props();
|
||||
checkbox.setProps({ indeterminate: false });
|
||||
checkbox.instance().componentDidUpdate(prevProp);
|
||||
expect(checkbox.instance().el.indeterminate).toBe(false);
|
||||
expect(checkbox.instance().el.nodeName).toEqual('INPUT');
|
||||
|
||||
prevProp = checkbox.props();
|
||||
checkbox.setProps({ indeterminate: true });
|
||||
checkbox.instance().componentDidUpdate(prevProp);
|
||||
expect(checkbox.instance().el.indeterminate).toBe(true);
|
||||
expect(checkbox.instance().el.nodeName).toEqual('INPUT');
|
||||
const checkbox = mount(<Checkbox id='x' />);
|
||||
expect(checkbox.find('input[type="checkbox"]#x')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Checkbox indeterminate', () => {
|
||||
const checkbox = mount(<Checkbox indeterminate />);
|
||||
expect(checkbox.getDOMNode().indeterminate).toBe(true);
|
||||
});
|
||||
|
||||
test('Checkbox switch', () => {
|
||||
const checkbox = mount(<Checkbox indeterminate={0} />);
|
||||
expect(checkbox.getDOMNode().indeterminate).toBe(false);
|
||||
|
||||
checkbox.setProps({ indeterminate: 1 });
|
||||
expect(checkbox.getDOMNode().indeterminate).toBe(true);
|
||||
});
|
||||
|
|
|
@ -1,89 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
export class Columns extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<Container
|
||||
columns={this.props.columns.length}
|
||||
ratios={this.props.columns.map(([, v]) => v)}
|
||||
>
|
||||
{this.props.columns.map(([column], key) => {
|
||||
return <Column key={key}>{column}</Column>;
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Container extends React.Component {
|
||||
createColumnStyle(width) {
|
||||
return {
|
||||
float: 'left',
|
||||
boxSizing: 'border-box',
|
||||
width: width.toString() + '%',
|
||||
};
|
||||
}
|
||||
|
||||
get containerStyle() {
|
||||
return {
|
||||
content: '',
|
||||
display: 'table',
|
||||
width: '100%',
|
||||
};
|
||||
}
|
||||
|
||||
get columnStyles() {
|
||||
let { columns, ratios } = this.props;
|
||||
let result = [];
|
||||
ratios = ratios || [];
|
||||
if (!columns) {
|
||||
return result;
|
||||
}
|
||||
if (ratios.length) {
|
||||
// sum of rations
|
||||
const total = ratios.reduce((a, b) => a + b, 0);
|
||||
// percentage widths of each column
|
||||
const widths = ratios.map((column) => (column / total) * 100);
|
||||
// give each column the correct width
|
||||
widths.map((width) => result.push(this.createColumnStyle(width)));
|
||||
return result;
|
||||
} else {
|
||||
// give each column an equal share
|
||||
return [
|
||||
...Array(columns)
|
||||
.fill()
|
||||
.map(() => this.createColumnStyle(100 / columns)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let { children } = this.props;
|
||||
return (
|
||||
<div className='container' style={this.containerStyle}>
|
||||
{React.Children.map(children || [], (child, key) => {
|
||||
let columnStyle = {};
|
||||
if (this.columnStyles.length >= key) {
|
||||
columnStyle = this.columnStyles[key];
|
||||
}
|
||||
return (
|
||||
<div key={key} style={columnStyle || {}}>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Column extends React.PureComponent {
|
||||
render() {
|
||||
const { className } = this.props;
|
||||
let _className = 'column';
|
||||
if (className) {
|
||||
_className = _className + ' ' + className;
|
||||
}
|
||||
return <div className={_className}>{this.props.children}</div>;
|
||||
}
|
||||
}
|
|
@ -1,125 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { Columns, Container, Column } from './columns.js';
|
||||
|
||||
test('Columns render', () => {
|
||||
const columndescriptors = [
|
||||
['a', 2],
|
||||
['b', 3],
|
||||
['c', 7],
|
||||
];
|
||||
const columns = shallow(<Columns columns={columndescriptors} />);
|
||||
const container = columns.find(Container);
|
||||
expect(container.length).toBe(1);
|
||||
expect(container.props().columns).toBe(3);
|
||||
expect(columns.props().ratios).toEqual([2, 3, 7]);
|
||||
expect(container.props().children.length).toBe(3);
|
||||
expect(container.props().children[0].props.children).toBe('a');
|
||||
expect(container.props().children[0].type).toBe(Column);
|
||||
expect(container.props().children[1].props.children).toBe('b');
|
||||
expect(container.props().children[1].type).toBe(Column);
|
||||
expect(container.props().children[2].props.children).toBe('c');
|
||||
expect(container.props().children[2].type).toBe(Column);
|
||||
expect(columns.text()).toBe('<Container />');
|
||||
});
|
||||
|
||||
test('Column render', () => {
|
||||
let column = shallow(<Column />);
|
||||
expect(column.text()).toBe('');
|
||||
let div = column.find('div');
|
||||
expect(div.length).toBe(1);
|
||||
expect(div.props().className).toBe('column');
|
||||
column = shallow(<Column>xyz</Column>);
|
||||
expect(column.text()).toBe('xyz');
|
||||
column = shallow(<Column className='special-column'></Column>);
|
||||
div = column.find('div');
|
||||
expect(div.length).toBe(1);
|
||||
expect(div.props().className).toBe('column special-column');
|
||||
});
|
||||
|
||||
test('Container render', () => {
|
||||
let container = shallow(<Container />);
|
||||
expect(container.text()).toBe('');
|
||||
expect(container.instance().columnStyles).toEqual([]);
|
||||
let div = container.find('div');
|
||||
expect(div.props().className).toBe('container');
|
||||
expect(div.props().style).toEqual({
|
||||
content: '',
|
||||
display: 'table',
|
||||
width: '100%',
|
||||
});
|
||||
expect(div.props().children).toEqual([]);
|
||||
container = shallow(<Container columns={3} />);
|
||||
expect(container.text()).toBe('');
|
||||
div = container.find('div');
|
||||
expect(container.instance().columnStyles).toEqual([
|
||||
{
|
||||
boxSizing: 'border-box',
|
||||
float: 'left',
|
||||
width: '33.333333333333336%',
|
||||
},
|
||||
{
|
||||
boxSizing: 'border-box',
|
||||
float: 'left',
|
||||
width: '33.333333333333336%',
|
||||
},
|
||||
{
|
||||
boxSizing: 'border-box',
|
||||
float: 'left',
|
||||
width: '33.333333333333336%',
|
||||
},
|
||||
]);
|
||||
expect(div.props().children).toEqual([]);
|
||||
expect(div.props().style).toEqual({
|
||||
content: '',
|
||||
display: 'table',
|
||||
width: '100%',
|
||||
});
|
||||
expect(div.props().children).toEqual([]);
|
||||
container = shallow(<Container columns={3} ratios={[2, 3, 7]} />);
|
||||
expect(container.text()).toBe('');
|
||||
expect(container.instance().columnStyles).toEqual([
|
||||
{
|
||||
boxSizing: 'border-box',
|
||||
float: 'left',
|
||||
width: '16.666666666666664%',
|
||||
},
|
||||
{ boxSizing: 'border-box', float: 'left', width: '25%' },
|
||||
{
|
||||
boxSizing: 'border-box',
|
||||
float: 'left',
|
||||
width: '58.333333333333336%',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('Container render children', () => {
|
||||
const children = [<div key={1}>FOO...</div>, <div key={2}>...BAR</div>];
|
||||
const styles = jest.fn(() => {
|
||||
return [
|
||||
{ background: 'black', color: 'red' },
|
||||
{ background: 'green', color: 'gold' },
|
||||
];
|
||||
});
|
||||
|
||||
class MockContainer extends Container {
|
||||
get columnStyles() {
|
||||
return styles();
|
||||
}
|
||||
}
|
||||
|
||||
const container = shallow(<MockContainer>{children}</MockContainer>);
|
||||
expect(container.text()).toBe('FOO......BAR');
|
||||
expect(styles.mock.calls).toEqual([[], [], [], []]);
|
||||
const div = container.find('div.container');
|
||||
expect(div.length).toBe(1);
|
||||
expect(div.props().children.length).toBe(2);
|
||||
const child0 = div.props().children[0];
|
||||
const child1 = div.props().children[1];
|
||||
expect(child0.props.style).toEqual({ background: 'black', color: 'red' });
|
||||
expect(child0.props.children).toEqual(children[0]);
|
||||
expect(child1.props.style).toEqual({ background: 'green', color: 'gold' });
|
||||
expect(child1.props.children).toEqual(children[1]);
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
|
||||
import './error-list.css';
|
||||
|
||||
export function ErrorList({ errors }) {
|
||||
const entries = Object.entries(errors);
|
||||
return entries.length === 0 ? null : (
|
||||
<ul className='errors'>
|
||||
{entries.map(([name, error], index) => (
|
||||
<li className='error' key={index}>
|
||||
{name}: {error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { ErrorList } from './error-list.js';
|
||||
|
||||
test('ErrorList render', () => {
|
||||
const errors = mount(<ErrorList errors={{}} />);
|
||||
expect(errors.text()).toBe('');
|
||||
expect(errors.find('ul').length).toBe(0);
|
||||
|
||||
errors.setProps({ errors: { foo: 'Did a foo', bar: 'Bars happen' } });
|
||||
expect(errors.find('ul.errors')).toHaveLength(1);
|
||||
expect(errors.find('li.error')).toHaveLength(2);
|
||||
expect(errors.find('li.error').at(1).html()).toBe(
|
||||
'<li class="error">bar: Bars happen</li>',
|
||||
);
|
||||
});
|
|
@ -1,53 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import './errors.css';
|
||||
|
||||
export class Error extends React.PureComponent {
|
||||
get className() {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
render() {
|
||||
let { className, error, name, ...props } = this.props;
|
||||
className = className
|
||||
? className + ' ' + this.className
|
||||
: this.className;
|
||||
return (
|
||||
<li className={className} {...props}>
|
||||
{name}: {error}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ErrorList extends React.PureComponent {
|
||||
get className() {
|
||||
return 'errors';
|
||||
}
|
||||
|
||||
get errorComponent() {
|
||||
return Error;
|
||||
}
|
||||
|
||||
renderError(key, name, error) {
|
||||
const ErrorComponent = this.errorComponent;
|
||||
return <ErrorComponent error={error} key={key} name={name} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
let { errors, className, ...props } = this.props;
|
||||
className = className
|
||||
? className + ' ' + this.className
|
||||
: this.className;
|
||||
if (!Object.keys(errors).length) {
|
||||
return '';
|
||||
}
|
||||
return (
|
||||
<ul className={className} {...props}>
|
||||
{Object.entries(errors).map(([name, error], key) => {
|
||||
return this.renderError(key, name, error);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { ErrorList, Error } from './errors.js';
|
||||
|
||||
test('Error render', () => {
|
||||
let error = shallow(<Error name='FOO' error='BAR' />);
|
||||
expect(error.text()).toBe('FOO: BAR');
|
||||
let li = error.find('li.error');
|
||||
expect(li.length).toBe(1);
|
||||
});
|
||||
|
||||
test('ErrorList render', () => {
|
||||
let errors = shallow(<ErrorList errors={{}} />);
|
||||
expect(errors.text()).toBe('');
|
||||
expect(errors.find('ul').length).toBe(0);
|
||||
errors = shallow(
|
||||
<ErrorList errors={{ foo: 'Did a foo', bar: 'Bars happen' }} />,
|
||||
);
|
||||
let ul = errors.find('ul.errors');
|
||||
expect(ul.length).toBe(1);
|
||||
let lis = ul.find(Error);
|
||||
expect(lis.length).toBe(2);
|
||||
expect(errors.text()).toBe('<Error /><Error />');
|
||||
});
|
|
@ -1,60 +0,0 @@
|
|||
/* eslint-env node */
|
||||
|
||||
const { resolve } = require('path');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const BundleTracker = require('webpack-bundle-tracker');
|
||||
|
||||
/** @type {import('webpack').Configuration} */
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
|
||||
entry: {
|
||||
tag_admin: resolve(__dirname, 'src/index.js'),
|
||||
},
|
||||
|
||||
output: {
|
||||
// This copies each source entry into the extension dist folder named
|
||||
// after its entry config key.
|
||||
path: resolve(__dirname, '../assets/webpack_bundles/'),
|
||||
filename: '[name].entry.chunk.js',
|
||||
chunkFilename: '[name].[chunkhash].js',
|
||||
publicPath: '/static/webpack_bundles/',
|
||||
},
|
||||
|
||||
module: {
|
||||
// This transpiles all code (except for third party modules) using Babel.
|
||||
rules: [
|
||||
{
|
||||
exclude: /node_modules/,
|
||||
test: /\.js$/,
|
||||
loaders: ['babel-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
resolve: {
|
||||
// This allows you to import modules just like you would in a NodeJS app.
|
||||
extensions: ['.js', '.jsx'],
|
||||
modules: ['node_modules'],
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// Read by django-webpack-loader
|
||||
new BundleTracker({ filename: '../webpack-stats.json' }),
|
||||
|
||||
new MiniCssExtractPlugin({
|
||||
// Options similar to the same options in webpackOptions.output
|
||||
// both options are optional
|
||||
filename: '[name].css',
|
||||
chunkFilename: '[id].css',
|
||||
}),
|
||||
],
|
||||
|
||||
// This will expose source map files so that errors will point to your
|
||||
// original source files instead of the transpiled files.
|
||||
devtool: 'sourcemap',
|
||||
};
|
Загрузка…
Ссылка в новой задаче