Исходными требованиями задачи было добавить таблицу для ввода финансовых показателей в хронологическом порядке, с возможностью добавлять в этой таблице строки, а также диаграмму, которая будет динамически меняться в зависимости от того, какие данные пользователь вводит.

Колонки таблицы:
График должен рисоваться в реальном времени по данным, которые пользователь вводит в таблице. Заполнение всех полей в одном ряду обязательно, если не заполнено хотя бы одно из полей в ряду — нужно выдать ошибку и подсветить проблемное поле.
На странице ноды нужно показывать таблицу с данными, которые пользователь ввел на форме ноды и показывать точно такой же график как и на форме.

После анализа требований, как обычно, были предприняты попытки найти готовые или похожие решения среди contrib-модулей, но ничего подходящего найдено не было.
В результате анализа вариантов реализации было принято решение создать новое CCK-поле. Преимущества такого выбора:
Итак, мы будем делать составное CCK-поле, которое будет выводиться в виде таблицы, как на форме, так и на странице ноды. Во второй части статьи мы к этому полю прикрутим график.
Мы не будем сейчас останавливаться на том, как сделать CCK-поле, потому что об этом написано много хороших статей как на английском, так и на русском языках - некоторые из них приведены в конце статьи.
Мы рассмотрим особенности создания составного CCK-поля.
Для этого нам нужно внести измнения в hook_field_settings():
/** * Implementation of CCK's hook_field_settings() * CCK hook_field_settings (http://drupal.org/node/354365) */ function financial_table_field_settings($op, $field) { switch ($op) { case 'database columns': break; case 'form': // Global settings for field: $form = array(); return $form; case 'validate': break; case 'save': break; } }
Мы добавляем описания дополнительных полей для операции database columns. Формат такой же как в Schema API. Обратите внимание, что цифровые поля у нас хранятся как VARCHAR, потому что они могут быть десятичными (иметь точку и несколько знаков после запятой) — данные хранятся как строка.
case 'database columns': $database_columns = array( 'financial_table_year' => array( 'type' => 'varchar', 'length' => 4, 'not null' => FALSE, 'sortable' => TRUE, 'views' => TRUE, ), 'financial_table_revenues' => array( 'type' => 'varchar', 'length' => 12, 'not null' => FALSE, 'sortable' => TRUE, 'views' => TRUE, ), 'financial_table_expenditures' => array( 'type' => 'varchar', 'length' => 12, 'not null' => FALSE, 'sortable' => TRUE, 'views' => TRUE, ), 'financial_table_net' => array( 'type' => 'varchar', 'length' => 12, 'not null' => FALSE, 'sortable' => TRUE, 'views' => TRUE, ), ); return $database_columns;
Теперь создадим форму настроек поля, чтобы можно было менять диапазон лет в выпадающем списке. Формат элементов см. в Form API:
case 'form': // Global settings for field: $form = array(); $form['financial_table_year_range'] = array( '#type' => 'textfield', '#title' => t('Years back and forward'), '#default_value' => !empty($field['financial_table_year_range']) ? $field['financial_table_year_range'] : '-50:+10', '#size' => 10, '#maxsize' => 10, '#description' => t('Number of years to go back and forward in the year selection list, default is -50:+10.'), ); return $form;
Вот как эта форма выглядит:

Далее мы создаем код для операций валидации формы настроек поля и сохранения.
case 'validate': if (!preg_match('@\-[0-9]*:[\+|\-][0-9]*@', $field['financial_table_year_range'])) { form_set_error('financial_table_year_range', t('Years back and forward must be in the format -9:+9.')); } break; case 'save': return array('financial_table_year_range');
Мы используем для этого операцию validate в hook_field(). В массиве $items мы получаем все строки данных, которые ввел пользователь на форме. Логика простая — если хотя бы один из элементов строки не заполнен, то нужно выдать сообщение об ошибке.
/** * Implementation of CCK's hook_field(). * http://drupal.org/node/342996 */ function financial_table_field($op, $node, $field, &$items, $teaser, $page) { switch ($op) { case 'validate': $fields_names = array('year', 'revenues', 'expenditures', 'net'); foreach ($items as $delta => $item) { $row_has_been_filled = FALSE; foreach ($fields_names as $key => $name) { $empty_field = ''; $element_value = $item['financial_table_' . $name]; if ($key == 0) { if (empty($element_value)) { $empty_field = $name; } else { $row_has_been_filled = TRUE; } } elseif (empty($element_value) && $row_has_been_filled) { if ($name != 'year') { if (strlen($element_value) < 1 ) { $empty_field = $name; } } else { $empty_field = $name; } } if ($empty_field && $key != 0) { $error_element = isset($item['_error_element']['financial_table_' . $empty_field]) ? $item['_error_element']['financial_table_' . $empty_field] : ''; if (is_array($item) && isset($item['_error_element']['financial_table_' . $empty_field])) unset($item['_error_element']['financial_table_' . $empty_field]); form_set_error($error_element, t('%name: you should fill the row. Not all values were set', array('%name' => t($field['widget']['label'])))); break; } } } return $items; } }
Мы определили CCK-поле. Его добавление на странице управления полями выглядит так:

Нам ещё нужно создать виджет (widget — элемент формы) для нашего поля.
Особенность нашего виджета в том, что нас поле имеет несколько элементов, а не один. Несколько элементов также имеют поля Money и Location. Используем hook_widget():
/** * Implementation of CCK's hook_widget(). * http://drupal.org/node/344142 */ function financial_table_widget(&$form, &$form_state, $field, $items, $delta = 0) { //$field['columns'] содержит имена всех элементов $field_names = array_keys($field['columns']); $widget = $field['widget']; //Получаем размеры всех элементов $size = _financial_table_get_widget_sizes($widget); //Преобразуем массив в переменные extract($size); //Создаем элемент формы для года: $element['financial_table_year'] = array( '#type' => 'select', // Получаем массив лет с учётом диапазона указанного в настройках поля: '#options' => _financial_table_select_options($field['financial_table_year_range']), '#default_value' => isset($items[$delta][$field_names[0]]) ? $items[$delta][$field_names[0]] : '', //Эти аттрибуты помогают расположить элементы не вертикально, а горизонтально: '#attributes' => array('class' => 'financial_table_year', 'style' => 'width:'. $year_size .'px;float:left;margin:0 10px 0 0;'), ); $element['financial_table_revenues'] = array( '#type' => 'textfield', '#default_value' => isset($items[$delta][$field_names[1]]) ? $items[$delta][$field_names[1]] : '', '#size' => 10, '#maxlength' => 12, '#attributes' => array('class' => 'financial_table_revenues', 'style' => 'width:'. $revenues_size .'px;float:left;margin:0 10px 0 10px;'), '#element_validate' => array('_financial_table_validate_demical_positive'), ); $element['financial_table_expenditures'] = array( '#type' => 'textfield', '#default_value' => isset($items[$delta][$field_names[2]]) ? $items[$delta][$field_names[2]] : '', '#size' => 10, '#maxlength' => 12, '#attributes' => array('class' => 'financial_table_expenditures', 'style' => 'width:'. $expenditures_size .'px;float:left;margin:0 10px 0 10px;'), //Добавляем функцию валидатор (приведена ниже): '#element_validate' => array('_financial_table_validate_demical_positive'), ); $element['financial_table_net'] = array( '#type' => 'textfield', '#default_value' => isset($items[$delta][$field_names[3]]) ? $items[$delta][$field_names[3]] : '', '#size' => 10, '#maxlength' => 12, '#attributes' => array('class' => 'financial_table_net', 'style' => 'width:'. $net_size .'px;margin:0 10px 0 10px;'), //Добавляем функцию валидатор (приведена ниже): '#element_validate' => array('_financial_table_validate_demical'), ); if (empty($form['#parents'])) { $form['#parents'] = array(); } //Заполняем специальный элемент массива $element['_error_element'] //значениями для того, чтобы можно было подстветить элемент при ошибке $fields_names = array('year', 'revenues', 'expenditures', 'net'); foreach ($fields_names as $key => $name) { $element['_error_element']['financial_table_' . $name] = array( '#type' => 'value', '#value' => implode('][', array_merge(array($field[$name], $delta, 'financial_table_' . $name))), ); } //Задаем функцию валидации для виджета if (empty($element['#element_validate'])) { $element['#element_validate'] = array(); } $element['#element_validate'][] = 'financial_table_widget_validate'; return $element; }
Весь этот код нужен для того, чтобы построить псевдо-таблицу. «Псевдо» потому, что у нас все элементы будут в одной ячейке таблицы, чтобы не нарушать логику модуля CCK для работы с сортировкой полей с неограниченным количеством значений.
Для валидации элементов используются 2 функции, назначение которых очевидно:
/** * Helper form element validator : integer > 0. */ function _financial_table_validate_demical_positive($element, &$form_state) { $element_key = _financial_table_get_element_key($element); $element_delta = _financial_table_get_element_delta($element); $value = $element['#value']; $start = $value; $value = preg_replace('@[^0-9\.]@', '', $value); if ($start != $value) { form_error($element, t('%name in %row row could be positive or zero. Only numbers and decimals are allowed.', array('%name' => $element_key, '%row' => $element_delta))); } } /** * Helper form element validator : integer. */ function _financial_table_validate_demical($element, &$form_state) { $element_key = _financial_table_get_element_key($element); $element_delta = _financial_table_get_element_delta($element); $value = $element['#value']; $start = $value; $value = preg_replace('@[^-0-9\.]@', '', $value); if ($start != $value) { form_error($element, t('Only numbers and decimals are allowed in %name in %row row.', array('%name' => $element_key, '%row' => $element_delta))); } }
Функция, которая получает размеры всех элементов из настроек виджета:
function _financial_table_get_widget_sizes($widget) { $size['year_size'] = ((isset($widget['year_size']) && is_numeric($widget['year_size'])) ? $widget['year_size'] : '100'); $size['revenues_size'] = ((isset($widget['revenues_size']) && is_numeric($widget['revenues_size'])) ? $widget['revenues_size'] : '100'); $size['expenditures_size'] = ((isset($widget['expenditures_size']) && is_numeric($widget['expenditures_size'])) ? $widget['expenditures_size'] : '100'); $size['net_size'] = ((isset($widget['net_size']) && is_numeric($widget['net_size'])) ? $widget['net_size'] : '100'); return $size; }
Для того, чтобы получить эти размеры для каждого из элементов, — нам их нужно задать.
Для задания размеров элементов виджета мы используем hook_widget_settings(), в котором на операции form мы создаем форму, а на операции save указываем какие переменные нужно сохранить:
/** * Implementation of CCK hook_widget_settings(). * http://drupal.org/node/354369 */ function financial_table_widget_settings($op, $widget) { //Получаем размеры всех элементов виджета: $size = _financial_table_get_widget_sizes($widget); extract($size); switch ($op) { case 'form': $form = array(); $form['size'] = array( '#type' => 'fieldset', '#title' => t('Size of fields'), ); $form['size']['year_size'] = array( '#type' => 'textfield', '#title' => t('Size of Year field (px)'), '#default_value' => $year_size, //Добавляем валидацию для этого элемента формы: '#element_validate' => array('_element_validate_integer_positive'), '#required' => TRUE, ); $form['size']['revenues_size'] = array( '#type' => 'textfield', '#title' => t('Size of Revenues field (px)'), '#default_value' => $revenues_size, '#element_validate' => array('_element_validate_integer_positive'), '#required' => TRUE, ); $form['size']['expenditures_size'] = array( '#type' => 'textfield', '#title' => t('Size of Expenditures field (px)'), '#default_value' => $expenditures_size, '#element_validate' => array('_element_validate_integer_positive'), '#required' => TRUE, ); $form['size']['net_size'] = array( '#type' => 'textfield', '#title' => t('Size of Net field (px)'), '#default_value' => $net_size, '#element_validate' => array('_element_validate_integer_positive'), '#required' => TRUE, ); return $form; case 'save': return array('year_size', 'revenues_size', 'expenditures_size', 'net_size'); } }
Для каждого элемента мы указываем callback-функцию-валидатор: _element_validate_integer_positive() — это стандартный валидатор.
На странице настроек поля эта форма выглядит так:

С настройкой виджета мы тоже закончили.
Виджет должен выглядеть вот так:

Но он выглядит так:

Для того, чтобы темизировать вывод поля нам нужно создать в файле template.php текущей темы функцию THEME_NAME_content_multiple_values($element).
В нашем случае она будет выглядеть так:
/** * Overriding of content module default rendering for multiple values * Used for node creation form. * Combine multiple values into a table with drag-n-drop reordering. */ function THEME_NAME_content_multiple_values($element) { $field_name = $element['#field_name']; $field = content_fields($field_name); $output = ''; // here is the definition of fields don't need to be reordered manually $fields_no_manual_reordering = array('field_employment', 'field_education'); $noReordering = in_array($field_name, $fields_no_manual_reordering); if ($field['multiple'] >= 1) { $table_id = $element['#field_name'] .'_values'; $order_class = $element['#field_name'] .'-delta-order'; $required = !empty($element['#required']) ? '<span class="form-required" title="'. t('This field is required.') .'">*</span>' : ''; if ($field['type'] == 'financial_table') { if(function_exists('financial_table_get_widget_header')) { //Заменяем стандартную шапку таблицы своей, где указаны названия наших колонок //Количество колонок при этом в таблице не меняется $header = financial_table_get_widget_header($field, $required); } } else { $header = array( array( 'data' => t('!title: !required', array('!title' => $element['#title'], '!required' => $required)), 'colspan' => 2 ), $noReordering ? null : t('Order'), ); } $rows = array(); // Sort items according to '_weight' (needed when the form comes back after // preview or failed validation) $items = array(); foreach (element_children($element) as $key) { if ($key !== $element['#field_name'] .'_add_more') { $items[] = &$element[$key]; } } usort($items, '_content_sort_items_value_helper'); // Add the items as table rows. foreach ($items as $key => $item) { $item['_weight']['#attributes']['class'] = $order_class; $delta_element = drupal_render($item['_weight']); $cells = array( array('data' => '', 'class' => 'content-multiple-drag'), drupal_render($item), $noReordering ? null : array('data' => $delta_element, 'class' => 'delta-order'), ); $rows[] = array( 'data' => $cells, 'class' => 'draggable', ); } $output .= theme('table', $header, $rows, array('id'=> $table_id, 'class' => 'content-multiple-table') ); $output .= $element['#description'] ? '<div class="description">'. $element['#description'] .'</div>' : ''; $output .= drupal_render($element[$element['#field_name'] .'_add_more']); if (!$noReordering) { drupal_add_tabledrag($table_id, 'order', 'sibling', $order_class); } } else { foreach (element_children($element) as $key) { $output .= drupal_render($element[$key]); } } if ($output) { if (($field['type'] == 'financial_table') && $field['display_settings ']['label']['format'] == 'above') { //Выводим лэйбл только, если его вывод задан в настроках поля $output = '<label for="' . $table_id .'">' . $element['#title'] . ': </label>' . $output; } } return $output; }
После добавления этих изменений внешний вид нашего поля будет такой как нам нужно — с шапкой и в виде таблицы.
Форматтер отвечает за отображение поля на странице ноды. У нас был определён стандартный форматтер для поля:
/* * Theme function for 'plain' example field formatter. * Default formatter is replaced in content-field.tpl.php */ function theme_financial_table_formatter_default($element) { $output .= strip_tags($element['#item']['financial_table_year']) . ' | '; $output .= strip_tags($element['#item']['financial_table_revenues']) . ' | '; $output .= strip_tags($element['#item']['financial_table_expenditures']) . ' | '; $output .= strip_tags($element['#item']['financial_table_net']) . ' | '; return $output; }
Он будет может выводить данные и будет это выглядеть вот так:

Этот форматтер можно использовать для тестирования. После того, как мы приведём внешний вид в порядок он больше нам не понадобится.
Любой форматтер поля, какой бы мы ни создали, оперирует только с элементами одного ряда,— а нам нужно построить таблицу, которая содержит все ряды. Для этого в теме переопределим вывод всего этого поля. Таким образом форматтер по умолчанию не будет использоваться, а будет выводится красивая таблица с данными.
В файле template.php текущей темы нужно добавить функцию THEME_NAME_preprocess_content_field (&$vars). И добавить в неё код:
function green_evolution_preprocess_content_field (&$vars) { if ($vars['field']['type'] == 'financial_table') { if (!$vars['field_empty']) { $header = array(t('Year'), t('Revenues'), t('Expenditures'), t('Net')); $rows = array(); foreach ($vars['items'] as $delta => $item) { if (!$item['empty']) { if(isset($item['financial_table_year']) && isset($item['financial_table_revenues']) && isset($item['financial_table_expenditures']) && isset($item['financial_table_net'])) { $rows[] = array( $item['financial_table_year'], $item['financial_table_revenues'], $item['financial_table_expenditures'], $item['financial_table_net'], ); } } } if(count($rows) > 0) { $vars['field']['rendered_table'] = theme('table', $header, $rows, array('id' => 'field-' . $field_name_css, 'class' => $field['type'])); } } } }
Этот код обрабатывает данные поля из всех строк, форматирует таблицу, которую мы выведем в шаблоне. Для этого нам понадобится дополнительный файл шаблона в теме. Создаём файл content-field.tpl.php в папке текущей темы с таким контентом (обычно его там нет):
<?php if ($field['type'] == 'financial_table') { if ($field['rendered_table']) { ?> <div class="field field-type-<?php print $field_type_css ?> field-<?php print $field_name_css ?>"> <?php if ($label_display == 'above') : ?> <div class="field-label"><?php print t($label) ?>: </div> <?php endif;?> <div class="field-items"><?php echo $field['rendered_table'] ?></div> </div> <?php } } else { ?> <?php if (!$field_empty) : ?> <div class="field field-type-<?php print $field_type_css ?> field-<?php print $field_name_css ?>"> <?php if ($label_display == 'above') : ?> <div class="field-label"><?php print t($label) ?>: </div> <?php endif;?> <div class="field-items"> <?php $count = 1; foreach ($items as $delta => $item) : if (!$item['empty']) : ?> <div class="field-item <?php print ($count % 2 ? 'odd' : 'even') ?>"> <?php if ($label_display == 'inline') { ?> <div class="field-label-inline<?php print($delta ? '' : '-first')?>"> <?php print t($label) ?>: </div> <?php } ?> <?php print $item['view'] ?> </div> <?php $count++; endif; endforeach; ?> </div> </div> <?php endif; }
Если поле имеет тип financial_table, то будет выводится наша таблица, которую мы отрендерили раньше в файле template.php. Во всех остальных случаях используется стандартный вывод. После сохранения кода мы можем видеть такой результат:

Напомню, что раньше мы сделали виджет формы, который выглядит так:
