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:
Eemeli Aro 2022-02-21 20:28:25 +02:00 коммит произвёл GitHub
Родитель dd7c494ab6
Коммит 8d2714d5db
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
46 изменённых файлов: 1842 добавлений и 10934 удалений

1
.gitignore поставляемый
Просмотреть файл

@ -5,6 +5,7 @@ pip-log.txt
docs/_gh-pages
build.py
build
dist
.DS_Store
node_modules
*-min.css

1
.npmrc Normal file
Просмотреть файл

@ -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

Просмотреть файл

@ -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>`_.

9905
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -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',
};