From ee5b4fd8d3ab30b62149f2d0a370bb3dbedaabae Mon Sep 17 00:00:00 2001 From: Christopher Joe Date: Wed, 7 Sep 2016 15:35:47 +1200 Subject: [PATCH] Tabs support in new file/image editor Introducing 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 --- Assets/File.php | 59 ++++---- Assets/Folder.php | 24 +++- Assets/Image.php | 72 +++++++++- Assets/ImageManipulation.php | 10 +- Assets/Storage/DBFile.php | 24 ++++ Forms/CompositeField.php | 33 +++++ Forms/FieldGroup.php | 15 +-- Forms/GroupedDropdownField.php | 3 + Forms/MoneyField.php | 3 +- Forms/Schema/FormSchema.php | 6 +- Forms/Tab.php | 11 +- Forms/TabSet.php | 7 + ORM/Versioning/ChangeSet.php | 12 +- admin/client/src/boot/index.js | 6 + .../CompositeField/CompositeField.js | 36 +++++ .../src/components/CompositeField/README.md | 18 +++ .../src/components/FieldHolder/FieldHolder.js | 7 +- .../src/components/FormBuilder/FormBuilder.js | 86 ++++++++++-- .../src/components/FormBuilder/README.md | 5 +- .../FormBuilder/tests/FormBuilder-test.js | 56 ++++++++ .../components/LiteralField/LiteralField.js | 8 +- .../SingleSelectField/SingleSelectField.js | 8 +- admin/client/src/components/Tabs/README.md | 50 +++++++ admin/client/src/components/Tabs/TabItem.js | 62 +++++++++ admin/client/src/components/Tabs/Tabs.js | 126 ++++++++++++++++++ admin/client/src/lib/Injector.js | 2 + .../templates/SilverStripe/Forms/TabSet.ss | 23 ++++ client/dist/js/TabSet.js | 9 ++ client/src/legacy/TabSet.js | 10 ++ gulpfile.js | 1 + npm-shrinkwrap.json | 1 - templates/SilverStripe/Forms/TabSet.ss | 6 +- tests/forms/FormSchemaTest.php | 2 + 33 files changed, 715 insertions(+), 86 deletions(-) create mode 100644 admin/client/src/components/CompositeField/CompositeField.js create mode 100644 admin/client/src/components/CompositeField/README.md create mode 100644 admin/client/src/components/Tabs/TabItem.js create mode 100644 admin/client/src/components/Tabs/Tabs.js create mode 100644 admin/themes/bootstrap/templates/SilverStripe/Forms/TabSet.ss diff --git a/Assets/File.php b/Assets/File.php index 6a5a6d2e5..e8325eb1a 100644 --- a/Assets/File.php +++ b/Assets/File.php @@ -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 = ""; - 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('%s', $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); diff --git a/Assets/Folder.php b/Assets/Folder.php index c5a61d6c1..bcbd460d8 100644 --- a/Assets/Folder.php +++ b/Assets/Folder.php @@ -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 = ""; + + $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); diff --git a/Assets/Image.php b/Assets/Image.php index 309ea8506..f6a64bfec 100644 --- a/Assets/Image.php +++ b/Assets/Image.php @@ -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 = ""; + + $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('%s', + '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('
%spx, %s
', + $dimensions, $this->getSize()) + ) + ); + } + + $fields = FieldList::create(TabSet::create('Root', $content)); + + $this->extend('updateCMSFields', $fields); + return $fields; } diff --git a/Assets/ImageManipulation.php b/Assets/ImageManipulation.php index 83a30ed63..050030079 100644 --- a/Assets/ImageManipulation.php +++ b/Assets/ImageManipulation.php @@ -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. * diff --git a/Assets/Storage/DBFile.php b/Assets/Storage/DBFile.php index aaccd8180..aaed8c935 100644 --- a/Assets/Storage/DBFile.php +++ b/Assets/Storage/DBFile.php @@ -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; + } } diff --git a/Forms/CompositeField.php b/Forms/CompositeField.php index 99ea7b631..47170e0ef 100644 --- a/Forms/CompositeField.php +++ b/Forms/CompositeField.php @@ -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 diff --git a/Forms/FieldGroup.php b/Forms/FieldGroup.php index d2b98b48f..02ada59d1 100644 --- a/Forms/FieldGroup.php +++ b/Forms/FieldGroup.php @@ -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); diff --git a/Forms/GroupedDropdownField.php b/Forms/GroupedDropdownField.php index 3ecd710dd..7634fc850 100644 --- a/Forms/GroupedDropdownField.php +++ b/Forms/GroupedDropdownField.php @@ -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 * diff --git a/Forms/MoneyField.php b/Forms/MoneyField.php index 8c8c52521..13d4734df 100644 --- a/Forms/MoneyField.php +++ b/Forms/MoneyField.php @@ -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 diff --git a/Forms/Schema/FormSchema.php b/Forms/Schema/FormSchema.php index dd50b8716..17bd3b8c7 100644 --- a/Forms/Schema/FormSchema.php +++ b/Forms/Schema/FormSchema.php @@ -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; diff --git a/Forms/Tab.php b/Forms/Tab.php index 3ae27b7a7..e39bcd4b1 100644 --- a/Forms/Tab.php +++ b/Forms/Tab.php @@ -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() { diff --git a/Forms/TabSet.php b/Forms/TabSet.php index d340201ae..5e9da7190 100644 --- a/Forms/TabSet.php +++ b/Forms/TabSet.php @@ -32,6 +32,13 @@ use InvalidArgumentException; */ class TabSet extends CompositeField { + /** + * Use custom react component + * + * @var string + */ + protected $schemaComponent = 'Tabs'; + /** * @var TabSet */ diff --git a/ORM/Versioning/ChangeSet.php b/ORM/Versioning/ChangeSet.php index 922af2f02..d57bac358 100644 --- a/ORM/Versioning/ChangeSet.php +++ b/ORM/Versioning/ChangeSet.php @@ -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; } diff --git a/admin/client/src/boot/index.js b/admin/client/src/boot/index.js index 980467641..309165a91 100644 --- a/admin/client/src/boot/index.js +++ b/admin/client/src/boot/index.js @@ -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 = {}; diff --git a/admin/client/src/components/CompositeField/CompositeField.js b/admin/client/src/components/CompositeField/CompositeField.js new file mode 100644 index 000000000..da1449d2c --- /dev/null +++ b/admin/client/src/components/CompositeField/CompositeField.js @@ -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 && + {this.props.data.legend} + ); + } + + render() { + const legend = this.getLegend(); + const Tag = this.props.data.tag; + + return ( + + {legend} + {this.props.children} + + ); + } +} + +CompositeField.propTypes = { + tag: React.PropTypes.string, + legend: React.PropTypes.string, + extraClass: React.PropTypes.string, +}; + +CompositeField.defaultProps = { + tag: 'div', +}; + +export default CompositeField; diff --git a/admin/client/src/components/CompositeField/README.md b/admin/client/src/components/CompositeField/README.md new file mode 100644 index 000000000..7e96dbe8a --- /dev/null +++ b/admin/client/src/components/CompositeField/README.md @@ -0,0 +1,18 @@ +# CompositeField + +For containing groups of fields in a container element. + +## Example + +``` + + + + +``` + +## 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. diff --git a/admin/client/src/components/FieldHolder/FieldHolder.js b/admin/client/src/components/FieldHolder/FieldHolder.js index 68e0fb867..579370954 100644 --- a/admin/client/src/components/FieldHolder/FieldHolder.js +++ b/admin/client/src/components/FieldHolder/FieldHolder.js @@ -22,7 +22,7 @@ function fieldHolder(Field) { return (
{labelText && -