Add generic React components

Includes moving some components from AssetAdmin
This commit is contained in:
Paul Clarke 2016-03-22 12:25:23 +13:00 committed by Ingo Schommer
parent b0ba742c1f
commit 0ca090a391
49 changed files with 1168 additions and 160 deletions

View File

@ -92,27 +92,27 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
"customValidationMessage": "",
"attributes": [],
"data": {
'collectionReadUrl': {
'url': 'admin\/campaigns\/items',
'method': 'GET'
"collectionReadUrl": {
"url": "admin\/campaigns\/items",
"method": "GET"
},
'itemReadUrl': {
'url': 'admin\/campaigns\/item\/:id',
'method': 'GET'
"itemReadUrl": {
"url": "admin\/campaigns\/item\/:id",
"method": "GET"
},
'itemUpdateUrl': {
'url': 'admin\/campaigns\/item\/:id',
'method': 'PUT'
"itemUpdateUrl": {
"url": "admin\/campaigns\/item\/:id",
"method": "PUT"
},
'itemCreateUrl': {
'url': 'admin\/campaigns\/item\/:id',
'method': 'POST'
"itemCreateUrl": {
"url": "admin\/campaigns\/item\/:id",
"method": "POST"
},
'itemDeleteUrl': {
'url': 'admin\/campaigns\/item\/:id',
'method': 'DELETE'
"itemDeleteUrl": {
"url": "admin\/campaigns\/item\/:id",
"method": "DELETE"
},
'editFormSchemaUrl': 'admin\/campaigns\/schema\/DetailEditForm'
"editFormSchemaUrl": "admin\/campaigns\/schema\/DetailEditForm"
}
}, {
"name": "SecurityID",

View File

@ -18,6 +18,7 @@ if(typeof(ss) == 'undefined' || typeof(ss.i18n) == 'undefined') {
"ModelAdmin.REALLYDELETE": "Do you really want to delete?",
"ModelAdmin.DELETED": "Deleted",
"ModelAdmin.VALIDATIONERROR": "Validation Error",
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left."
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left.",
"Campaigns.ADDCAMPAIGN": "Add campaign"
});
}

View File

@ -13,5 +13,6 @@
"ModelAdmin.REALLYDELETE": "Do you really want to delete?",
"ModelAdmin.DELETED": "Deleted",
"ModelAdmin.VALIDATIONERROR": "Validation Error",
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left."
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left.",
"Campaigns.ADDCAMPAIGN": "Add campaign"
}

View File

@ -1,15 +0,0 @@
import { Component } from 'react';
export default class SilverStripeComponent extends Component {
constructor(props) {
super(props);
}
componentDidMount() {
}
componentWillUnmount() {
}
};

View File

@ -0,0 +1,37 @@
# Action
This component is used to display a button which is linked to an action.
## Props
### handleClick (function)
The handler for when a button is clicked, is passed the click event as the only argument.
### text (string)
The text to be shown in the button.
### type (string)
The type of button to be shown, adds a class to the button.
Accepted values are:
* 'danger'
* 'success'
* 'primary'
* 'link'
* 'secondary'
* 'complete'
### icon (string)
The icon to be used on the button, adds font-icon-{this.props.icon} class to the button. See available icons [here](../../../../fonts/incon-reference.html).
### loading (boolean)
If true, replaces the text/icon with a loading icon.
### disabled (boolean)
If true, gives the button a visually disabled state and disables click events.

View File

@ -0,0 +1,109 @@
import React from 'react';
import ReactDOM from 'react-dom';
import SilverStripeComponent from '../../SilverStripeComponent';
class ActionComponent extends SilverStripeComponent {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
render() {
return (
<button className={this.getButtonClasses()} onClick={this.handleClick}>
{this.getLoadingIcon()}
{this.props.text}
</button>
);
}
/**
* Returns the necessary button classes based on the given props
*
* @returns string
*/
getButtonClasses() {
var buttonClasses = 'btn';
// If there is no text
if (typeof this.props.text === 'undefined') {
buttonClasses += ' no-text';
}
// Add 'type' class
if (this.props.type === 'danger') {
buttonClasses += ' btn-danger';
} else if (this.props.type === 'success') {
buttonClasses += ' btn-success';
} else if (this.props.type === 'primary') {
buttonClasses += ' btn-primary';
} else if (this.props.type === 'link') {
buttonClasses += ' btn-link';
} else if (this.props.type === 'secondary') {
buttonClasses += ' btn-secondary';
} else if (this.props.type === 'complete') {
buttonClasses += ' btn-success-outline';
}
// Add icon class
if (typeof this.props.icon !== 'undefined') {
buttonClasses += ` font-icon-${this.props.icon}`;
}
// Add loading class
if (this.props.loading === true) {
buttonClasses += ' btn--loading';
}
// Add disabled class
if (this.props.disabled === true) {
buttonClasses += ' disabled';
}
return buttonClasses;
}
/**
* Returns markup for the loading icon
*
* @returns object|null
*/
getLoadingIcon() {
if (this.props.loading) {
return (
<div className="btn__loading-icon" >
<svg viewBox="0 0 44 12">
<circle cx="6" cy="6" r="6" />
<circle cx="22" cy="6" r="6" />
<circle cx="38" cy="6" r="6" />
</svg>
</div>
);
}
return null;
}
/**
* Event handler triggered when a user clicks the button.
*
* @param object event
* @returns null
*/
handleClick(event) {
if (typeof this.props.handleClick === 'function' && this.props.disabled !== true) {
this.props.handleClick(event);
}
return null;
}
}
ActionComponent.propTypes = {
type: React.PropTypes.string,
icon: React.PropTypes.string,
text: React.PropTypes.string
};
export default ActionComponent;

View File

@ -0,0 +1,123 @@
// General buttons
.btn {
height: 32px;
margin-right: 1rem;
position: relative;
}
// Button icons
.btn[class*="font-icon-"]::before {
font-size: 16px;
position: relative;
top: 3px;
margin-right: 6px;
line-height: 13px;
}
.no-text[class*="font-icon-"]::before {
margin-right: 0;
}
.btn-group {
margin-right: 1rem;
.btn {
margin-right: 0;
}
.btn-success {
border-left: 1px solid darken($btn-success-bg, 6%);
&:first-child {
border-left: none;
}
}
}
// SVG loading icon
.btn__loading-icon {
float: left;
margin: 0 4px 0 0;
height: 20px;
position: absolute;
left: 50%;
top: $btn-padding-y;
transform: translate(-50%);
svg {
width: 24px;
height: 20px;
circle {
width: 4px;
height: 5px;
animation: loading-icon 1.2s infinite ease-in-out both;
fill: $gray;
transform-origin: 50% 50%;
}
circle:nth-child(1) {
animation-delay: -.32s;
}
circle:nth-child(2) {
animation-delay: -.16s;
}
}
}
.btn--loading {
> span,
&::before {
visibility: hidden;
}
}
@keyframes loading-icon {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
// Specific button types
.btn-link {
&:hover,
&:focus {
text-decoration: none;
}
}
.btn-secondary {
border-color: transparent;
&:hover,
&:active,
&:focus {
background-color: $gray-lighter;
}
}
.btn-success-outline {
border-color: lighten($brand-success,10%);
&:hover,
&:active,
&:focus {
color: $brand-success;
background-image: none;
background-color: transparent;
border-color: lighten($brand-success,10%);
}
svg circle {
fill: $brand-success;
}
}
.btn-success {
border-bottom-color: $btn-success-shadow;
svg circle {
fill: #fff;
}
}

View File

@ -0,0 +1,17 @@
# FormActionComponent
Used for form actions. For example a submit button.
## Props
### className
CSS class names to use on the button. Defaults to `btn btn-primary`
### label (required)
The text to display on the button.
### type
Used for the button's `type` attribute. Defaults to `button`

View File

@ -0,0 +1,27 @@
import React from 'react';
import SilverStripeComponent from '../../SilverStripeComponent';
class FormActionComponent extends SilverStripeComponent {
render() {
return (
<button type={this.props.type} className={this.props.className}>
{this.props.label}
</button>
);
}
}
FormActionComponent.propTypes = {
className: React.PropTypes.string,
label: React.PropTypes.string.isRequired,
type: React.PropTypes.string
};
FormActionComponent.defaultProps = {
className: 'btn btn-primary',
type: 'button'
};
export default FormActionComponent;

View File

@ -0,0 +1,23 @@
# FormBuilderComponent
Used to generate forms, made up of field components and actions, from FormFieldSchema data.
This component will be moved to Framweork or CMS when dependency injection is implemented.
## PropTypes
### actions
Actions the component can dispatch. This should include but is not limited to:
#### setSchema
An action to call when the response from fetching schema data is returned. This would normally be a simple action to set the store's `schema` key to the returned data.
### formSchemaUrl
The schema URL where the form will be scaffolded from e.g. '/admin/pages/schema/1'.
### schema
JSON schema representing the form. Used as the blueprint for generating the form.

View File

@ -0,0 +1,230 @@
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import $ from 'jQuery';
import * as schemaActions from '../../state/schema/actions';
import SilverStripeComponent from '../../SilverStripeComponent';
import FormComponent from '../form';
import TextField from '../text-field';
// Using this to map field types to components until we implement dependency injection.
var fakeInjector = {
/**
* Components registered with the fake DI container.
*/
components: {
'TextField': TextField
},
/**
* Gets the component matching the passed component name.
* Used when a component type is provided bt the form schema.
*
* @param string componentName - The name of the component to get from the injector.
*
* @return object|null
*/
getComponentByName: function (componentName) {
return this.components[componentName];
},
/**
* Default data type to component mappings.
* Used as a fallback when no component type is provided in the form schema.
*
* @param string dataType - The data type provided by the form schema.
*
* @return object|null
*/
getComponentByDataType: function (dataType) {
switch (dataType) {
case 'String':
return this.components.TextField;
case 'Hidden':
return this.components.TextField;
case 'Text':
// Textarea field (not implemented)
return null;
case 'HTML':
// HTML editor field (not implemented)
return null;
case 'Integer':
// Numeric field (not implemented)
return null;
case 'Decimal':
// Numeric field (not implemented)
return null;
case 'MultiSelect':
// Radio field (not implemented)
return null;
case 'SingleSelect':
// Dropdown field (not implemented)
return null;
case 'Date':
// DateTime field (not implemented)
return null;
case 'DateTime':
// DateTime field (not implemented)
return null;
case 'Time':
// DateTime field (not implemented)
return null;
case 'Boolean':
// Checkbox field (not implemented)
return null;
default:
return null;
}
}
}
export class FormBuilderComponent extends SilverStripeComponent {
constructor(props) {
super(props);
this.formSchemaPromise = null;
this.isFetching = false;
this.fetch();
}
/**
* Fetches data used to generate a form. This can be form schema and or form state data.
* When the response comes back the data is saved to state.
*
* @param boolean schema - If form schema data should be returned in the response.
* @param boolean state - If form state data should be returned in the response.
*
* @return object - Promise from the AJAX request.
*/
fetch(schema = true, state = false) {
var headerValues = [];
if (this.isFetching === true) {
return this.formSchemaPromise;
}
if (schema === true) {
headerValues.push('schema');
}
if (state === true) {
headerValues.push('state');
}
this.formSchemaPromise = $.ajax({
method: 'GET',
headers: { 'X-FormSchema-Request': headerValues.join() },
url: this.props.formSchemaUrl
}).done((data, status, xhr) => {
this.isFetching = false;
this.props.actions.setSchema(data);
});
this.isFetching = true;
return this.formSchemaPromise;
}
/**
* Gets form schema for the FormBuilder.
*
* @return object|undefined
*/
getFormSchema() {
return this.props.schema.forms.find(function (form) {
return form.schema.schema_url === this.props.formSchemaUrl;
}.bind(this));
}
/**
* Maps a list of schema fields to their React Component.
* Only top level form fields are handled here, composite fields (TabSets etc),
* are responsible for mapping and rendering their children.
*
* @param array fields
*
* @return array
*/
mapFieldsToComponents(fields) {
return fields.map((field, i) => {
const Component = field.component !== null
? fakeInjector.getComponentByName(field.component)
: fakeInjector.getComponentByDataType(field.type);
if (Component === null) {
return null;
}
// Props which every form field receives.
let props = {
attributes: field.attributes,
data: field.data,
description: field.description,
extraClass: field.extraClass,
fields: field.children,
id: field.id,
name: field.name
};
// Structural fields (like TabSets) are not posted back to the server and don't receive some props.
if (field.type !== 'Structural') {
props.rightTitle = field.rightTitle;
props.leftTitle = field.leftTitle;
props.readOnly = field.readOnly;
props.disabled = field.disabled;
props.customValidationMessage = field.customValidationMessage;
}
// Dropdown and Radio fields need some options...
if (field.type === 'MultiSelect' || field.type === 'SingleSelect') {
props.source = field.source;
}
return <Component key={i} {...props} />
});
}
render() {
// If the response from fetching the initial data
// hasn't come back yet, don't render anything.
if (this.props.schema.forms.length === 0) {
return null;
}
const schema = this.getFormSchema().schema;
const formProps = {
actions: schema.actions,
attributes: schema.attributes,
data: schema.data,
fields: schema.fields,
mapFieldsToComponents: this.mapFieldsToComponents
};
return <FormComponent {...formProps} />
}
}
FormBuilderComponent.propTypes = {
actions: React.PropTypes.object.isRequired,
formSchemaUrl: React.PropTypes.string.isRequired,
schema: React.PropTypes.object.isRequired
};
function mapStateToProps(state) {
return {
schema: state.schema
}
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(schemaActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(FormBuilderComponent);

View File

@ -0,0 +1,31 @@
jest.unmock('../../../SilverStripeComponent');
jest.unmock('../');
import { FormBuilderComponent } from '../';
describe('FormBuilderComponent', () => {
describe('getFormSchema()', () => {
var formBuilder;
beforeEach(() => {
const props = {
store: {
getState: () => {}
},
actions: {},
formSchemaUrl: 'admin/assets/schema/1',
schema: { forms: [{ schema: { id: '1', schema_url: 'admin/assets/schema/1' } }] }
};
formBuilder = new FormBuilderComponent(props);
});
it('should return the form schema for the FormBuilder', () => {
const form = formBuilder.getFormSchema();
expect(form.schema.id).toBe('1');
});
});
});

View File

@ -0,0 +1,37 @@
# FormComponent
The FormComponent is used to render forms in SilverStripe. The only time you should need to use `FormComponent` directly is when you're composing custom layouts. Forms can be automatically generated from a schema using the `FormBuilder` component.
This component should be moved to Framework when dependency injection is implemented.
## Props
### actions (required)
A list of objects representing the form actions. For example the submit button.
### attributes (required)
An object of HTML attributes for the form. For example:
```js
{
action: 'admin/assets/EditForm',
class: 'cms-edit-form root-form AssetAdmin LeftAndMain',
enctype: 'multipart/form-data',
id: 'Form_EditForm',
method: 'POST'
}
```
### data
Ad hoc data passed to the front-end from the server.
### fields (required)
A list of field objects to display in the form. These objects should be transformed to Components using the `this.props.mapFieldsToComponents` method.
### mapFieldsToComponents (required)
A function that maps each schema field (`this.props.fields`) to the component responsibe for render it.

View File

@ -0,0 +1,59 @@
import React from 'react';
import SilverStripeComponent from '../../SilverStripeComponent';
import FormActionComponent from '../form-action';
class FormComponent extends SilverStripeComponent {
/**
* Gets the components responsible for perfoming actions on the form.
* For example form submission.
*
* @return array|null
*/
getFormActionComponents() {
return this.props.actions.map((action) => {
return <FormActionComponent {...action} />;
});
}
render() {
const attr = this.props.attributes;
const fields = this.props.mapFieldsToComponents(this.props.fields);
const actions = this.getFormActionComponents();
return (
<form id={attr.id} className={attr.className} encType={attr.enctype} method={attr.method} action={attr.action}>
{fields &&
<fieldset className='form-group'>
{fields}
</fieldset>
}
{actions &&
<div className='actions-fix-btm'>
<div className='btn-group' role='group'>
{actions}
</div>
</div>
}
</form>
);
}
}
FormComponent.propTypes = {
actions: React.PropTypes.array.isRequired,
attributes: React.PropTypes.shape({
action: React.PropTypes.string.isRequired,
className: React.PropTypes.string.isRequired,
enctype: React.PropTypes.string.isRequired,
id: React.PropTypes.string.isRequired,
method: React.PropTypes.string.isRequired
}),
data: React.PropTypes.array,
fields: React.PropTypes.array.isRequired,
mapFieldsToComponents: React.PropTypes.func.isRequired
};
export default FormComponent;

View File

@ -1,5 +1,5 @@
import React from 'react';
import SilverStripeComponent from 'silverstripe-component';
import SilverStripeComponent from '../../SilverStripeComponent';
class GridFieldCellComponent extends SilverStripeComponent {

View File

@ -1,5 +1,5 @@
import React from 'react';
import SilverStripeComponent from 'silverstripe-component';
import SilverStripeComponent from '../../SilverStripeComponent';
class GridFieldHeaderCellComponent extends SilverStripeComponent {

View File

@ -1,5 +1,5 @@
import React from 'react';
import SilverStripeComponent from 'silverstripe-component';
import SilverStripeComponent from '../../SilverStripeComponent';
import GridFieldRowComponent from '../grid-field-row';
class GridFieldHeaderComponent extends SilverStripeComponent {

View File

@ -1,5 +1,5 @@
import React from 'react';
import SilverStripeComponent from 'silverstripe-component';
import SilverStripeComponent from '../../SilverStripeComponent';
class GridFieldRowComponent extends SilverStripeComponent {

View File

@ -1,5 +1,5 @@
import React from 'react';
import SilverStripeComponent from 'silverstripe-component';
import SilverStripeComponent from '../../SilverStripeComponent';
class GridFieldTableComponent extends SilverStripeComponent {

View File

@ -0,0 +1,48 @@
import React from 'react';
import SilverStripeComponent from '../../SilverStripeComponent';
class HiddenFieldComponent extends SilverStripeComponent {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
render() {
return (
<div className='field hidden'>
<input {...this.getInputProps()} />
</div>
);
}
getInputProps() {
return {
className: ['hidden', this.props.extraClass].join(' '),
id: this.props.name,
name: this.props.name,
onChange: this.props.onChange,
type: 'hidden',
value: this.props.value
};
}
handleChange(event) {
if (typeof this.props.onChange === 'undefined') {
return;
}
this.props.onChange();
}
}
HiddenFieldComponent.propTypes = {
label: React.PropTypes.string,
extraClass: React.PropTypes.string,
name: React.PropTypes.string.isRequired,
onChange: React.PropTypes.func,
value: React.PropTypes.string
};
export default HiddenFieldComponent;

View File

@ -0,0 +1,21 @@
# Hidden Field Component
Generates an `<input type="hidden" />`
## Props
### extraClass
Addition CSS classes to apply to the `<input>` element.
### name (required)
Used for the field's `name` attribute.
### onChange
Handler function called when the field's value changes.
### value
The field's value.

View File

@ -0,0 +1,3 @@
.field.hidden {
display: none;
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import SilverStripeComponent from 'silverstripe-component';
import SilverStripeComponent from '../../SilverStripeComponent';
class NorthHeaderBreadcrumbsComponent extends SilverStripeComponent {

View File

@ -1,6 +1,6 @@
import React from 'react';
import NorthHeaderBreadcrumbsComponent from '../north-header-breadcrumbs';
import SilverStripeComponent from 'silverstripe-component';
import SilverStripeComponent from '../../SilverStripeComponent';
class NorthHeaderComponent extends SilverStripeComponent {

View File

@ -0,0 +1,55 @@
import React from 'react';
import SilverStripeComponent from '../../SilverStripeComponent';
class TextFieldComponent extends SilverStripeComponent {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
render() {
return (
<div className='field text'>
{this.props.label &&
<label className='left' htmlFor={'gallery_' + this.props.name}>
{this.props.label}
</label>
}
<div className='middleColumn'>
<input {...this.getInputProps()} />
</div>
</div>
);
}
getInputProps() {
return {
className: ['text', this.props.extraClass].join(' '),
id: `gallery_${this.props.name}`,
name: this.props.name,
onChange: this.props.onChange,
type: 'text',
value: this.props.value
};
}
handleChange(event) {
if (typeof this.props.onChange === 'undefined') {
return;
}
this.props.onChange();
}
}
TextFieldComponent.propTypes = {
label: React.PropTypes.string,
extraClass: React.PropTypes.string,
name: React.PropTypes.string.isRequired,
onChange: React.PropTypes.func,
value: React.PropTypes.string
};
export default TextFieldComponent;

View File

@ -0,0 +1,25 @@
# Text Field Component
Generates an editable text field.
## Props
### label
The label text to display with the field.
### extraClass
Addition CSS classes to apply to the `<input>` element.
### name (required)
Used for the field's `name` attribute.
### onChange
Handler function called when the field's value changes.
### value
The field's value.

View File

@ -0,0 +1,37 @@
jest.unmock('react');
jest.unmock('react-addons-test-utils');
jest.unmock('../');
import React from 'react';
import ReactTestUtils from 'react-addons-test-utils';
import TextFieldComponent from '../';
describe('TextFieldComponent', function() {
var props;
beforeEach(function () {
props = {
label: '',
name: '',
value: '',
onChange: jest.genMockFunction()
};
});
describe('handleChange()', function () {
var textField;
beforeEach(function () {
textField = ReactTestUtils.renderIntoDocument(
<TextFieldComponent {...props} />
);
});
it('should call the onChange function on props', function () {
textField.handleChange();
expect(textField.props.onChange.mock.calls.length).toBe(1);
});
});
});

View File

@ -1,20 +1,39 @@
import React from 'react';
import { connect } from 'react-redux';
import SilverStripeComponent from 'silverstripe-component';
import NorthHeader from '../../components/north-header';
import GridField from '../grid-field';
import ActionButton from 'action-button';
import i18n from 'i18n';
import NorthHeader from 'north-header';
import GridField from 'grid-field';
class CampaignAdminContainer extends SilverStripeComponent {
constructor(props) {
super(props);
this.addCampaign = this.addCampaign.bind(this);
}
render() {
return (
<div>
<NorthHeader></NorthHeader>
<GridField></GridField>
<NorthHeader />
<ActionButton
text={i18n._t('Campaigns.ADDCAMPAIGN')}
type={'secondary'}
icon={'plus-circled'}
handleClick={this.addCampaign} />
<GridField />
</div>
);
}
addCampaign() {
//Add campaign
}
}
CampaignAdminContainer.propTypes = {

View File

@ -1,3 +0,0 @@
.CampaignAdmin {
}

View File

@ -1,5 +1,5 @@
import React from 'react';
import SilverStripeComponent from 'silverstripe-component';
import SilverStripeComponent from '../../SilverStripeComponent';
import GridFieldTable from '../../components/grid-field-table';
import GridFieldHeader from '../../components/grid-field-header';
import GridFieldHeaderCell from '../../components/grid-field-header-cell';

View File

@ -0,0 +1,5 @@
# Schema state
Manages state associated with the FormFieldSchema.
When dependency injection is implemented, this will be moved into either Framework or CMS. We can't moveit there sooner because there is no way of extending state.

View File

@ -0,0 +1,5 @@
const ACTION_TYPES = {
SET_SCHEMA: 'SET_SCHEMA'
};
export default ACTION_TYPES;

View File

@ -0,0 +1,15 @@
import ACTION_TYPES from './action-types';
/**
* Sets the schema being used to generate the curent layout.
*
* @param string schema - JSON schema for the layout.
*/
export function setSchema(schema) {
return (dispatch, getState) => {
return dispatch ({
type: ACTION_TYPES.SET_SCHEMA,
payload: schema
});
}
}

View File

@ -0,0 +1,33 @@
import deepFreeze from 'deep-freeze';
import ACTION_TYPES from './action-types';
const initialState = deepFreeze({
forms: []
});
export default function schemaReducer(state = initialState, action = null) {
switch (action.type) {
case ACTION_TYPES.SET_SCHEMA:
if (state.forms.length === 0) {
return deepFreeze(Object.assign({}, state, { forms: [action.payload] }));
}
// Replace the form which has a matching `schema.id` property.
return deepFreeze(Object.assign({}, state, {
forms: state.forms.map((form) => {
if (form.schema.id === action.payload.schema.id) {
// Only replace the `schema` key incase other actions have updated other keys.
return Object.assign({}, form, action.payload);
}
return form;
})
}));
default:
return state;
}
}

View File

@ -0,0 +1,72 @@
jest.unmock('deep-freeze');
jest.unmock('../action-types.js');
jest.unmock('../reducer.js');
import schemaReducer from '../reducer.js';
import ACTION_TYPES from '../action-types';
describe('schemaReducer', () => {
describe('SET_SCHEMA', () => {
it('should create a new form when none exist', () => {
const initialState = { forms: [] };
const serverResponse = { id : 'TestForm' };
const nextState = schemaReducer(initialState, {
type: ACTION_TYPES.SET_SCHEMA,
payload: { schema: serverResponse }
});
expect(nextState.forms.length).toBe(1);
expect(JSON.stringify(nextState.forms[0])).toBe(JSON.stringify({ schema: { id: 'TestForm' } }));
});
it('should update an existing form', () => {
const initialState = {
forms: [{
schema: {
id: 'TestForm',
name: 'TestForm'
}
}]
};
const serverResponse = { id: 'TestForm', name: 'BetterTestForm' };
const nextState = schemaReducer(initialState, {
type: ACTION_TYPES.SET_SCHEMA,
payload: { schema: serverResponse }
});
expect(nextState.forms.length).toBe(1);
expect(JSON.stringify(nextState.forms[0])).toBe(JSON.stringify({ schema: { id: 'TestForm', name: 'BetterTestForm' } }));
});
it("should only update the the form's 'schema' key", () => {
const initialState = {
forms: [{
schema: {
id: 'TestForm',
name: 'TestForm'
},
state: {
error: 'Oops!'
}
}]
};
const serverResponse = { id: 'TestForm', name: 'BetterTestForm' };
const nextState = schemaReducer(initialState, {
type: ACTION_TYPES.SET_SCHEMA,
payload: { schema: serverResponse }
});
expect(nextState.forms.length).toBe(1);
expect(JSON.stringify(nextState.forms[0])).toBe(JSON.stringify({ schema: { id: 'TestForm', name: 'BetterTestForm' }, state: { error: 'Oops!' } }));
});
});
});

View File

@ -13,3 +13,5 @@
@import "../components/grid-field-row/styles";
@import "../components/north-header/styles";
@import "../components/north-header-breadcrumbs/styles";
@import "../components/action/styles";
@import "../components/hidden-field/styles";

View File

@ -326,7 +326,7 @@ form.small .field, .field.small {
.chzn-container-single .chzn-single {
height: 32px;
line-height: 30px; /* not relative, as then we'd had to redo most of chzn */
font-size: $font-base-size;
font-size: $font-size-root;
background-image: linear-gradient(#efefef, #fff 10%, #fff 90%, #efefef);
&:hover, &:focus, &:active {
@ -524,7 +524,7 @@ form.small .field, .field.small {
&.ss-ui-button-small {
.ui-button-text {
font-size: $font-base-size - 2;
font-size: $font-size-sm;
}
}

View File

@ -33,7 +33,6 @@
padding: $grid-y*1.5 8px;
position: relative;
vertical-align: middle;
font-size: $font-base-size;
transition: padding .2s;
min-height: 52px;
transition: padding .2s;
@ -55,7 +54,6 @@
span {
font-weight: bold;
font-size: $font-base-size;
line-height: 16px;
padding: 6px 0;
margin-left: 32px;
@ -65,7 +63,7 @@
.cms-login-status {
padding: $grid-y*1.5 8px;
line-height: 16px;
font-size: $font-base-size - 1;
font-size: $font-size-sm;
min-height: 28px;
transition: padding .2s;
@ -278,7 +276,6 @@
display: block;
line-height: $grid-y * 2;
min-height: 50px;
font-size: $font-base-size;
color: $color-text-default;
padding: (2 * $grid-y + 1) 5px (2 * $grid-y + 1) 8px;
background-color: $base-menu-bg;

View File

@ -127,11 +127,11 @@ Used in side panels and action tabs
line-height: $grid-y * 2;
}
h3 {
font-size: $font-base-size + 1;
font-size: $font-size-root;
}
h4 {
font-size: $font-base-size;
font-size: $font-size-root -1;
margin: 5px 0;
}

View File

@ -69,8 +69,6 @@
// Reset the font and vertical alignment.
@mixin reset-font {
font: inherit;
font-size: 100%;
vertical-align: baseline; }
// Resets the outline when focus.

View File

@ -37,11 +37,6 @@ body.cms {
}
}
body .ui-widget {
font-family: $font-family;
font-size: $font-base-size;
}
strong {
font-weight: bold;
}
@ -111,7 +106,7 @@ body.cms {
}
h2 {
font-size: $font-base-size + 2;
font-size: $font-size-h4;
font-weight: bold;
margin: 0;
margin-bottom: $grid-x;
@ -1109,11 +1104,11 @@ body.cms {
line-height: $grid-y * 2;
}
h3 {
font-size: $font-base-size + 1;
font-size: $font-size-h5;
}
h4 {
font-size: $font-base-size;
font-size: $font-size-h5;
margin:5px 0;
}
@ -1131,7 +1126,7 @@ body.cms {
label {
float: none;
width: auto;
font-size: $font-base-size;
font-size: $font-size-root;
padding: 0 $grid-x 4px 0;
}
@ -1441,7 +1436,7 @@ form.member-profile-form {
font-style: normal;
}
.toggle {
font-size: $font-base-size - 1;
font-size: $font-size-sm;
}
}
@ -1669,7 +1664,7 @@ form.member-profile-form {
// Titlebar for pop-up dialog.
.ui-dialog-titlebar.ui-widget-header {
font-size: $font-base-size+2;
font-size: $font-size-root +1;
padding: 0;
border:none;
background: transparent url(../images/textures/cms_content_header.png) repeat;
@ -2019,7 +2014,7 @@ body.cms-dialog {
.flyout {
height: 26px - 2*4px; // minus padding
font-size: $font-base-size+2;
font-size: $font-size-root +1;
font-weight: bold;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;

View File

@ -4,7 +4,6 @@
* Contains the basic typography related styles for the admin interface.
*/
body, html {
font-size: $font-base-size;
line-height: $grid-y * 2;
font-family: $font-family;
color: $color-text;
@ -18,22 +17,9 @@ body, html {
}
h2 {
font-size: $font-base-size + 6;
line-height: $grid-y * 3;
}
h3 {
font-size: $font-base-size + 4;
}
h4 {
font-size: $font-base-size + 2;
}
h5 {
font-size: $font-base-size;
}
p {
line-height: $grid-y * 2;
margin-bottom: $grid-y * 2;

View File

@ -12,7 +12,7 @@
.ui-widget-content,
.ui-widget {
color: $color-text;
font-size: $font-base-size;
font-size: 1em;
font-family: $font-family;
border: 0;
}
@ -32,8 +32,6 @@
text-shadow: lighten($color-base, 10%) 1px 1px 0;
}
& a.ui-dialog-titlebar-close {
position: absolute;
top: -5px;
@ -65,15 +63,6 @@
cursor: pointer;
}
.ui-widget input,
.ui-widget select,
.ui-widget textarea,
.ui-widget button {
color: $color-text;
font-size: $font-base-size;
font-family: $font-family;
}
.ui-accordion {
.ui-accordion-header {
border-color: $color-button-generic-border;

View File

@ -26,17 +26,17 @@
//
// Grayscale and brand colors for use across Bootstrap.
// $gray-dark: #373a3c;
// $gray: #55595c;
// $gray-light: #818a91;
$gray-dark: #4f5861;
$gray: #55595c;
$gray-light: #d3d9dd;
$gray-lighter: #e8e9ea;
// $gray-lightest: #f7f7f9;
//
// $brand-primary: #0275d8;
// $brand-success: #5cb85c;
$brand-success: #3fa142;
// $brand-info: #5bc0de;
// $brand-warning: #f0ad4e;
// $brand-danger: #d9534f;
$brand-danger: #D40404;
// Options
@ -142,26 +142,26 @@ $gray-lighter: #e8e9ea;
//
// Font, line-height, and color for body text, headings, and more.
// $font-family-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
// $font-family-serif: Georgia, "Times New Roman", Times, serif;
// $font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
// $font-family-base: $font-family-sans-serif;
$font-family-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
$font-family-serif: Georgia, "Times New Roman", Times, serif;
$font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
$font-family-base: $font-family-sans-serif;
// Pixel value used to responsively scale all typography. Applied to the `<html>` element.
// $font-size-root: 16px;
//
// $font-size-base: 1rem;
// $font-size-lg: 1.25rem;
// $font-size-sm: .875rem;
// $font-size-xs: .75rem;
//
// $font-size-h1: 2.5rem;
// $font-size-h2: 2rem;
// $font-size-h3: 1.75rem;
// $font-size-h4: 1.5rem;
// $font-size-h5: 1.25rem;
// $font-size-h6: 1rem;
//
$font-size-root: 13px;
$font-size-base: 1rem;
$font-size-lg: 1.23rem; /* 16px */
$font-size-sm: .846rem; /* 11px */
$font-size-xs: .769rem; /* 10px */
$font-size-h1: 2.5rem;
$font-size-h2: 18px; /* 2rem; */
$font-size-h3: 16px; /* 1.75rem; */
$font-size-h4: 14px; /* 1.5rem; */
$font-size-h5: 13px; /* 1.25rem; */
$font-size-h6: 1rem;
// $display1-size: 6rem;
// $display2-size: 5.5rem;
// $display3-size: 4.5rem;
@ -171,9 +171,9 @@ $gray-lighter: #e8e9ea;
// $display2-weight: 300;
// $display3-weight: 300;
// $display4-weight: 300;
//
// $line-height: 1.5;
//
$line-height: 1.538;
// $headings-margin-bottom: ($spacer / 2);
// $headings-font-family: inherit;
// $headings-font-weight: 500;
@ -239,34 +239,40 @@ $gray-lighter: #e8e9ea;
//
// For each of Bootstrap's buttons, define text, background and border color.
// $btn-padding-x: 1rem;
// $btn-padding-y: .375rem;
// $btn-font-weight: normal;
//
// $btn-primary-color: #fff;
$btn-padding-x: 1.1rem;
$btn-padding-y: .3846rem;
$btn-font-weight: normal;
$btn-primary-color: #fff;
// $btn-primary-bg: $brand-primary;
// $btn-primary-border: $btn-primary-bg;
//
// $btn-secondary-color: $gray-dark;
// $btn-secondary-bg: #fff;
// $btn-secondary-border: #ccc;
//
$btn-secondary-color: $gray-dark;
$btn-secondary-bg: transparent;
$btn-secondary-border: transparent;
// $btn-info-color: #fff;
// $btn-info-bg: $brand-info;
// $btn-info-border: $btn-info-bg;
//
// $btn-success-color: #fff;
// $btn-success-bg: $brand-success;
// $btn-success-border: $btn-success-bg;
//
$btn-success-color: #fff;
$btn-success-bg: $brand-success;
$btn-success-border: $btn-success-bg;
$btn-success-shadow: darken($btn-success-bg, 6%);
$btn-complete-color: #555;
$btn-complete-bg: $brand-success;
$btn-complete-border: $gray-light;
$btn-complete-shadow: darken($btn-success-bg, 6%);
// $btn-warning-color: #fff;
// $btn-warning-bg: $brand-warning;
// $btn-warning-border: $btn-warning-bg;
//
// $btn-danger-color: #fff;
// $btn-danger-bg: $brand-danger;
// $btn-danger-border: $btn-danger-bg;
//
$btn-danger-color: $brand-danger;
$btn-danger-bg: transparent;
$btn-danger-border: transparent;
// $btn-link-disabled-color: $gray-light;
//
// $btn-padding-x-sm: .75rem;

View File

@ -98,7 +98,6 @@ $tab-panel-texture-background: $tab-panel-texture-color url(../images/textures/b
* Typography.
* ------------------------------------------------ */
$font-family: Arial, sans-serif !default;
$font-base-size: 12px !default;
/** -----------------------------------------------
* Grid Units (px)

View File

@ -213,10 +213,23 @@ gulp.task('bundle-lib', function bundleLib() {
comments: false
}))
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/config.js', { expose: 'config' })
.require(PATHS.FRAMEWORK_JAVASCRIPT_SRC + '/jQuery.js', { expose: 'jQuery' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/action', { expose: 'action-button' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/form', { expose: 'form' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/form-action', { expose: 'form-action' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/form-builder', { expose: 'form-builder' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/sections/grid-field', { expose: 'grid-field' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/grid-field-cell', { expose: 'grid-field-cell' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/grid-field-header', { expose: 'grid-field-header' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/grid-field-header-cell', { expose: 'grid-field-header-cell' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/grid-field-row', { expose: 'grid-field-row' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/grid-field-table', { expose: 'grid-field-table' })
.require(PATHS.FRAMEWORK_JAVASCRIPT_SRC + '/i18n.js', { expose: 'i18n' })
.require(PATHS.FRAMEWORK_JAVASCRIPT_SRC + '/router.js', { expose: 'router' })
.require(PATHS.FRAMEWORK_JAVASCRIPT_SRC + '/jQuery.js', { expose: 'jQuery' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/north-header', { expose: 'north-header' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/north-header-breadcrumbs', { expose: 'north-header-breadcrumbs' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/reducer-register.js', { expose: 'reducer-register' })
.require(PATHS.FRAMEWORK_JAVASCRIPT_SRC + '/router.js', { expose: 'router' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/components/text-field', { expose: 'text-field' })
.bundle()
.on('update', bundleLib)
.on('error', notify.onError({ message: bundleFileName + ': <%= error.message %>' }))
@ -228,14 +241,20 @@ gulp.task('bundle-lib', function bundleLib() {
gulp.task('bundle-react', function bundleReact() {
return browserify(Object.assign({}, browserifyOptions))
.require('react-addons-test-utils', { expose: 'react-addons-test-utils' })
.transform(babelify.configure({
presets: ['es2015'],
ignore: /(node_modules)/
}))
.require('deep-freeze', { expose: 'deep-freeze' })
.require('react', { expose: 'react' })
.require('react-addons-css-transition-group', { expose: 'react-addons-css-transition-group' })
.require('react-addons-test-utils', { expose: 'react-addons-test-utils' })
.require('react-dom', { expose: 'react-dom' })
.require('redux', { expose: 'redux' })
.require('react-redux', { expose: 'react-redux' })
.require('redux', { expose: 'redux' })
.require('redux-thunk', { expose: 'redux-thunk' })
.require('isomorphic-fetch', { expose: 'isomorphic-fetch' })
.require(PATHS.ADMIN_JAVASCRIPT_DIST + '/SilverStripeComponent', { expose: 'silverstripe-component' })
.require(PATHS.ADMIN_JAVASCRIPT_SRC + '/SilverStripeComponent', { expose: 'silverstripe-component' })
.bundle()
.on('update', bundleReact)
.on('error', notify.onError({ message: bundleFileName + ': <%= error.message %>' }))
@ -271,18 +290,21 @@ gulp.task('bundle-campaign-admin', function bundleCampaignAdmin() {
presets: ['es2015', 'react'],
ignore: /(node_modules)/
}))
.external('action-button')
.external('deep-freeze')
.external('grid-field')
.external('i18n')
.external('jQuery')
.external('north-header')
.external('page.js')
.external('react')
.external('react-addons-test-utils')
.external('react-dom')
.external('react-redux')
.external('reducer-register')
.external('redux')
.external('redux-thunk')
.external('silverstripe-component')
.external('reducer-register')
.bundle()
.on('error', notify.onError({ message: bundleFileName + ': <%= error.message %>' }))
.pipe(source(bundleFileName))

View File

@ -40,7 +40,6 @@
"blueimp-tmpl": "^1.0.2",
"bootstrap": "^4.0.0-alpha.2",
"deep-freeze": "0.0.1",
"isomorphic-fetch": "^2.2.1",
"jquery-sizes": "^0.33.0",
"npm-shrinkwrap": "^5.4.1",
"page.js": "^4.13.3",

View File

@ -114,7 +114,6 @@ $gf_grid_x: 16px;
}
.grid-csv-button, .grid-print-button {
margin-bottom: 0;
font-size: $font-base-size;
display: inline-block;
}
}