Tabs support in new file/image editor

Introducing <Tabs> component based on react-bootstrap
Better support for nested fields in FormBuilder
Tweaks to get FormBuilder working with frameworktest BasicFieldsPage fields
Added exception in FormBuilder when Component is defined but not found
Added check in SingleSelectField for empty value before adding one in
Added temporary workaround for CompositeFields with no name (another story to address the actual problem)
Added asset_preview_height for File image preview, matches the defined CSS max-height
Added documentation to DBFile::PreviewLink() method
This commit is contained in:
Christopher Joe 2016-09-07 15:35:47 +12:00 committed by Ingo Schommer
parent 955d75b219
commit ee5b4fd8d3
33 changed files with 715 additions and 86 deletions

View File

@ -15,7 +15,8 @@ use SilverStripe\Forms\DatetimeField;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\HeaderField;
use SilverStripe\Forms\HiddenField;
use SilverStripe\Forms\HTMLReadonlyField;
use SilverStripe\Forms\Tab;
use SilverStripe\Forms\TabSet;
use SilverStripe\Forms\LiteralField;
use SilverStripe\Forms\ReadonlyField;
use SilverStripe\Forms\TextField;
@ -472,36 +473,36 @@ class File extends DataObject implements ShortcodeHandler, AssetContainer, Thumb
public function getCMSFields() {
$path = '/' . dirname($this->getFilename());
$fields = FieldList::create([
HeaderField::create('TitleHeader', $this->Title, 1),
LiteralField::create("ImageFull", $this->PreviewThumbnail()),
TextField::create("Name", $this->fieldLabel('Filename')),
ReadonlyField::create(
"Path",
_t('AssetTableField.PATH', 'Path'),
(($path !== '/.') ? $path : '') . '/'
)
]);
$width = (int)Image::config()->get('asset_preview_width');
$previewLink = Convert::raw2att($this->ScaleMaxWidth($width)->getIcon());
$image = "<img src=\"{$previewLink}\" class=\"editor__thumbnail\" />";
if ($this->getIsImage()) {
$fields->push(ReadonlyField::create(
"DisplaySize",
_t('AssetTableField.SIZE', "File size"),
sprintf('%spx, %s', $this->getDimensions(), $this->getSize())
));
$fields->push(HTMLReadonlyField::create(
'ClickableURL',
_t('AssetTableField.URL','URL'),
sprintf('<a href="%s" target="_blank">%s</a>', $this->Link(), $this->Link())
));
}
$fields->push(HiddenField::create('ID', $this->ID));
$content = Tab::create('Main',
HeaderField::create('TitleHeader', $this->Title, 1)
->addExtraClass('editor__heading'),
LiteralField::create("IconFull", $image)
->addExtraClass('editor__file-preview'),
TabSet::create('Editor',
Tab::create('Details',
TextField::create("Title", $this->fieldLabel('Title')),
TextField::create("Name", $this->fieldLabel('Filename')),
ReadonlyField::create(
"Path",
_t('AssetTableField.PATH', 'Path'),
(($path !== '/.') ? $path : '') . '/'
)
),
Tab::create('Usage',
DatetimeField::create(
"LastEdited",
_t('AssetTableField.LASTEDIT', 'Last changed')
)->setReadonly(true)
)
),
HiddenField::create('ID', $this->ID)
);
$fields->insertBefore('Name', TextField::create("Title", $this->fieldLabel('Title')));
$fields->push(DatetimeField::create(
"LastEdited",
_t('AssetTableField.LASTEDIT', 'Last changed')
)->setReadonly(true));
$fields = FieldList::create(TabSet::create('Root', $content));
$this->extend('updateCMSFields', $fields);

View File

@ -11,6 +11,8 @@ use SilverStripe\Forms\TextField;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\ORM\Versioning\Versioned;
use SilverStripe\Forms\Tab;
use SilverStripe\Forms\TabSet;
/**
* Represents a logical folder, which may be used to organise assets
@ -194,12 +196,24 @@ class Folder extends File {
// Don't show readonly path until we can implement parent folder selection,
// it's too confusing when readonly (makes sense for files only).
$fields = FieldList::create([
HeaderField::create('TitleHeader', $this->Title, 1),
LiteralField::create("ImageFull", $this->PreviewThumbnail()),
TextField::create("Name", $this->fieldLabel('Filename')),
$width = (int)Image::config()->get('asset_preview_width');
$previewLink = Convert::raw2att($this->ScaleMaxWidth($width)->getIcon());
$image = "<img src=\"{$previewLink}\" class=\"editor__thumbnail\" />";
$content = Tab::create('Main',
HeaderField::create('TitleHeader', $this->Title, 1)
->addExtraClass('editor__heading'),
LiteralField::create("IconFull", $image)
->addExtraClass('editor__file-preview'),
TabSet::create('Editor',
Tab::create('Details',
TextField::create("Name", $this->fieldLabel('Filename'))
)
),
HiddenField::create('ID', $this->ID)
]);
);
$fields = FieldList::create(TabSet::create('Root', $content));
$this->extend('updateCMSFields', $fields);

View File

@ -3,9 +3,18 @@
namespace SilverStripe\Assets;
use SilverStripe\Core\Convert;
use SilverStripe\Forms\ReadonlyField;
use SilverStripe\Forms\HTMLReadonlyField;
use SilverStripe\Forms\LiteralField;
use SilverStripe\View\Parsers\ShortcodeParser;
use SilverStripe\View\Parsers\ShortcodeHandler;
use SilverStripe\Forms\Tab;
use SilverStripe\Forms\HeaderField;
use SilverStripe\Forms\TabSet;
use SilverStripe\Forms\TextField;
use SilverStripe\Forms\DatetimeField;
use SilverStripe\Forms\ReadonlyField;
use SilverStripe\Forms\HiddenField;
use SilverStripe\Forms\FieldList;
/**
* Represents an Image
@ -36,11 +45,64 @@ class Image extends File implements ShortcodeHandler {
}
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->insertAfter(
'LastEdited',
new ReadonlyField("Dimensions", _t('AssetTableField.DIM','Dimensions') . ':')
$path = '/' . dirname($this->getFilename());
$width = (int)Image::config()->get('asset_preview_width');
$height = (int)Image::config()->get('asset_preview_height');
$previewLink = Convert::raw2att($this
->FitMax($width, $height)
->PreviewLink()
);
$image = "<img src=\"{$previewLink}\" class=\"editor__thumbnail\" />";
$link = $this->Link();
$content = Tab::create('Main',
HeaderField::create('TitleHeader', $this->Title, 1)
->addExtraClass('editor__heading'),
LiteralField::create("ImageFull", $image)
->addExtraClass('editor__file-preview'),
TabSet::create('Editor',
Tab::create('Details',
TextField::create("Title", $this->fieldLabel('Title')),
TextField::create("Name", $this->fieldLabel('Filename')),
ReadonlyField::create(
"Path",
_t('AssetTableField.PATH', 'Path'),
(($path !== '/.') ? $path : '') . '/'
),
HTMLReadonlyField::create(
'ClickableURL',
_t('AssetTableField.URL','URL'),
sprintf('<i class="%s"></i><a href="%s" target="_blank">%s</a>',
'icon font-icon-link editor__url-icon', $link, $link)
)
),
Tab::create('Usage',
DatetimeField::create(
"LastEdited",
_t('AssetTableField.LASTEDIT', 'Last changed')
)->setReadonly(true)
)
),
HiddenField::create('ID', $this->ID)
);
if ($dimensions = $this->getDimensions()) {
$content->insertAfter(
'TitleHeader',
LiteralField::create(
"DisplaySize",
sprintf('<div><i>%spx, %s</i></div>',
$dimensions, $this->getSize())
)
);
}
$fields = FieldList::create(TabSet::create('Root', $content));
$this->extend('updateCMSFields', $fields);
return $fields;
}

View File

@ -156,13 +156,19 @@ trait ImageManipulation {
/**
* The width of an image preview in the Asset section
*
* This thumbnail is only sized to width.
*
* @config
* @var int
*/
private static $asset_preview_width = 400;
/**
* The height of an image preview in the Asset section
*
* @config
* @var int
*/
private static $asset_preview_height = 336;
/**
* Fit image to specified dimensions and fill leftover space with a solid colour (default white). Use in templates with $Pad.
*

View File

@ -12,6 +12,7 @@ use SilverStripe\ORM\ValidationResult;
use SilverStripe\ORM\ValidationException;
use SilverStripe\ORM\FieldType\DBComposite;
use SilverStripe\Security\Permission;
use SilverStripe\Core\Convert;
/**
* Represents a file reference stored in a database
@ -528,4 +529,27 @@ class DBFile extends DBComposite implements AssetContainer, Thumbnail {
->getStore()
->canView($this->Filename, $this->Hash);
}
/**
* Generates the URL for this DBFile preview, this is particularly important for images that
* have been manipulated e.g. by {@link ImageManipulation}
* Use the 'updatePreviewLink' extension point to customise the link.
*
* @param null $action
* @return bool|string
*/
public function PreviewLink($action = null) {
// Since AbsoluteURL can whitelist protected assets,
// do permission check first
if (!$this->failover->canView()) {
return false;
}
if ($this->getIsImage()) {
$link = $this->getAbsoluteURL();
} else {
$link = Convert::raw2att($this->failover->getIcon());
}
$this->extend('updatePreviewLink', $link, $action);
return $link;
}
}

View File

@ -76,6 +76,10 @@ class CompositeField extends FormField {
}
$defaults['children'] = $childSchema;
}
$defaults['data']['tag'] = $this->getTag();
$defaults['data']['legend'] = $this->getLegend();
return $defaults;
}
@ -97,6 +101,35 @@ class CompositeField extends FormField {
return $this->children;
}
/**
* Returns the name (ID) for the element.
* If the CompositeField doesn't have a name, but we still want the ID/name to be set.
* This code generates the ID from the nested children.
*
* @todo this is temporary, and should be removed when FormTemplateHelper is updated to handle ID for CompositeFields with no name
*
* @return String $name
*/
public function getName(){
if($this->name) {
return $this->name;
}
$fieldList = $this->FieldList();
$compositeTitle = '';
$count = 0;
/** @var FormField $subfield */
foreach($fieldList as $subfield){
$compositeTitle .= $subfield->getName();
if($subfield->getName()) $count++;
}
/** @skipUpgrade */
if($count === 1) {
$compositeTitle .= 'Group';
}
return preg_replace("/[^a-zA-Z0-9]+/", "", $compositeTitle);
}
/**
* @param FieldList $children
* @return $this

View File

@ -103,6 +103,9 @@ class FieldGroup extends CompositeField {
* Returns the name (ID) for the element.
* In some cases the FieldGroup doesn't have a title, but we still want
* the ID / name to be set. This code, generates the ID from the nested children
*
* TODO this is temporary, and should be removed when FormTemplateHelper is updated to handle ID
* for CompositeFields with no name
*/
public function getName(){
if($this->name) {
@ -110,17 +113,7 @@ class FieldGroup extends CompositeField {
}
if(!$this->title) {
$fs = $this->FieldList();
$compositeTitle = '';
$count = 0;
foreach($fs as $subfield){
/** @var FormField $subfield */
$compositeTitle .= $subfield->getName();
if($subfield->getName()) $count++;
}
/** @skipUpgrade */
if($count == 1) $compositeTitle .= 'Group';
return preg_replace("/[^a-zA-Z0-9]+/", "", $compositeTitle);
return parent::getName();
}
return preg_replace("/[^a-zA-Z0-9]+/", "", $this->title);

View File

@ -53,6 +53,9 @@ use SilverStripe\View\ArrayData;
*/
class GroupedDropdownField extends DropdownField {
// TODO remove this when GroupedDropdownField is implemented
protected $schemaDataType = 'GroupedDropdownField';
/**
* Build a potentially nested fieldgroup
*

View File

@ -16,7 +16,8 @@ use SilverStripe\ORM\DataObjectInterface;
*/
class MoneyField extends FormField {
protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_TEXT;
// TODO replace with `FormField::SCHEMA_DATA_TYPE_TEXT` when MoneyField is implemented
protected $schemaDataType = 'MoneyField';
/**
* @var string $_locale

View File

@ -35,13 +35,13 @@ class FormSchema {
'actions' => []
];
/** @var FormField $action */
foreach ($form->Actions() as $action) {
/** @var FormField $action */
$schema['actions'][] = $action->getSchemaData();
}
/** @var FormField $field */
foreach ($form->Fields() as $field) {
/** @var FormField $field */
$schema['fields'][] = $field->getSchemaData();
}
@ -81,7 +81,7 @@ class FormSchema {
if ($field instanceof CompositeField) {
$subFields = $field->FieldList();
array_merge($states, $this->getFieldStates($subFields));
$states = array_merge($states, $this->getFieldStates($subFields));
}
}
return $states;

View File

@ -21,6 +21,13 @@ use InvalidArgumentException;
*/
class Tab extends CompositeField {
/**
* Use custom react component
*
* @var string
*/
protected $schemaComponent = 'TabItem';
/**
* @var TabSet
*/
@ -115,7 +122,9 @@ class Tab extends CompositeField {
}
public function extraClass() {
return implode(' ', (array)$this->extraClasses);
$classes = (array)$this->extraClasses;
return implode(' ', $classes);
}
public function getAttributes() {

View File

@ -32,6 +32,13 @@ use InvalidArgumentException;
*/
class TabSet extends CompositeField {
/**
* Use custom react component
*
* @var string
*/
protected $schemaComponent = 'Tabs';
/**
* @var TabSet
*/

View File

@ -369,11 +369,13 @@ class ChangeSet extends DataObject {
}
public function getCMSFields() {
$fields = new FieldList();
$fields->push(TextField::create('Name', $this->fieldLabel('Name')));
if($this->isInDB()) {
$fields->push(ReadonlyField::create('State', $this->fieldLabel('State')));
}
$fields = parent::getCMSFields();
$fields->removeByName('OwnerID');
$fields->removeByName('Changes');
$fields->dataFieldByName('State')->setReadonly(true);
$this->extend('updateCMSFields', $fields);
return $fields;
}

View File

@ -20,6 +20,9 @@ import PopoverField from 'components/PopoverField/PopoverField';
import HeaderField from 'components/HeaderField/HeaderField';
import LiteralField from 'components/LiteralField/LiteralField';
import HtmlReadonlyField from 'components/HtmlReadonlyField/HtmlReadonlyField';
import CompositeField from 'components/CompositeField/CompositeField';
import Tabs from 'components/Tabs/Tabs';
import TabItem from 'components/Tabs/TabItem';
import { routerReducer } from 'react-router-redux';
// Sections
@ -43,6 +46,9 @@ function appBoot() {
injector.register('HeaderField', HeaderField);
injector.register('LiteralField', LiteralField);
injector.register('HtmlReadonlyField', HtmlReadonlyField);
injector.register('CompositeField', CompositeField);
injector.register('Tabs', Tabs);
injector.register('TabItem', TabItem);
injector.register('FormAction', FormAction);
const initialState = {};

View File

@ -0,0 +1,36 @@
import React from 'react';
import SilverStripeComponent from 'lib/SilverStripeComponent';
class CompositeField extends SilverStripeComponent {
getLegend() {
return (
this.props.data.tag === 'fieldset' &&
this.props.data.legend &&
<legend>{this.props.data.legend}</legend>
);
}
render() {
const legend = this.getLegend();
const Tag = this.props.data.tag;
return (
<Tag className={this.props.extraClass}>
{legend}
{this.props.children}
</Tag>
);
}
}
CompositeField.propTypes = {
tag: React.PropTypes.string,
legend: React.PropTypes.string,
extraClass: React.PropTypes.string,
};
CompositeField.defaultProps = {
tag: 'div',
};
export default CompositeField;

View File

@ -0,0 +1,18 @@
# CompositeField
For containing groups of fields in a container element.
## Example
```
<CompositeField name="Container">
<TextField name="FirstName" />
<TextField name="LastName" />
</CompositeField>
```
## Properties
* `tag` (string): The element type the composite field should use in HTML.
* `legend` (boolean): A label/legend for the group of fields contained.
* `extraClass` (string): Extra classes the CompositeField should have.

View File

@ -22,7 +22,7 @@ function fieldHolder(Field) {
return (
<div className={classNames.join(' ')} id={this.props.holder_id}>
{labelText &&
<label className="form__field-label" htmlFor={`${this.props.id}`}>
<label className="form__field-label" htmlFor={this.props.id}>
{labelText}
</label>
}
@ -36,9 +36,10 @@ function fieldHolder(Field) {
}
FieldHolder.propTypes = {
leftTitle: React.PropTypes.string,
title: React.PropTypes.string,
leftTitle: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.bool]),
title: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.bool]),
extraClass: React.PropTypes.string,
holder_id: React.PropTypes.string,
id: React.PropTypes.string,
};

View File

@ -29,6 +29,7 @@ export class FormBuilderComponent extends SilverStripeComponent {
this.removeForm = this.removeForm.bind(this);
this.getFormId = this.getFormId.bind(this);
this.getFormSchema = this.getFormSchema.bind(this);
this.findField = this.findField.bind(this);
}
/**
@ -172,6 +173,13 @@ export class FormBuilderComponent extends SilverStripeComponent {
}
}
/**
* When the action is clicked on, records which action was clicked on
* This can allow for preventing the submit action, such as a custom action for the button
*
* @param event
* @param submitAction
*/
handleAction(event, submitAction) {
this.props.formActions.setSubmitAction(this.getFormId(), submitAction);
if (typeof this.props.handleAction === 'function') {
@ -234,10 +242,15 @@ export class FormBuilderComponent extends SilverStripeComponent {
* @returns {Object}
*/
getFieldValues() {
const schemaFields = this.props.schemas[this.props.schemaUrl].schema.fields;
const schema = this.props.schemas[this.props.schemaUrl];
// using state is more efficient and has the same fields, fallback to nested schema
const fields = (schema.state)
? schema.state.fields
: schema.schema.fields;
return this.props.form[this.getFormId()].fields
.reduce((prev, curr) => {
const match = schemaFields.find(schemaField => schemaField.id === curr.id);
const match = this.findField(fields, curr.id);
if (!match) {
return prev;
}
@ -248,6 +261,38 @@ export class FormBuilderComponent extends SilverStripeComponent {
}, {});
}
/**
* Finds the field with matching id from the schema or state, this is mainly for dealing with
* schema's deep nesting of fields.
*
* @param fields
* @param id
* @returns {object|undefined}
*/
findField(fields, id) {
let result = null;
if (!fields) {
return result;
}
result = fields.find(field => field.id === id);
for (const field of fields) {
if (result) {
break;
}
result = this.findField(field.children, id);
}
return result;
}
/**
* Common functionality for building a Field or Action from schema.
*
* @param field
* @param extraProps
* @returns {*}
*/
buildComponent(field, extraProps = {}) {
const Component = field.component !== null
? injector.getComponentByName(field.component)
@ -255,6 +300,8 @@ export class FormBuilderComponent extends SilverStripeComponent {
if (Component === null) {
return null;
} else if (field.component !== null && Component === undefined) {
throw Error(`Component not found in injector: ${field.component}`);
}
// Props which every form field receives.
@ -336,7 +383,7 @@ export class FormBuilderComponent extends SilverStripeComponent {
return structure;
}
return merge.recursive(true, structure, {
data: Object.assign({}, structure.data, state.data),
data: state.data,
source: state.source,
messages: state.messages,
valid: state.valid,
@ -353,6 +400,27 @@ export class FormBuilderComponent extends SilverStripeComponent {
this.props.formActions.removeForm(formId);
}
/**
* If there is structural and state data availabe merge those data for each field.
* Otherwise just use the structural data.
*/
getFieldData(formFields, formState) {
if (!formFields || !formState || !formState.fields) {
return formFields;
}
return formFields.map((field) => {
const state = formState.fields.find((item) => item.id === field.id);
const data = this.mergeFieldData(field, state);
if (field.children) {
data.children = this.getFieldData(field.children, formState);
}
return data;
});
}
render() {
const formId = this.getFormId();
if (!formId) {
@ -363,7 +431,7 @@ export class FormBuilderComponent extends SilverStripeComponent {
// If the response from fetching the initial data
// hasn't come back yet, don't render anything.
if (!formSchema) {
if (!formSchema || !formSchema.schema) {
return null;
}
@ -376,15 +444,7 @@ export class FormBuilderComponent extends SilverStripeComponent {
encType: formSchema.schema.attributes.enctype,
});
// If there is structural and state data availabe merge those data for each field.
// Otherwise just use the structural data.
const fieldData = formSchema.schema && formState && formState.fields
? formSchema.schema.fields.map((field) => {
const state = formState.fields.find((item) => item.id === field.id);
return this.mergeFieldData(field, state);
})
: formSchema.schema.fields;
const fieldData = this.getFieldData(formSchema.schema.fields, formState);
const formProps = {
actions: formSchema.schema.actions,

View File

@ -16,7 +16,10 @@ The schema URL where the form will be scaffolded from e.g. '/admin/pages/schema/
### handleSubmit (func)
Event handler passed to the Form Component as a prop.
Event handler passed to the Form Component as a prop. Parameters received are:
* event (Event) - The submit event, it is strongly recommended to call `preventDefault()`
* fieldValues (object) - An object containing the field values captured by the Submit handler
* submitFn (func) - A callback for when the submission was successful, if submission fails, this function should not be called. (e.g. validation error)
### handleAction (func)

View File

@ -94,4 +94,60 @@ describe('FormBuilderComponent', () => {
});
});
});
describe('findField()', () => {
let formBuilder = null;
let fields = null;
const props = {
form: {
myForm: {},
formActions: {},
schemas: {
'admin/assets/schema/1': {
id: 'myForm',
schema: {},
},
},
schemaActions: {},
schemaUrl: 'admin/assets/schema/1',
},
};
beforeEach(() => {
formBuilder = new FormBuilderComponent(props);
});
it('should retrieve the field in the shallow fields list', () => {
fields = [
{ id: 'fieldOne' },
{ id: 'fieldTwo' },
{ id: 'fieldThree' },
{ id: 'fieldFour' },
];
const field = formBuilder.findField(fields, 'fieldThree');
expect(field).toBeTruthy();
expect(field.id).toBe('fieldThree');
});
it('should retrieve the field that is a grandchild in the fields list', () => {
fields = [
{ id: 'fieldOne' },
{ id: 'fieldTwo', children: [
{ id: 'fieldTwoOne' },
{ id: 'fieldTwoTwo', children: [
{ id: 'fieldTwoOne' },
{ id: 'fieldTwoTwo' },
{ id: 'fieldTwoThree' },
] },
] },
{ id: 'fieldThree' },
{ id: 'fieldFour' },
];
const field = formBuilder.findField(fields, 'fieldTwoThree');
expect(field).toBeTruthy();
expect(field.id).toBe('fieldTwoThree');
});
});
});

View File

@ -8,13 +8,19 @@ class LiteralField extends SilverStripeComponent {
render() {
return (
<div id={this.props.id} dangerouslySetInnerHTML={this.getContent()}></div>
<div
id={this.props.id}
className={this.props.extraClass}
dangerouslySetInnerHTML={this.getContent()}
>
</div>
);
}
}
LiteralField.propTypes = {
id: React.PropTypes.string,
extraClass: React.PropTypes.string,
data: React.PropTypes.oneOfType([
React.PropTypes.array,
React.PropTypes.shape({

View File

@ -46,7 +46,7 @@ class SingleSelectField extends SilverStripeComponent {
getSelectField() {
const options = this.props.source || [];
if (this.props.data.hasEmptyDefault) {
if (this.props.data.hasEmptyDefault && !options.find((item) => !item.value)) {
options.unshift({
value: '',
title: this.props.data.emptyString,
@ -55,8 +55,8 @@ class SingleSelectField extends SilverStripeComponent {
}
return (
<select {...this.getInputProps()}>
{ options.map((item) => {
const key = `${this.props.name}-${item.value || 'null'}`;
{ options.map((item, index) => {
const key = `${this.props.name}-${item.value || `empty${index}`}`;
return (
<option key={key} value={item.value} disabled={item.disabled}>
@ -105,7 +105,7 @@ SingleSelectField.propTypes = {
readOnly: React.PropTypes.bool,
source: React.PropTypes.arrayOf(React.PropTypes.shape({
value: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.number]),
title: React.PropTypes.string,
title: React.PropTypes.any,
disabled: React.PropTypes.bool,
})),
data: React.PropTypes.oneOfType([

View File

@ -1 +1,51 @@
# Tabs
For separating content into tabs without the need for separate pages.
This extends from `react-bootstrap` with similar expected behaviours, only difference is that when
there is only one tab (or none) in the Tabset, then only the content will show without the
clickable tab.
## Example
```
<Tabs defaultActiveKey="Main" id="Root">
<TabItem name="Main" title="Main">
My first tab content
</TabItem>
<TabItem name="Settings" title="Settings">
My settings tab here
</TabItem>
</Tabs>
```
## Tabs Properties
* `id` (string) (required): The ID for the Tabset.
* `defaultActiveKey` (string): The default tab to open, should match the name of a child `TabItem`, will default to the first Tab child.
* `extraClass` (string): Extra classes the Tabset should have.
* `activeKey` (string): Used with `onSelect` action handler, forces the given tab to open, should match the name of a child `TabItem`.
* `onSelect` (function): Action handler for when a tab is clicked, if an override for the default functionality is required, will need to handle own state.
* `animation` (boolean): Whether to animate when transitioning between tabs.
* `bsStyle` (enum): `"tabs"|"pills"` change the style of the tabs.
* `unmountOnExit` (boolean): Whether to remove the Tab content from the DOM when it's hidden.
## TabItem Properties
* `name` (string) (required): A key to match the `activeKey` or `defaultActiveKey` property in the `Tabs` component to show the content. This replaces the `eventKey` property.
* `title` (string): The label to display for the tab, can be set `null` to hide the tab.
* `disabled` (boolean): Whether the Tab is clickable, or greyed out.
* `extraClass` (string): Extra classes the Tab should have.
* `bsClass` (string): Changes `tab-pane` bootstrap class to use something else.
* `tabClassName` (string): Class to give tab title.
Animation callbacks
* `onEnter` `onEntering` `onEntered` - different stages for when a tab is opened.
* `onExit` `onExiting` `onExited` - different stages for when a tab is closed.
Not recommended properties, if using `Tabs` component:
* `animation` (boolean|ReactElement): Animation when transitioning between tabs.
* `id` (string): Identifier for `TabItem`, this is generated by the `Tabs` component.
* `aria-labelledby` (string): Accessibility, provides information about the `TabItem`, generated by the `Tabs` component.
* `unmountOnExit` (boolean): Whether to remove the Tab content from the DOM when it's hidden.

View File

@ -0,0 +1,62 @@
import React from 'react';
import SilverStripeComponent from 'lib/SilverStripeComponent';
import { Tab } from 'react-bootstrap-ss';
class TabItem extends SilverStripeComponent {
getTabProps() {
const {
name,
className,
extraClass,
disabled,
bsClass,
onEnter,
onEntering,
onEntered,
onExit,
onExiting,
onExited,
animation,
id,
unmountOnExit,
} = this.props;
return {
eventKey: name,
className: `${className} ${extraClass}`,
disabled,
bsClass,
onEnter,
onEntering,
onEntered,
onExit,
onExiting,
onExited,
animation,
id,
unmountOnExit,
'aria-labelledby': this.props['aria-labelledby'],
};
}
render() {
const tabProps = this.getTabProps();
return (
<Tab.Pane {...tabProps}>
{this.props.children}
</Tab.Pane>
);
}
}
TabItem.propTypes = {
name: React.PropTypes.string.isRequired,
extraClass: React.PropTypes.string,
};
TabItem.defaultProps = {
className: '',
extraClass: '',
};
export default TabItem;

View File

@ -0,0 +1,126 @@
import React from 'react';
import SilverStripeComponent from 'lib/SilverStripeComponent';
import { Tab, Nav, NavItem } from 'react-bootstrap-ss';
class Tabs extends SilverStripeComponent {
/**
* Returns props for the container component
*
* @returns {object}
*/
getContainerProps() {
const {
activeKey,
onSelect,
className,
extraClass,
} = this.props;
const combinedClassName = `${className} ${extraClass}`;
return {
activeKey,
className: combinedClassName,
defaultActiveKey: this.getDefaultActiveKey(),
onSelect,
};
}
/**
* Determines a default tab to be opened and validates the given default tab.
* Replaces the given default tab if it is invalid with a valid tab.
*
* @returns {string|undefined}
*/
getDefaultActiveKey() {
let active = null;
if (typeof this.props.defaultActiveKey === 'string') {
const activeChild = React.Children.toArray(this.props.children)
.find((child) => child.props.name === this.props.defaultActiveKey);
if (activeChild) {
active = activeChild.props.name;
}
}
if (typeof active !== 'string') {
React.Children.forEach(this.props.children, (child) => {
if (typeof active !== 'string') {
active = child.props.name;
}
});
}
return active;
}
/**
* Render an individual link for the tabset
*
* @param child
* @returns {ReactElement}
*/
renderTab(child) {
if (child.props.title === null) {
return null;
}
return (
<NavItem eventKey={child.props.name}
disabled={child.props.disabled}
className={child.props.tabClassName}
>
{child.props.title}
</NavItem>
);
}
/**
* Builds the tabset navigation links, will hide the links if there is only one child
*
* @returns {ReactElement|null}
*/
renderNav() {
const tabs = React.Children
.map(this.props.children, this.renderTab);
if (tabs.length <= 1) {
return null;
}
return (
<Nav bsStyle={this.props.bsStyle} role="tablist">
{tabs}
</Nav>
);
}
render() {
const containerProps = this.getContainerProps();
const nav = this.renderNav();
return (
<Tab.Container {...containerProps}>
<div className="wrapper">
{ nav }
<Tab.Content animation={this.props.animation}>
{this.props.children}
</Tab.Content>
</div>
</Tab.Container>
);
}
}
Tabs.propTypes = {
id: React.PropTypes.string.isRequired,
defaultActiveKey: React.PropTypes.string,
extraClass: React.PropTypes.string,
};
Tabs.defaultProps = {
bsStyle: 'tabs',
className: '',
extraClass: '',
};
export default Tabs;

View File

@ -37,6 +37,8 @@ class Injector {
return this.components.SingleSelectField;
case 'Custom':
return this.components.GridField;
case 'Structural':
return this.components.CompositeField;
default:
return null;
}

View File

@ -0,0 +1,23 @@
<div $getAttributesHTML("class") class="ss-tabset $extraClass">
<ul class="nav nav-tabs">
<% loop $Tabs %>
<li class="$FirstLast $MiddleString $extraClass nav-item">
<a href="#$id" id="tab-$id" class="nav-link">$Title</a>
</li>
<% end_loop %>
</ul>
<div class="tab-content">
<% loop $Tabs %>
<% if $Tabs %>
$FieldHolder
<% else %>
<div $getAttributesHTML("class") class="tab-pane $extraClass">
<% loop $Fields %>
$FieldHolder
<% end_loop %>
</div>
<% end_if %>
<% end_loop %>
</div>
</div>

View File

@ -76,5 +76,14 @@
});
}
});
$('.ui-tabs-active .ui-tabs-anchor').entwine({
onmatch: function onmatch() {
this.addClass('nav-link active');
},
onunmatch: function onunmatch() {
this.removeClass('active');
}
});
});
});

View File

@ -72,4 +72,14 @@ $.entwine('ss', function($){
});
}
});
// adding bootstrap theme classes to corresponding jQueryUI elements
$('.ui-tabs-active .ui-tabs-anchor').entwine({
onmatch: function() {
this.addClass('nav-link active');
},
onunmatch: function() {
this.removeClass('active');
}
});
});

View File

@ -388,6 +388,7 @@ gulp.task('bundle-legacy', function bundleLeftAndMain() {
.external('i18n')
.external('i18nx')
.external('lib/Router')
.external('react')
.external('react-dom')
.external('react-bootstrap-ss')
.external('components/FormBuilderModal/FormBuilderModal')

1
npm-shrinkwrap.json generated
View File

@ -6902,7 +6902,6 @@
},
"chosen": {
"version": "1.5.1",
"from": "chosen@git://github.com/harvesthq/chosen.git#66ee36dc4b3e4faf01f24ab2908e2d1aba771b53",
"resolved": "git://github.com/harvesthq/chosen.git#66ee36dc4b3e4faf01f24ab2908e2d1aba771b53"
},
"deep-equal": {

View File

@ -1,10 +1,13 @@
<div $getAttributesHTML("class") class="ss-tabset $extraClass">
<ul>
<% loop $Tabs %>
<li class="$FirstLast $MiddleString $extraClass"><a href="#$id" id="tab-$id">$Title</a></li>
<li class="$FirstLast $MiddleString $extraClass">
<a href="#$id" id="tab-$id" class="nav-link">$Title</a>
</li>
<% end_loop %>
</ul>
<div>
<% loop $Tabs %>
<% if $Tabs %>
$FieldHolder
@ -16,4 +19,5 @@
</div>
<% end_if %>
<% end_loop %>
</div>
</div>

View File

@ -286,6 +286,8 @@ class FormSchemaTest extends SapphireTest {
'data' => [
'popoverTitle' => null,
'placement' => 'bottom',
'tag' => 'div',
'legend' => null,
],
'children' => [
[