From dbb9640a27798acc0ff75355fd9c6199336f307a Mon Sep 17 00:00:00 2001 From: Earl Miles <merlin@logrus.com> Date: Sat, 6 Dec 2008 02:13:48 +0000 Subject: [PATCH] A tool to facilitate multi-step wizards. I do not think this is quite finished yet but it handles the simple case pretty well. --- css/wizard.css | 9 ++ delegator/delegator.admin.inc | 5 +- delegator/delegator.install | 64 +++++++++ delegator/delegator.module | 4 + help/wizard.html | 28 ++++ includes/export.inc | 33 ++++- includes/form.inc | 27 +++- includes/wizard.inc | 237 ++++++++++++++++++++++++++++++++++ includes/wizard.theme.inc | 24 ++++ 9 files changed, 423 insertions(+), 8 deletions(-) create mode 100644 css/wizard.css create mode 100644 help/wizard.html create mode 100644 includes/wizard.inc create mode 100644 includes/wizard.theme.inc diff --git a/css/wizard.css b/css/wizard.css new file mode 100644 index 00000000..80adc7ff --- /dev/null +++ b/css/wizard.css @@ -0,0 +1,9 @@ +/* $Id$ */ + +.wizard-trail { + font-size: 120%; +} + +.wizard-trail-current { + font-weight: bold; +} diff --git a/delegator/delegator.admin.inc b/delegator/delegator.admin.inc index 473f6a1d..37546c71 100644 --- a/delegator/delegator.admin.inc +++ b/delegator/delegator.admin.inc @@ -1091,7 +1091,7 @@ function delegator_admin_edit_task_handler(&$form_state) { $form['buttons'] = array( '#prefix' => '<div class="clear-block">', '#suffix' => '</div>', - '#weight' => 1000 + '#weight' => 1000, ); if (isset($form_state['next'])) { @@ -1101,6 +1101,7 @@ function delegator_admin_edit_task_handler(&$form_state) { '#next' => $form_state['next'], '#validate' => $validate, '#submit' => $submit, + '#weight' => -1000, ); } @@ -1296,7 +1297,7 @@ function delegator_admin_import_task_handler_validate($form, &$form_state) { $handler->export_type = EXPORT_IN_DATABASE; if (isset($cache->handlers[$handler->name])) { - drupal_set_message(t('Warning: The handler you are important already exists and is overwriting an existing handler. If this is not what you intend, you may Cancel this. You should then modify the <code>$handler->name</code> field of your import to have a unique name.'), 'warning'); + drupal_set_message(t('Warning: The handler you are importing already exists and this operation will overwrite an existing handler. If this is not what you intend, you may Cancel this. You should then modify the <code>$handler->name</code> field of your import to have a unique name.'), 'warning'); $old_handler = delegator_admin_find_handler($handler->name, $cache); $handler->export_type = $old_handler->export_type | EXPORT_IN_DATABASE; diff --git a/delegator/delegator.install b/delegator/delegator.install index 4b0ba30c..9d031b9e 100644 --- a/delegator/delegator.install +++ b/delegator/delegator.install @@ -87,6 +87,70 @@ function delegator_schema_1() { 'weights' => array('name', 'weight'), ), ); + + $schema['delegator_pages'] = array( + 'description' => t('Contains page subtasks for implementing pages with arbitrary tasks.'), + 'fields' => array( + 'pid' => array( + 'type' => 'serial', + 'not null' => TRUE, + 'description' => t('Primary ID field for the table. Not used for anything except internal lookups.'), + 'no export' => TRUE, + ), + 'name' => array( + 'type' => 'varchar', + 'length' => '255', + 'description' => t('Unique ID for this subtask. Used to identify it programmatically.'), + ), + 'admin_title' => array( + 'type' => 'varchar', + 'length' => '255', + 'description' => t('Human readable title for this page subtask.'), + ), + 'path' => array( + 'type' => 'varchar', + 'length' => '255', + 'description' => t('The menu path that will invoke this task.'), + ), + 'access' => array( + 'type' => 'text', + 'size' => 'big', + 'description' => t('Access configuration for this path.'), + 'not null' => TRUE, + 'default' => '', + 'serialize' => TRUE, + ), + 'menu' => array( + 'type' => 'text', + 'size' => 'big', + 'description' => t('Serialized configuration of Drupal menu visibility settings for this item.'), + 'not null' => TRUE, + 'default' => '', + 'serialize' => TRUE, + ), + 'parent_menu' => array( + 'type' => 'text', + 'size' => 'big', + 'description' => t('Serialized configuration of Drupal parent menu visibility settings for this item.'), + 'not null' => TRUE, + 'default' => '', + 'serialize' => TRUE, + ), + 'arguments' => array( + 'type' => 'text', + 'size' => 'big', + 'description' => t('Configuration of arguments for this menu item.'), + 'not null' => TRUE, + 'default' => '', + 'serialize' => TRUE, + ), + ), + 'primary key' => array('pid'), + 'unique keys' => array( + 'name' => array('name'), + ), + ); + return $schema; } diff --git a/delegator/delegator.module b/delegator/delegator.module index eb215f90..942c7b6c 100644 --- a/delegator/delegator.module +++ b/delegator/delegator.module @@ -318,6 +318,10 @@ function delegator_delete_task_handler($handler) { db_query("DELETE FROM {delegator_weights} WHERE name = '%s'", $handler->name); } +/** + * Export a task handler into code suitable for import or use as a default + * task handler. + */ function delegator_export_task_handler($handler, $indent = '') { ctools_include('export'); ctools_include('plugins'); diff --git a/help/wizard.html b/help/wizard.html new file mode 100644 index 00000000..ebe7a709 --- /dev/null +++ b/help/wizard.html @@ -0,0 +1,28 @@ +<!-- $Id$ --> +form_info => array( + 'id' => An id for this multistep. Will be used for things like trail theming. + + 'path' => /path/to/form/%step, + 'return path' => Where to go when exiting the wizard. Required [maybe] + + 'show trail' => (default false), + 'show back' => (default false) -- show a back button + 'show return' => (default false) show a return button + + // callbacks + 'finish callback', + 'cancel callback', + 'return callback', + 'next callback' + + // form info + 'order' => array( + 'id' => t('title'), + ), + 'forms' => array( + 'form_id' => array( + 'include' => .., + 'form id' => .., + ), + ), + diff --git a/includes/export.inc b/includes/export.inc index 93801488..f2f7b22b 100644 --- a/includes/export.inc +++ b/includes/export.inc @@ -416,4 +416,35 @@ function ctools_export_form(&$form_state, $code, $title = '') { ); return $form; -} \ No newline at end of file +} + +/** + * Create a new object based upon schema values. + * + * Because 'default' has ambiguous meaning on some fields, we will actually + * use 'object default' to fill in default values if default is not set + * That's a little safer to use as it won't cause weird database default situations. + */ +function ctools_export_new_object($table) { + $schema = ctools_export_get_schema($table); + $export = $schema['export']; + + $object = new stdClass; + foreach ($schema['fields'] as $field => $info) { + if (isset($info['object default'])) { + $object->$field = $info['object default']; + } + else if (isset($info['default'])) { + $object->$field = $info['default']; + } + else { + $object->$field = NULL; + } + } + + // Set some defaults so this data always exists. + + $object->export_type = EXPORT_IN_DATABASE; + $object->type = t('Local'); + return $object; +} diff --git a/includes/form.inc b/includes/form.inc index 48be9f14..4ac55576 100644 --- a/includes/form.inc +++ b/includes/form.inc @@ -53,15 +53,32 @@ function ctools_build_form($form_id, &$form_state) { // to build it from scratch. if (!isset($form)) { $form_state['post'] = $form_state['input']; - // Use a copy of the function's arguments for manipulation - $args_temp = $args; - $args_temp[0] = &$form_state; - array_unshift($args_temp, $form_id); + // This allows us to do some interesting form embedding stuff without + // messing up the form IDs too badly. + if (isset($form_state['wrapper callback']) && function_exists($form_state['wrapper callback'])) { + // If there is a wrapper callback, we do not use drupal_retrieve_form. + // Instead, we call $form_id builder function directly. This means the args + // are *different* for forms used this way, which may not be ideal but + // is necessary right now. + $form = array(); + $form_state['wrapper callback']($form, $form_state); + if (function_exists($form_id)) { + $form_id($form, $form_state); + } + } + else { + // Use a copy of the function's arguments for manipulation + $args_temp = $args; + $args_temp[0] = &$form_state; + array_unshift($args_temp, $form_id); + + $form = call_user_func_array('drupal_retrieve_form', $args_temp); + } - $form = call_user_func_array('drupal_retrieve_form', $args_temp); $form_build_id = 'form-' . md5(mt_rand()); $form['#build_id'] = $form_build_id; + if ($form_state['method'] == 'get' && !isset($form['#method'])) { $form['#method'] = 'get'; } diff --git a/includes/wizard.inc b/includes/wizard.inc new file mode 100644 index 00000000..939ffd3b --- /dev/null +++ b/includes/wizard.inc @@ -0,0 +1,237 @@ +<?php +// $Id$ + +/** + * @file + * CTools' multi-step form wizard tool. + * + * This tool enables the creation of multi-step forms that go from one + * form to another. The forms themselves can allow branching if they + * like, and there are a number of configurable options to how + * the wizard operates. + * + * The wizard can also be friendly to ajax forms, such as when used + * with the modal tool. + * + * TODO: should the wizard also perform object caching? We'll see + * as we develop this if it should happen within the wizard + * or outside the wizard. + */ + +/** + * Display a multi-step form. + * + * Aside from the addition of the $form_info which contains an array of + * information and configuration so the multi-step wizard can do its thing, + * this function works a lot like ctools_build_form. + * + * Remember that the form builders for this form will receive + * &$form, &$form_state, NOT just &$form_state and no additional args. + * + * Do NOT use #required => TRUE with these forms as that validation + * cannot be skipped for the CANCEL button. + * + * @param $form_info + * An array of form info. @todo document the array. + * @param $step + * The current form step. + * @param &$form_state + * The form state array; this is a reference so the caller can get back + * whatever information the form(s) involved left for it. + */ +function ctools_wizard_multistep_form($form_info, $step, &$form_state) { + $form_state['step'] = $step; + $form_state['form_info'] = $form_info; + + // Ensure we have form information for the current step. + if (!isset($form_info['forms'][$step])) { + return; + } + + // Ensure that whatever include file(s) were requested by the form info are + // actually included. + $info = $form_info['forms'][$step]; + + if (!empty($info['include'])) { + if (is_array($info['include'])) { + foreach ($info['include'] as $file) { + require_once './' . $file; + } + } + else { + require_once './' . $info['include']; + } + } + + // This tells ctools_build_form to apply our wrapper to the form. It + // will give it buttons and the like. + $form_state['wrapper callback'] = 'ctools_wizard_wrapper'; + $form_state['re_render'] = FALSE; + $form_state['no_redirect'] = TRUE; + + ctools_include('form'); + $output = ctools_build_form($info['form id'], $form_state); + + if (!$output) { + // We use the plugins get_function format because it's powerful and + // not limited to just functions. + ctools_include('plugins'); + + if (isset($form_state['clicked_button']['#wizard type'])) { + $type = $form_state['clicked_button']['#wizard type']; + // If the finish button was clicked, call the finish callback. + if ($function = ctools_plugin_get_function($form_info, "$type callback")) { + $function($form_state); + } + } + + // redirect, if one is set. + drupal_redirect_form(array(), $form_state['redirect']); + } + + return $output; +} + +/** + * Provide a wrapper around another form for adding multi-step information. + */ +function ctools_wizard_wrapper(&$form, &$form_state) { + $form_info = &$form_state['form_info']; + $info = $form_info['forms'][$form_state['step']]; + + // Determine the next form from this step. + // Create a form trail if we're supposed to have one. + $trail = array(); + $previous = TRUE; + foreach ($form_info['order'] as $id => $title) { + if ($id == $form_state['step']) { + $previous = FALSE; + $class = 'wizard-trail-current'; + } + elseif ($previous) { + $not_first = TRUE; + $class = 'wizard-trail-previous'; + $form_state['previous'] = $id; + } + else { + $class = 'wizard-trail-next'; + if (!isset($form_state['next'])) { + $form_state['next'] = $id; + } + if (empty($form_info['show trail'])) { + break; + } + } + + if (!empty($form_info['show trail'])) { + $trail[] = '<span class="' . $class . '">' . $title . '</span>'; + } + } + + // Display the trail if instructed to do so. + if (!empty($form_info['show trail'])) { + ctools_add_css('wizard'); +// drupal_add_css(drupal_get_path('module', 'delegator') . '/css/task-handlers.css'); + $form['ctools_trail'] = array( + '#value' => theme(array('ctools_wizard_trail__' . $form_info['id'], 'ctools_wizard_trail'), $trail), + '#weight' => -1000, + ); + } + + // Ensure buttons stay on the bottom. + $form['buttons'] = array( + '#prefix' => '<div class="clear-block">', + '#suffix' => '</div>', + '#weight' => 1000, + ); + + if (!empty($form_info['show back']) && isset($form_state['previous'])) { + $form['buttons']['previous'] = array( + '#type' => 'submit', + '#value' => t('Back'), + '#next' => $form_state['previous'], + '#wizard type' => 'next', + '#weight' => -2000, + // hardcode the submit so that it doesn't try to save data. + '#submit' => array('ctools_wizard_submit'), + ); + } + + // If there is a next form, place the next button. + if (isset($form_state['next'])) { + $form['buttons']['next'] = array( + '#type' => 'submit', + '#value' => t('Continue'), + '#next' => $form_state['next'], + '#wizard type' => 'next', + '#weight' => -1000, + ); + } + + // There are two ways the return button can appear. If this is not the + // end of the form list (i.e, there is a next) then it's "update and return" + // to be clear. If this is the end of the path and there is no next, we + // call it 'Finish'. + + // Even if there is no direct return path (some forms may not want you + // leaving in the middle) the final button is always a Finish and it does + // whatever the return action is. + if (!empty($form_info['show return']) && !empty($form_state['next'])) { + $form['buttons']['return'] = array( + '#type' => 'submit', + '#value' => t('Update and return'), + '#wizard type' => 'return', + ); + } + else if (empty($form_state['next'])) { + $form['buttons']['return'] = array( + '#type' => 'submit', + '#value' => t('Finish'), + '#wizard type' => 'finish', + ); + } + + // If we are allowed to cancel, place a cancel button. + if (isset($form_info['cancel path'])) { + $form['buttons']['cancel'] = array( + '#type' => 'submit', + '#value' => t('Cancel'), + '#submit' => array('ctools_wizard_cancel'), + '#wizard type' => 'cancel', + ); + } + + // Set up our submit handler after theirs. Since putting something here will + // skip Drupal's autodetect, we autodetect for it. + + // We make sure ours is after theirs so that they get to change #next if + // the want to. + $form['#submit'] = array(); + if (function_exists($info['form id'] . '_submit')) { + $form['#submit'][] = $info['form id'] . '_submit'; + } + $form['#submit'][] = 'ctools_wizard_submit'; +} + +/** + * On a submit, go to the next form. + */ +function ctools_wizard_submit(&$form, &$form_state) { + if (isset($form_state['clicked_button']['#wizard type'])) { + $type = $form_state['clicked_button']['#wizard type']; + if ($type == 'return' || $type == 'finish') { + $form_state['redirect'] = $form_state['form_info']['return path']; + // Do we need to do something here or just let it go? + } + else { + $form_state['redirect'] = ctools_wizard_get_path($form_state['form_info'], $form_state['clicked_button']['#next']); + } + } +} + +/** + * Create a path from the form info and a given step. + */ +function ctools_wizard_get_path($form_info, $step) { + return str_replace('%step', $step, $form_info['path']); +} diff --git a/includes/wizard.theme.inc b/includes/wizard.theme.inc new file mode 100644 index 00000000..643b05ef --- /dev/null +++ b/includes/wizard.theme.inc @@ -0,0 +1,24 @@ +<?php +// $Id$ +/** + * @file + * Themable for the wizard tool. + */ + +function ctools_wizard_theme(&$theme) { + $theme['ctools_wizard_trail'] = array( + 'arguments' => array('trail'), + 'file' => 'includes/wizard.theme.inc', + ); +} + +/** + * Themable display of the 'breadcrumb' trail to show the order of the + * forms. + */ +function theme_ctools_wizard_trail($trail) { + if (!empty($trail)) { + return '<div class="wizard-trail">' . implode(' » ', $trail) . '</div>'; + } +} + -- GitLab