From 5800db0239f1c7c3e7a5ae639d3315799d606be7 Mon Sep 17 00:00:00 2001 From: Sam Minnee Date: Thu, 8 Mar 2012 19:04:07 +1300 Subject: [PATCH] API CHANGE: Allow for the creation of custom GridField fragments. (#6911) --- docs/en/topics/grid-field.md | 36 ++++++++++ forms/gridfield/GridField.php | 80 ++++++++++++++++++---- forms/gridfield/GridFieldComponent.php | 13 +++- tests/forms/GridFieldTest.php | 94 ++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 14 deletions(-) diff --git a/docs/en/topics/grid-field.md b/docs/en/topics/grid-field.md index ce3e6e834..a53938e73 100644 --- a/docs/en/topics/grid-field.md +++ b/docs/en/topics/grid-field.md @@ -191,6 +191,42 @@ For example, this components will add a footer row to the grid field, thanking t If you wish to add CSS or JavaScript for your component, you may also make `Requirements` calls in this method. +### Defining new fragments + +Sometimes it is helpful to have one component write HTML into another component. For example, you might have an action header row at the top of your GridField that several different components may define actions for. + +To do this, you can put the following code into one of the HTML fragments returned by an HTML provider. + + $DefineFragment(fragment-name) + +Other `GridField_HTMLProvider` components can now write to `fragment-name` just as they would write to footer, etc. Fragments can be nested. + +For example, this component creates a `header-actions` fragment name that can be populated by other components: + + :::php + class HeaderActionComponent implements GridField_HTMLProvider { + public function getHTMLFragments($gridField) { + $colSpan = $gridField->getColumnCount(); + array( + "header" => "\$DefineFragment(header-actions)" + ); + } + } + +This is a simple example of how you might populate that new fragment: + + :::php + class AddNewActionComponent implements GridField_HTMLProvider { + public function getHTMLFragments($gridField) { + $colSpan = $gridField->getColumnCount(); + array( + "header-actions" => "" + ); + } + } + +If you write to a fragment that isn't defined anywhere, or you create a circular dependency within fragments, an exception will be thrown. + ### GridField_ColumnProvider By default, a grid contains no columns. All the columns displayed in a grid will need to be added by an appropriate component. diff --git a/forms/gridfield/GridField.php b/forms/gridfield/GridField.php index ea0a94b64..3ba9a1d75 100755 --- a/forms/gridfield/GridField.php +++ b/forms/gridfield/GridField.php @@ -314,22 +314,79 @@ class GridField extends FormField { // Render headers, footers, etc $content = array( - 'header' => array(), - 'body' => array(), - 'footer' => array(), - 'before' => array(), - 'after' => array(), + "before" => "", + "after" => "", + "header" => "", + "footer" => "", ); foreach($this->components as $item) { if($item instanceof GridField_HTMLProvider) { $fragments = $item->getHTMLFragments($this); foreach($fragments as $k => $v) { - $content[$k][] = $v; + $k = strtolower($k); + if(!isset($content[$k])) $content[$k] = ""; + $content[$k] .= $v . "\n"; } } } + foreach($content as $k => $v) { + $content[$k] = trim($v); + } + + // Replace custom fragments and check which fragments are defined + // Nested dependencies are handled by deferring the rendering of any content item that + // Circular dependencies are detected by disallowing any item to be deferred more than 5 times + // It's a fairly crude algorithm but it works + + $fragmentDefined = array('header' => true, 'footer' => true, 'before' => true, 'after' => true); + reset($content); + while(list($k,$v) = each($content)) { + if(preg_match_all('/\$DefineFragment\(([a-z0-9\-_]+)\)/i', $v, $matches)) { + foreach($matches[1] as $match) { + $fragmentName = strtolower($match); + $fragmentDefined[$fragmentName] = true; + $fragment = isset($content[$fragmentName]) ? $content[$fragmentName] : ""; + + // If the fragment still has a fragment definition in it, when we should defer this item until later. + if(preg_match('/\$DefineFragment\(([a-z0-9\-_]+)\)/i', $fragment, $matches)) { + // If we've already deferred this fragment, then we have a circular dependency + if(isset($fragmentDeferred[$k]) && $fragmentDeferred[$k] > 5) throw new LogicException("GridField HTML fragment '$fragmentName' and '$matches[1]' appear to have a circular dependency."); + + // Otherwise we can push to the end of the content array + unset($content[$k]); + $content[$k] = $v; + if(!isset($fragmentDeferred[$k])) $fragmentDeferred[$k] = 1; + else $fragmentDeferred[$k]++; + break; + } else { + $content[$k] = preg_replace('/\$DefineFragment\(' . $fragmentName . '\)/i', $fragment, $content[$k]); + } + } + } + } + + // Check for any undefined fragments, and if so throw an exception + // While we're at it, trim whitespace off the elements + foreach($content as $k => $v) { + if(empty($fragmentDefined[$k])) throw new LogicException("GridField HTML fragment '$k' was given content, " . + "but not defined. Perhaps there is a supporting GridField component you need to add?"); + } + + $rows = array(); + foreach($list as $idx => $record) { + $rowContent = ''; + foreach($columns as $column) { + $colContent = $this->getColumnContent($record, $column); + // A return value of null means this columns should be skipped altogether. + if($colContent === null) continue; + $colAttributes = $this->getColumnAttributes($record, $column); + $rowContent .= $this->createTag('td', $colAttributes, $colContent); + } + $rows[] = $row; + } + $content['body'] = implode("\n", $rows); $total = $list->count(); if($total > 0) { @@ -364,13 +421,12 @@ class GridField extends FormField { array("class" => 'ss-gridfield-item ss-gridfield-no-items'), $this->createTag('td', array('colspan' => count($columns)), _t('GridField.NoItemsFound', 'No items found')) ); - $content['body'][] = $row; } // Turn into the relevant parts of a table - $head = $content['header'] ? $this->createTag('thead', array(), implode("\n", $content['header'])) : ''; - $body = $content['body'] ? $this->createTag('tbody', array('class' => 'ss-gridfield-items'), implode("\n", $content['body'])) : ''; - $foot = $content['footer'] ? $this->createTag('tfoot', array(), implode("\n", $content['footer'])) : ''; + $head = $content['header'] ? $this->createTag('thead', array(), $content['header']) : ''; + $body = $content['body'] ? $this->createTag('tbody', array('class' => 'ss-gridfield-items'), $content['body']) : ''; + $foot = $content['footer'] ? $this->createTag('tfoot', array(), $content['footer']) : ''; $this->addExtraClass('ss-gridfield field'); $attrs = array_diff_key( @@ -386,9 +442,9 @@ class GridField extends FormField { ); return $this->createTag('fieldset', $attrs, - implode("\n", $content['before']) . + $content['before'] . $this->createTag('table', $tableAttrs, $head."\n".$foot."\n".$body) . - implode("\n", $content['after']) + $content['after'] ); } diff --git a/forms/gridfield/GridFieldComponent.php b/forms/gridfield/GridFieldComponent.php index c53666fee..988ed1083 100644 --- a/forms/gridfield/GridFieldComponent.php +++ b/forms/gridfield/GridFieldComponent.php @@ -12,8 +12,17 @@ interface GridFieldComponent { interface GridField_HTMLProvider extends GridFieldComponent { /** - * Returns a map with 4 keys 'header', 'footer', 'before', 'after'. Each of these can contain an - * HTML fragment and each of these are optional. + * Returns a map where the keys are fragment names and the values are pieces of HTML to add to these fragments. + * + * Here are 4 built-in fragments: 'header', 'footer', 'before', and 'after', but components may also specify + * fragments of their own. + * + * To specify a new fragment, specify a new fragment by including the text "$DefineFragment(fragmentname)" in the + * HTML that you return. Fragment names should only contain alphanumerics, -, and _. + * + * If you attempt to return HTML for a fragment that doesn't exist, an exception will be thrown when the GridField + * is rendered. + * * @return Array */ function getHTMLFragments($gridField); diff --git a/tests/forms/GridFieldTest.php b/tests/forms/GridFieldTest.php index e536628f0..24d0543e3 100644 --- a/tests/forms/GridFieldTest.php +++ b/tests/forms/GridFieldTest.php @@ -333,7 +333,90 @@ class GridFieldTest extends SapphireTest { $request = new SS_HTTPRequest('POST', 'url'); $obj->gridFieldAlterAction(array('StateID'=>$id), $form, $request); } + + /** + * Test the interface for adding custom HTML fragment slots via a component + */ + public function testGridFieldCustomFragments() { + new GridFieldTest_HTMLFragments(array( + "header-left-actions" => "left\$DefineFragment(nested-left)", + "header-right-actions" => "right", + )); + + new GridFieldTest_HTMLFragments(array( + "nested-left" => "[inner]", + )); + + + $config = GridFieldConfig::create()->addComponents( + new GridFieldTest_HTMLFragments(array( + "header" => "
\$DefineFragment(header-right-actions)
\$DefineFragment(header-left-actions)
", + )), + new GridFieldTest_HTMLFragments(array( + "header-left-actions" => "left", + "header-right-actions" => "rightone", + )), + new GridFieldTest_HTMLFragments(array( + "header-right-actions" => "righttwo", + )) + ); + $field = new GridField('testfield', 'testfield', ArrayList::create(), $config); + $form = new Form(new Controller(), 'testform', new FieldList(array($field)), new FieldList()); + + $this->assertContains("
rightone\nrighttwo
left
", + $field->FieldHolder()); + } + + /** + * Test the nesting of custom fragments + */ + public function testGridFieldCustomFragmentsNesting() { + $config = GridFieldConfig::create()->addComponents( + new GridFieldTest_HTMLFragments(array( + "level-one" => "first", + )), + new GridFieldTest_HTMLFragments(array( + "before" => "
\$DefineFragment(level-one)
", + )), + new GridFieldTest_HTMLFragments(array( + "level-one" => "\$DefineFragment(level-two)", + )), + new GridFieldTest_HTMLFragments(array( + "level-two" => "second", + )) + ); + $field = new GridField('testfield', 'testfield', ArrayList::create(), $config); + $form = new Form(new Controller(), 'testform', new FieldList(array($field)), new FieldList()); + + $this->assertContains("
first\nsecond
", + $field->FieldHolder()); + } + + /** + * Test that circular dependencies throw an exception + */ + public function testGridFieldCustomFragmentsCircularDependencyThrowsException() { + $config = GridFieldConfig::create()->addComponents( + new GridFieldTest_HTMLFragments(array( + "level-one" => "first", + )), + new GridFieldTest_HTMLFragments(array( + "before" => "
\$DefineFragment(level-one)
", + )), + new GridFieldTest_HTMLFragments(array( + "level-one" => "\$DefineFragment(level-two)", + )), + new GridFieldTest_HTMLFragments(array( + "level-two" => "\$DefineFragment(level-one)", + )) + ); + $field = new GridField('testfield', 'testfield', ArrayList::create(), $config); + $form = new Form(new Controller(), 'testform', new FieldList(array($field)), new FieldList()); + + $this->setExpectedException('LogicException'); + $field->FieldHolder(); + } } class GridFieldTest_Component implements GridField_ColumnProvider, GridField_ActionProvider, TestOnly{ @@ -388,4 +471,15 @@ class GridFieldTest_Player extends DataObject implements TestOnly { ); static $belongs_many_many = array('Teams' => 'GridFieldTest_Team'); +} + + +class GridFieldTest_HTMLFragments implements GridField_HTMLProvider, TestOnly{ + function __construct($fragments) { + $this->fragments = $fragments; + } + + function getHTMLFragments($gridField) { + return $this->fragments; + } } \ No newline at end of file