You are here

commerce_product_bundle.module in Commerce Product Bundle 7

Same filename and directory in other branches
  1. 8 commerce_product_bundle.module
  2. 7.2 commerce_product_bundle.module

Allows the bundling of products in Drupal Commerce.

File

commerce_product_bundle.module
View source
<?php

/**
 * @file
 * Allows the bundling of products in Drupal Commerce.
 */

/**
 * Implements hook_field_formatter_info().
 *
 * Provide an option for the user to add this as a product kit item.
 */
function commerce_product_bundle_field_formatter_info() {
  return array(
    'commerce_bundle_product_add_to_cart_form' => array(
      'label' => t('Product Bundle: Add to cart form'),
      'description' => t('Render the product bundle add to cart form. This formatter should be applied to the sub products of the bundle. You must not set this on a display node.'),
      'field types' => array(
        'commerce_product_reference',
      ),
      'settings' => commerce_product_bundle_field_formatter_default_settings(),
    ),
  );
}

/**
 * Returns the default settings for the form display.
 */
function commerce_product_bundle_field_formatter_default_settings() {
  return array(
    'show_quantity' => FALSE,
    'default_quantity' => 1,
    'show_fieldset' => TRUE,
    'bundle_type' => 'single',
  );
}

/**
 * Implements hook_field_formatter_settings_form().
 */
function commerce_product_bundle_field_formatter_settings_form($field, $instance, $view_mode, $form, &$form_state) {
  $display = $instance['display'][$view_mode];
  $settings = $display['settings'] + commerce_product_bundle_field_formatter_default_settings();
  $element = array();
  if ($display['type'] == 'commerce_bundle_product_add_to_cart_form') {
    $element['show_quantity'] = array(
      '#type' => 'checkbox',
      '#title' => t('Display a textfield quantity widget on the add to cart form for this bundle.'),
      '#default_value' => $settings['show_quantity'],
    );
    $element['default_quantity'] = array(
      '#type' => 'textfield',
      '#title' => t('Default quantity'),
      '#default_value' => $settings['default_quantity'] <= 0 ? 1 : $settings['default_quantity'],
      '#element_validate' => array(
        'commerce_cart_field_formatter_settings_form_quantity_validate',
      ),
      '#size' => 16,
    );
    $element['show_fieldset'] = array(
      '#type' => 'checkbox',
      '#title' => t('Display the options in a fieldset.'),
      '#default_value' => $settings['show_fieldset'],
    );
    $element['bundle_type'] = array(
      '#type' => 'select',
      '#title' => t('Defines how referenced products are handled.'),
      '#default_value' => $settings['bundle_type'],
      '#options' => array(
        'single' => 'Single Select Box',
        'multiple' => 'Multiple Select Box',
        'hidden' => 'Hidden, add all products',
      ),
    );
  }
  return $element;
}

/**
 * Implements hook_field_formatter_settings_summary().
 */
function commerce_product_bundle_field_formatter_settings_summary($field, $instance, $view_mode) {
  $display = $instance['display'][$view_mode];
  $settings = $display['settings'] + commerce_product_bundle_field_formatter_default_settings();
  $summary = array();
  if ($display['type'] == 'commerce_bundle_product_add_to_cart_form') {
    $summary = array(
      t('Quantity widget: !status', array(
        '!status' => $settings['show_quantity'] ? t('Enabled') : t('Disabled'),
      )),
      t('Default quantity: @quantity', array(
        '@quantity' => $settings['default_quantity'],
      )),
      t('Fieldset: !status', array(
        '!status' => $settings['show_fieldset'] ? t('Enabled') : t('Disabled'),
      )),
      t('Bundle type: !type', array(
        '!type' => $settings['bundle_type'],
      )),
    );
  }
  return implode('<br />', $summary);
}

/**
 * Implements hook_field_formatter_view().
 *
 * @ToDo: 'This code do nothing. I'm unsure if we can use the hook for something
 *  useful. Have a closer look, do something with it or remove it.'
 */
function commerce_product_bundle_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
  $result = array();

  // Modify the product reference form view:
  if ($display['type'] == 'commerce_bundle_product_add_to_cart_form') {
    $settings = $display['settings'];
  }
  return $result;
}

/**
 * Implements of hook_bundle_form_alter().
 *
 * Here we modify the add to cart form.
 */
function commerce_product_bundle_form_alter(&$form, &$form_state, $form_id) {
  if (strstr($form_id, 'commerce_cart_add_to_cart_form')) {
    if (isset($form_state['default_product'])) {
      $current_product = $form_state['default_product'];
    }
    elseif (isset($form_state['products'])) {
      $current_product = reset($form_state['products']);
      $form_state['default_product'] = $current_product;
    }
    else {
      return;
    }
    $parent_product_id = $current_product->product_id;
    foreach ($current_product as $field_name => $field) {

      // If the field is empty, we have nothing to do here, so skip it.
      if (empty($field)) {
        continue;
      }
      $field_info = field_info_field($field_name);
      $type = $field_info['type'];
      if ($type == 'commerce_product_reference') {
        $field_instance = field_read_instance('commerce_product', $field_name, $current_product->type);

        // Check if the field is enabled for sub products display:
        if (isset($field_instance['display']['default']['type']) && $field_instance['display']['default']['type'] == 'commerce_bundle_product_add_to_cart_form') {
          $lang_code = field_language('commerce_product', $current_product, $field_name);
          $product_ids = array();
          foreach ($field[$lang_code] as $product) {
            $product_ids[] = $product['product_id'];
          }
          $context = isset($form_state['build_info']['args'][2]) ? $form_state['build_info']['args'][2] : array();
          commerce_product_bundle_add_to_cart_form($form, $form_state, $parent_product_id, $product_ids, $field_instance, $field_instance['settings'], $context);
        }
      }
    }
  }
  elseif (strpos($form_id, 'commerce_line_item_views_form_commerce_cart_form_') === 0) {

    // Change any Delete buttons to say Remove.
    if (!empty($form['edit_delete'])) {
      foreach (element_children($form['edit_delete']) as $line_item_id) {
        $form['edit_delete'][$line_item_id]['#submit'][] = 'commerce_product_bundle_line_item_delete_form_submit';
      }
    }
  }
}

/**
 * Submit function for the bundle line item delete form.
 *
 * @ToDo Delete the line items of the sublineitems after deleting the parent
 * line item.
 * @see http://drupal.org/node/1378072
 */
function commerce_product_bundle_line_item_delete_form_submit($form, &$form_state) {
}

/**
 * Builds the add to cart form, for product bundles.
 *
 * Therefore it takes the add-to-cart-forms of each of the subproducts and
 * put them together in one form.
 *
 * @param array &$form
 *   The standard commerce add to cart form for the bundle product.
 * @param array &$form_state
 *   The form_state array for the bundle product.
 * @param int $parent_product_id
 *   The ID of the bundle product.
 * @param array $product_ids
 *   The ID's of the subproducts, aka the products which are part of the bundle.
 * @param array $field_instance
 *   Document the field instance.
 * @param array $settings
 *   Settings for this instance of the field.
 * @param array $context
 *   Document Context array.
 *
 * @return array
 *   The add to cart form with the bundle product.
 *
 * @ToDo This are tons of duplicated code from 'commerce_cart_add_to_cart_form'.
 *    We should finde a way to reduce code duplication.
 */
function commerce_product_bundle_add_to_cart_form(&$form, &$form_state, $parent_product_id, $product_ids, $field_instance, $settings = array(), $context = array()) {
  global $user;

  // Store the context in the form state for use during AJAX refreshes.
  $form_state['context'] = $context;

  // Get display settings.
  if (isset($field_instance['display']['node_full'])) {
    $display = $field_instance['display']['node_full'];
  }
  else {
    if (isset($field_instance['display']['default'])) {
      $display = $field_instance['display']['default'];
    }
    else {
      $display = array();
    }
  }

  // Skip if we are not using the commerce_bundle_product_add_to_cart_form.
  if (!isset($display['type']) || $display['type'] != 'commerce_bundle_product_add_to_cart_form') {
    return array();
  }

  // Load all the products intended for sale on this form.
  $products = $product_ids ? commerce_product_load_multiple($product_ids, array(
    'status' => 1,
  )) : array();

  // If no products were returned, go home.
  // @todo: Commerce has a more user friendly approach, which uses a disabled submit button.
  //     Steal that and implement here.
  if (count($products) == 0) {
    return array();
  }
  if (!isset($display['settings'])) {
    $display['settings'] = array();
  }
  $settings = $display['settings'] + commerce_product_bundle_field_formatter_default_settings();
  $weight = $display['weight'];
  $ref_field_name = $field_instance['field_name'];
  $id = $parent_product_id . '__' . $ref_field_name;

  // Add a generic class ID.
  $form['#attributes']['class'][] = drupal_html_class('commerce-product-bundle-add-to-cart');

  // Load data from line item id.
  if (isset($form_state['line_item']) && !isset($form_state['values']['bundle'][$id]['product_id'])) {
    $sub_line_items = commerce_product_bundle_get_sub_line_items($form_state['line_item']);

    // We assume that we have never the same product in one bundle.
    // This means that we let the customer never choose the same product
    // in the same bundle.
    foreach ($sub_line_items as $sub_line_item) {
      $sub_line_item_wrapper = entity_metadata_wrapper('commerce_line_item', $sub_line_item);
      $sub_product_id = $sub_line_item_wrapper->commerce_product->product_id
        ->value();
      if (isset($products[$sub_product_id])) {
        $form_state['values']['bundle'][$id]['product_id'] = $sub_product_id;
        $settings['default_quantity'] = (double) $sub_line_item_wrapper->quantity
          ->value();
        break;
      }
    }
  }
  if (!isset($form['bundle'])) {
    $form['bundle'] = array();
  }
  if (!isset($form['bundle'][$id])) {
    $form['bundle'][$id] = array();
  }
  $form['bundle'] += array(
    '#tree' => TRUE,
  );
  $form['bundle'][$id] += array(
    '#tree' => TRUE,
    '#prefix' => '<div class="bundle-widgets">',
    '#suffix' => '</div>',
    '#weight' => $weight,
  );

  // If the form is for a single product, store the product_id in a hidden
  // form field for use by the submit handler.
  if (count($products) == 1) {
    $form['bundle'][$id]['product_id'] = array(
      '#type' => 'hidden',
      '#value' => key($products),
    );
  }
  if ($settings['show_fieldset']) {
    $form['bundle'][$id] += array(
      '#type' => 'fieldset',
      '#title' => $field_instance['label'],
    );
  }
  $form_state['bundle_products'][$id] = $products;

  // However, if more than one products are represented on it, attempt to
  // use smart select boxes for the product selection. If the products are
  // all of the same type and there are qualifying fields on that product
  // type, display their options for customer selection.
  $same_type = TRUE;
  $qualifying_fields = array();
  $type = '';

  // Find the default product so we know how to set default options on the
  // various Add to Cart form widgets and an array of any matching product
  // based on attribute selections so we can add a selection widget.
  $matching_products = array();
  $default_product = NULL;
  $attribute_names = array();
  $unchanged_attributes = array();
  $defaults = array();
  foreach ($products as $product_id => $product) {
    $product_wrapper = entity_metadata_wrapper('commerce_product', $product);
    $defaults[$product_id] = $product_id;

    // Store the first product type.
    if (empty($type)) {
      $type = $product->type;
    }

    // If the current product type is different from the first, we are not
    // dealing with a set of same typed products.
    if ($product->type != $type) {
      $same_type = FALSE;
    }

    // If the form state contains a set of attribute data, use it to try
    // and determine the default product.
    $changed_attribute = NULL;
    if (!empty($form_state['values']['bundle'][$id]['attributes'])) {
      $match = TRUE;

      // Set an array of checked attributes for later comparison against the
      // default matching product.
      if (empty($attribute_names)) {
        $attribute_names = (array) array_diff_key($form_state['values']['bundle'][$id]['attributes'], array(
          'product_select' => '',
        ));
        $unchanged_attributes = $form_state['values']['bundle'][$id]['unchanged_attributes'];
      }
      foreach ($attribute_names as $key => $value) {

        // If this is the attribute widget that was changed...
        if ($value != $unchanged_attributes[$key]) {

          // Store the field name.
          $changed_attribute = $key;

          // Clear the input for the "Select a product" widget now if it
          // exists on the form since we know an attribute was changed.
          unset($form_state['input']['bundle'][$id]['attributes']['product_select']);
        }

        // If a field name has been stored and we've moved past it to
        // compare the next attribute field...
        if (!empty($changed_attribute) && $changed_attribute != $key) {

          // Wipe subsequent values from the form state so the attribute
          // widgets can use the default values from the new default product.
          unset($form_state['input']['bundle'][$id]['attributes'][$key]);

          // Don't accept this as a matching product.
          continue;
        }
        if ($product_wrapper->{$key}
          ->raw() != $value) {
          $match = FALSE;
        }
      }

      // If the changed field name has already been stored, only accept the
      // first matching product by ignoring the rest that would match. An
      // exception is granted for additional matching products that share
      // the exact same attribute values as the first.
      if ($match && !empty($changed_attribute) && !empty($matching_products)) {
        reset($matching_products);
        $matching_product = $matching_products[key($matching_products)];
        $matching_product_wrapper = entity_metadata_wrapper('commerce_product', $matching_product);
        foreach ($attribute_names as $key => $value) {
          if ($product_wrapper->{$key}
            ->raw() != $matching_product_wrapper->{$key}
            ->raw()) {
            $match = FALSE;
          }
        }
      }
      if ($match) {
        $matching_products[$product_id] = $product;
      }
    }
  }

  // Set the default product now if it isn't already set.
  if (empty($matching_products)) {

    // If a product ID value was passed in, use that product if it exists.
    if (!empty($form_state['values']['bundle'][$id]['product_id']) && !empty($products[$form_state['values']['bundle'][$id]['product_id']])) {
      $default_product = $products[$form_state['values']['bundle'][$id]['product_id']];
    }
    else {
      reset($products);
      $default_product = $products[key($products)];
    }
  }
  else {

    // If the product selector has a value, use that.
    if (!empty($form_state['values']['bundle'][$id]['attributes']['product_select']) && !empty($products[$form_state['values']['bundle'][$id]['attributes']['product_select']]) && in_array($products[$form_state['values']['bundle'][$id]['attributes']['product_select']], $matching_products)) {
      $default_product = $products[$form_state['values']['bundle'][$id]['attributes']['product_select']];
    }
    else {
      reset($matching_products);
      $default_product = $matching_products[key($matching_products)];
    }
  }

  // Wrap the default product for later use.
  $default_product_wrapper = entity_metadata_wrapper('commerce_product', $default_product);
  $form_state['bundle'][$id]['default_product'] = $default_product;

  // If all the products are of the same type...
  if ($same_type) {

    // Loop through all the field instances on that product type.
    foreach (field_info_instances('commerce_product', $type) as $name => $instance) {

      // A field qualifies if it is single value, required and uses a widget
      // with a definite set of options. For the sake of simplicity, this is
      // currently restricted to fields defined by the options module.
      $field = field_info_field($instance['field_name']);

      // Get the array of Cart settings pertaining to this instance.
      $commerce_cart_settings = commerce_cart_field_instance_attribute_settings($instance);
      if (commerce_cart_field_attribute_eligible($field) && $commerce_cart_settings['attribute_field']) {

        // Get the options properties from the options module for the
        // attribute widget type selected for the field, defaulting to the
        // select list options properties.
        switch ($commerce_cart_settings['attribute_widget']) {
          case 'checkbox':
            $widget_type = 'onoff';
            break;
          case 'radios':
            $widget_type = 'buttons';
            break;
          default:
            $widget_type = 'select';
        }
        $properties = _options_properties($widget_type, FALSE, TRUE, TRUE);

        // Try to fetch localized names.
        $allowed_values = NULL;

        // Prepare translated options if using the i18n_field module.
        if (module_exists('i18n_field')) {
          if ($translate = i18n_field_type_info($field['type'], 'translate_options')) {
            $allowed_values = $translate($field);
            _options_prepare_options($allowed_values, $properties);
          }

          // Translate the field title if set.
          if (!empty($instance['label'])) {
            $instance['label'] = i18n_field_translate_property($instance, 'label');
          }
        }

        // Otherwise just use the base language values.
        if (empty($allowed_values)) {
          $allowed_values = _options_get_options($field, $instance, $properties, 'commerce_product', $default_product);
        }

        // Only consider this field a qualifying attribute field if we could
        // derive a set of options for it.
        if (!empty($allowed_values)) {
          $qualifying_fields[$name] = array(
            'field' => $field,
            'instance' => $instance,
            'commerce_cart_settings' => $commerce_cart_settings,
            'options' => $allowed_values,
            'weight' => $instance['widget']['weight'],
            'required' => $instance['required'],
          );
        }
      }
    }
  }

  // Generate the select form items if we have only one product type,
  // which implies that all products has the same fields / attributes.
  if (!empty($qualifying_fields)) {
    $used_options = array();
    $field_has_options = array();

    // Sort the fields by weight.
    uasort($qualifying_fields, 'drupal_sort_weight');
    foreach ($qualifying_fields as $name => $data) {

      // Build an options array of widget options used by referenced products.
      foreach ($products as $product_id => $product) {
        $product_wrapper = entity_metadata_wrapper('commerce_product', $product);

        // Only add options to the present array that appear on products that
        // match the default value of the previously added attribute widgets.
        foreach ($used_options as $used_field_name => $unused) {

          // Don't apply this check for the current field being evaluated.
          if ($used_field_name == $name) {
            continue;
          }
          if (isset($form['bundle'][$id]['attributes'][$used_field_name]['#default_value'])) {
            if ($product_wrapper->{$used_field_name}
              ->raw() != $form['bundle'][$id]['attributes'][$used_field_name]['#default_value']) {
              continue 2;
            }
          }
        }

        // With our hard dependency on widgets provided by the Options
        // module, we can make assumptions about where the data is stored.
        if ($product_wrapper->{$name}
          ->raw() != NULL) {
          $field_has_options[$name] = TRUE;
        }
        $used_options[$name][] = $product_wrapper->{$name}
          ->raw();
      }

      // If for some reason no options for this field are used, remove it
      // from the qualifying fields array.
      if (empty($field_has_options[$name]) || empty($used_options[$name])) {
        unset($qualifying_fields[$name]);
      }
      else {
        $form['bundle'][$id]['attributes'][$name] = array(
          '#type' => $data['commerce_cart_settings']['attribute_widget'],
          '#title' => check_plain($data['instance']['label']),
          '#options' => array_intersect_key($data['options'], drupal_map_assoc($used_options[$name])),
          '#default_value' => $default_product_wrapper->{$name}
            ->raw(),
          '#weight' => $data['instance']['widget']['weight'],
          '#ajax' => array(
            'callback' => 'commerce_product_attributes_add_to_cart_form_attributes_refresh',
          ),
        );

        // Add the empty value if the field is not required and products on
        // the form include the empty value.
        if (!$data['required'] && in_array('', $used_options[$name])) {
          $form['bundle'][$id]['attributes'][$name]['#empty_value'] = '';
        }
        $form['bundle'][$id]['unchanged_attributes'][$name] = array(
          '#type' => 'value',
          '#value' => $default_product_wrapper->{$name}
            ->raw(),
        );
      }
    }
    if (!empty($form['bundle'][$id]['attributes'])) {
      $form['bundle'][$id]['attributes'] += array(
        '#tree' => 'TRUE',
        '#prefix' => '<div class="attribute-widgets">',
        '#suffix' => '</div>',
        '#weight' => 0,
      );
      $form['bundle'][$id]['unchanged_attributes'] += array(
        '#tree' => 'TRUE',
      );

      // If the matching products array is empty, it means this is the first
      // time the form is being built. We should populate it now with
      // products that match the default attribute options.
      if (empty($matching_products)) {
        foreach ($products as $product_id => $product) {
          $product_wrapper = entity_metadata_wrapper('commerce_product', $product);
          $match = TRUE;
          foreach (element_children($form['bundle'][$id]['attributes']) as $name) {
            if ($product_wrapper->{$name}
              ->raw() != $form['bundle'][$id]['attributes'][$name]['#default_value']) {
              $match = FALSE;
            }
          }
          if ($match) {
            $matching_products[$product_id] = $product;
          }
        }
      }

      // If there were more than one matching products for the current
      // attribute selection, add a product selection widget.
      if (count($matching_products) > 1) {
        $options = array();
        foreach ($matching_products as $product_id => $product) {
          $options[$product_id] = check_plain($product->title);
        }
        $form['bundle'][$id]['attributes']['product_select'] = array(
          '#type' => 'select',
          '#title' => t('Select a product'),
          '#options' => $options,
          '#default_value' => $default_product->product_id,
          '#weight' => 40,
          '#ajax' => array(
            'callback' => 'commerce_product_attributes_add_to_cart_form_attributes_refresh',
          ),
        );
      }
      $form['bundle'][$id]['product_id'] = array(
        '#type' => 'hidden',
        '#value' => $default_product->product_id,
      );
    }
  }

  // If the products referenced were of different types or did not posess
  // any qualifying attribute fields, add a product selection widget.
  if (!$same_type || empty($qualifying_fields)) {

    // For a single product form, just add the hidden product_id field.
    if (count($products) == 1) {
      $form['bundle'][$id]['product_id'] = array(
        '#type' => 'hidden',
        '#value' => $default_product->product_id,
      );
    }
    else {
      $options = array();
      $defaults = array();
      foreach ($products as $product_id => $product) {
        $options[$product_id] = check_plain($product->title);
        $defaults[$product_id] = $product_id;
      }
      if ($settings['bundle_type'] == 'hidden') {
        foreach ($defaults as $default) {
          $form['bundle'][$id]['product_id'][$default] = array(
            '#type' => 'hidden',
            '#value' => $default,
          );
        }
      }
      else {
        if ($settings['bundle_type'] == 'multiple') {
          $form['bundle'][$id]['product_id'] = array(
            '#type' => 'select',
            '#options' => $options,
            '#default_value' => $defaults,
            '#weight' => 0,
            '#multiple' => true,
            '#ajax' => array(
              'callback' => 'commerce_product_attributes_add_to_cart_form_attributes_refresh',
            ),
          );
        }
        else {
          $form['bundle'][$id]['product_id'] = array(
            '#type' => 'select',
            '#options' => $options,
            '#default_value' => $default_product->product_id,
            '#weight' => 0,
            '#ajax' => array(
              'callback' => 'commerce_product_attributes_add_to_cart_form_attributes_refresh',
            ),
          );
        }
      }
    }
  }

  // Render the quantity field as either a textfield if shown or a hidden
  // field if not.
  if ($settings['show_quantity']) {
    $form['bundle'][$id]['quantity'] = array(
      '#type' => 'textfield',
      '#title' => t('Quantity'),
      '#default_value' => $settings['default_quantity'],
      '#datatype' => 'integer',
      '#size' => 5,
      '#weight' => 5,
    );
  }
  else {
    $form['bundle'][$id]['quantity'] = array(
      '#type' => 'hidden',
      '#value' => $settings['default_quantity'],
      '#datatype' => 'integer',
      '#weight' => 5,
    );
  }

  // Do not allow bundle products without a price to be purchased.
  $values = commerce_product_calculate_sell_price($form_state['default_product']);
  if (is_null($values) || is_null($values['amount']) || is_null($values['currency_code'])) {
    $form['submit'] = array(
      '#type' => 'submit',
      '#value' => t('Product not available'),
      '#weight' => 50,
      // Do not set #disabled in order not to prevent submission.
      '#attributes' => array(
        'disabled' => 'disabled',
      ),
      '#validate' => array(
        'commerce_cart_add_to_cart_form_disabled_validate',
      ),
    );
  }
  else {

    // Remove the default submit handler, we need our own handler.
    foreach ($form['#submit'] as $handler_id => $handler) {
      if ($handler == 'commerce_cart_add_to_cart_form_submit') {
        unset($form['#submit'][$handler_id]);
      }
      elseif ($handler == 'commerce_product_attributes_add_to_cart_form_submit') {
        unset($form['#submit'][$handler_id]);
      }
      elseif ($handler == 'commerce_product_bundle_add_to_cart_form_submit') {
        unset($form['#submit'][$handler_id]);
      }
    }
  }

  // Add the handlers manually since we're using hook_forms() to associate this
  // form with form IDs based on the $product_ids.
  $form['#validate'][] = 'commerce_product_bundle_add_to_cart_form_validate';
  $form['#submit'][] = 'commerce_product_bundle_add_to_cart_form_submit';
  return $form;
}

/**
 * Submit function to add product bundles to the cart.
 *
 * @param array $form
 *   The entire add to cart form array.
 * @param array $form_state
 *   The actual state of the form.
 *
 * @see commerce_product_bundle_update_cart()
 * @see commerce_product_bundle_add_to_cart_form()
 */
function commerce_product_bundle_add_to_cart_form_submit($form, &$form_state) {

  // The product id of the product we want to add.
  $product_id = $form_state['values']['product_id'];

  // Add sub products to the cart.
  $subproducts = array();
  foreach ($form_state['values']['bundle'] as $id => $bundled_item) {

    // $subproduct = $form_state['bundle'][$id]['default_product'];
    // $bundled_item['product_id'] = $subproduct->product_id;
    $subproducts[] = $bundled_item;
  }

  // If we have a line_item_id, then the product is always in the cart and we
  // we have to update the cart.
  if (!empty($form_state['line_item']->line_item_id)) {

    // Update the product to the specified shopping cart.
    $form_state['line_item'] = commerce_product_bundle_update_cart($form_state['values']['uid'], $product_id, $form_state['values']['quantity'], $subproducts, $form_state['line_item']->line_item_id);
    drupal_goto('cart');
  }
  else {

    // Add the product to the specified shopping cart.

    //field_attach_submit('commerce_line_item', $form_state['line_item'], $form['line_item_fields'], $form_state);

    // Return the submitted values of the fields
    $response = commerce_product_bundle_add_to_cart($form_state['values']['uid'], $product_id, $form_state['values']['quantity'], $subproducts, $form_state['line_item']);
    $form_state['line_item'] = $response['line_item'];
    $form_state['bundle_product_line_items'] = $response['sub_line_items'];
  }
}

/**
 * Adds the specified product to a customer's shopping cart.
 *
 * Most of the code is copied from commerce_cart_product_add,
 * we need to copy to reorder the rules invokations.
 *
 * @param int $uid
 *   The uid of the user whose cart you are adding the product to.
 * @param int $product_id
 *   The ID of the product to add to the cart.
 * @param int $quantity
 *   The quantity of this product to add to the cart.
 * @param array $subproducts
 *   An array of products that relates to this bundle.
 * @param $line_item
 *   The full line item object to save.
 *
 * @return array|FALSE
 *   Returns FALSE if we can't load a product from the provided $product_id.
 *   Returns an array of new or updated line item objects for the
 *   bundle_line_item and the sub_line_items
 */
function commerce_product_bundle_add_to_cart($uid, $product_id, $quantity, $subproducts, $line_item) {

  // Load and validate the specified product ID.
  $product = commerce_product_load($product_id);

  // Fail if the product does not exist or is disabled.
  if (empty($product) || !$product->status) {
    return FALSE;
  }

  // First attempt to load the customer's shopping cart order.
  $order = commerce_cart_order_load($uid);

  // If no order existed, create one now.
  if (empty($order)) {
    $order = commerce_cart_order_new($uid);
  }

  // Wrap the order for easy access to field data.
  $order_wrapper = entity_metadata_wrapper('commerce_order', $order);

  // Invoke the product prepare event with the shopping cart order.
  rules_invoke_all('commerce_cart_product_prepare', $order, $product, $quantity);

  // Determine if the product already exists on the order and increment its
  // quantity instead of adding a new line if it does.
  $matching_line_item = NULL;

  // TODO: Find a way to identify same bundles. A possible approach can be a
  // nested loop. Additionally we need to put this in a separate method with
  // a hook, because the commerce_option modules need to change this and other
  // modules too.

  /*
    // Loop through the line items looking for products.
    foreach ($order_wrapper->commerce_line_items as $delta => $line_item_wrapper) {
      // If this line item matches the product being added...
      if ($line_item_wrapper->type->value() == 'product' &&
          $line_item_wrapper->commerce_product->product_id->value() == $product_id) {
        $line_item = $line_item_wrapper->value();
        // Exit this loop with the $line_item intact so it gets updated.
        break;
      }
    }*/

  // If no matching line item was found.
  if (empty($matching_line_item)) {

    // Adjust the quantity.
    $line_item->quantity = $quantity;

    // Set the incoming line item's order_id.
    $line_item->order_id = $order->order_id;

    // Process the unit price through Rules so it reflects the user's actual
    // purchase price.
    rules_invoke_event('commerce_product_calculate_sell_price', $line_item);

    // Save the line item now so we get its ID.
    commerce_line_item_save($line_item);
    $sub_line_items = array();

    // Iterates over all sub products:
    foreach ($subproducts as $item_values) {
      $product_ids = array();
      if (is_array($item_values['product_id'])) {
        $product_ids = $item_values['product_id'];
      }
      else {
        $product_ids = array(
          $item_values['product_id'],
        );
      }
      foreach ($product_ids as $product_id) {

        // Check product.
        $subproduct = commerce_product_load($product_id);
        if (empty($subproduct) || !$subproduct->status) {

          // Skip this item, because it is not a valid one.
          continue;
        }

        // Check quantity.
        if ($item_values['quantity'] < 0) {

          // Skip, because it is not a valid quantity.
          continue;
        }
        $sub_line_item = commerce_product_bundle_line_item_new($subproduct, $line_item, $item_values['quantity'], $order->order_id);

        // Process the unit price through Rules so it reflects the user's actual
        // purchase price.
        rules_invoke_event('commerce_product_calculate_sell_price', $sub_line_item);

        // Save the line item.
        commerce_line_item_save($sub_line_item);
        $sub_line_items[$product_id] = $sub_line_item;
      }
    }

    // Process the unit price through Rules so it reflects the user's actual
    // purchase price.
    rules_invoke_event('commerce_product_calculate_sell_price', $line_item);

    // Save the line item now so we get its ID.
    commerce_line_item_save($line_item);

    // Add it to the order's line item reference value.
    $order_wrapper->commerce_line_items[] = $line_item;
  }
  else {

    // Increment the quantity of the line item and save it.
    $matching_line_item->quantity += $quantity;
    $matching_line_item->data = array_merge($line_item->data, $matching_line_item->data);
    commerce_line_item_save($matching_line_item);

    // Clear the line item cache so the updated quantity will be available to
    // the ensuing load instead of the original quantity as loaded above.
    entity_get_controller('commerce_line_item')
      ->resetCache(array(
      $matching_line_item->line_item_id,
    ));

    // Update the line item variable for use in the invocation and return value.
    $line_item = $matching_line_item;
  }

  // Save the updated order.
  commerce_order_save($order);

  // Invoke the product add event with the newly saved or updated line item.
  rules_invoke_all('commerce_cart_product_add', $order, $product, $quantity, $line_item);

  // Return the line item.
  return array(
    'line_item' => $line_item,
    'sub_line_items' => $sub_line_items,
  );
}

/**
 * Updates the specified product in a customer's shopping cart.
 *
 * Most of the code is copied from commerce_cart_product_add,
 * we need to copy to reorder the rules invokations.
 *
 * @param int $uid
 *   The uid of the user whose cart you are adding the product to.
 * @param int $product_id
 *   The ID of the product to add to the cart.
 * @param int $quantity
 *   The quantity of this product to add to the cart.
 * @param array $subproducts
 *   An array of products that relates to this bundle.
 * @param int $line_item_id
 *   The id of the line item.
 *
 * @return obj|FALSE
 *   Returns FALSE if we can't load a product from the provided $product_id.
 *   Returns updated line item object.
 */
function commerce_product_bundle_update_cart($uid, $product_id, $quantity, $subproducts, $line_item_id) {

  // Load and validate the specified product ID.
  $product = commerce_product_load($product_id);

  // Fail if the product does not exist or is disabled.
  if (empty($product) || !$product->status) {
    return FALSE;
  }

  // First attempt to load the customer's shopping cart order.
  $order = commerce_cart_order_load($uid);

  // If no order existed, create one now.
  if (empty($order)) {
    $order = commerce_cart_order_new($uid);
  }

  // Wrap the order for easy access to field data.
  $order_wrapper = entity_metadata_wrapper('commerce_order', $order);
  $line_item = commerce_line_item_load($line_item_id);
  $line_item_wrapper = entity_metadata_wrapper('commerce_line_item', $line_item);
  if (empty($line_item_wrapper)) {
    return;
  }

  // Invoke the product prepare event with the shopping cart order.
  rules_invoke_all('commerce_cart_product_prepare', $order, $product, $quantity);
  $line_item_wrapper->quantity = $quantity;
  $line_item_wrapper->commerce_product = $product_id;

  // Remove all current line items.
  $sub_line_items = commerce_product_bundle_get_sub_line_items($line_item, TRUE);
  commerce_line_item_delete_multiple(array_keys($sub_line_items));

  // Iterates over all sub products:
  foreach ($subproducts as $item_values) {

    // Check product.
    $subproduct = commerce_product_load($item_values['product_id']);
    if (empty($subproduct) || !$subproduct->status) {

      // Skip this item, because it is not a valid one.
      continue;
    }

    // Check quantity.
    if ($item_values['quantity'] < 0) {

      // Skip, because it is not a valid quantity.
      continue;
    }
    $sub_line_item = commerce_product_bundle_line_item_new($subproduct, $line_item, $item_values['quantity'], $order->order_id);

    // Process the unit price through Rules so it reflects the user's actual
    // purchase price.
    rules_invoke_event('commerce_product_calculate_sell_price', $sub_line_item);

    // Save the line item.
    commerce_line_item_save($sub_line_item);
    $sub_line_items[] = $sub_line_item;
  }
  rules_invoke_event('commerce_product_calculate_sell_price', $line_item);
  commerce_line_item_save($line_item);

  // Save the updated order.
  commerce_order_save($order);
  entity_get_controller('commerce_line_item')
    ->resetCache(array(
    $line_item->line_item_id,
  ));

  // Invoke the product add event with the newly saved or updated line item.
  // TODO: Implement this event see commerce_attribute_cart_product_update()
  // rules_invoke_all('commerce_cart_product_update', $order, $product, $quantity, $line_item);
  // Return the line item.
  return $line_item;
}

/**
 * Validation for the add to cart form.
 *
 * @param array $form
 *   The form array.
 * @param array $form_state
 *   The array reflecting the actual state of the form.
 */
function commerce_product_bundle_add_to_cart_form_validate($form, &$form_state) {

  // @TODO: Add the needed validation methods.
}

/**
 * Implements hook_commerce_line_item_type_info().
 */
function commerce_product_bundle_commerce_line_item_type_info() {
  return array(
    'bundle' => array(
      'type' => 'bundle',
      'name' => t('Bundle Item'),
      'description' => t('References a bundled product.'),
      'add_form_submit_value' => t('Add bundle product'),
      'base' => 'commerce_product_bundle_line_item',
      'callbacks' => array(
        'configuration' => 'commerce_product_bundle_configure_line_item',
      ),
    ),
  );
}

/**
 * Returns an appropriate title for this line item.
 *
 * @Todo If this function do something other than returning a title, use it in
 * commerce_product_bundle_add_to_cart_form_submit().
 */
function commerce_product_bundle_line_item_title($line_item) {

  // Currently, just return the product's title.  However, in the future replace
  // this with the product preview build mode.
  if ($product = entity_metadata_wrapper('commerce_line_item', $line_item)->commerce_product
    ->value()) {
    return check_plain($product->title);
  }
}

/**
 * Returns the elements necessary to add a product line item through a line item
 * manager widget.
 *
 * @TODO: Implement this method correct.
 */
function commerce_product_bundle_line_item_add_form($form_state) {
  $order = $form_state['commerce_order'];
  $form = array();
  $form['amount'] = array(
    '#type' => 'textfield',
    '#title' => t('Amount'),
    '#default_value' => $default_amount,
    '#size' => 10,
  );

  // Build a currency options list from all enabled currencies.
  $options = array();
  foreach (commerce_currencies(TRUE) as $currency_code => $currency) {
    $options[$currency_code] = check_plain($currency['code']);
  }
  $form['currency_code'] = array(
    '#type' => 'select',
    '#title' => t('Currency'),
    '#options' => $options,
    '#default_value' => commerce_default_currency(),
  );
  return $form;
}

/**
 * Adds the selected shipping information to a line item added via a line item
 *   manager widget.
 *
 * @param obj $line_item
 *   The newly created line item object.
 * @param array $element
 *   The array representing the widget form element.
 * @param array $form_state
 *   The present state of the form upon the latest submission.
 * @param array $form
 *   The actual form array.
 *
 * @TODO: Implement this method correct.
 */
function commerce_product_bundle_line_item_add_form_submit(&$line_item, $element, &$form_state, $form) {
  $order = $form_state['commerce_order'];

  // Populate the line item with the product data.
  $line_item_wrapper = entity_metadata_wrapper('commerce_line_item', $line_item);
  $line_item_wrapper->commerce_unit_price->amount = $element['actions']['amount']['#value'];
  $line_item_wrapper->commerce_unit_price->currency_code = $element['actions']['currency_code']['#value'];
  $line_item_wrapper->commerce_unit_price->data = commerce_price_component_add($line_item_wrapper->commerce_unit_price
    ->value(), 'base_price', $line_item_wrapper->commerce_unit_price
    ->value(), TRUE);
}

/**
 * Creates a new product line item populated with the proper product values.
 *
 * @param obj $product
 *   The subproduct.
 * @param obj $parent_line_item
 *   The line item object of the bundle product.
 * @param int $quantity
 *   The quantity of the subproduct.
 * @param int $order_id
 *   The id of the order to which the product belongs to.
 * @param array $data
 *   A data array to set on the new line item. The following information in the
 *   data array may be used on line item creation:
 *   - $data['context']['display_path']: if present will be used to set the line
 *     item's display_path field value.
 *
 * @return obj
 *   Line item object with default values.
 */
function commerce_product_bundle_line_item_new($product, $parent_line_item, $quantity = 1, $order_id = 0, $data = array()) {

  // Create the new line item.
  $line_item = entity_create('commerce_line_item', array(
    'type' => 'bundle',
    'quantity' => $quantity,
    'data' => $data,
  ));

  // Set the label to be the product SKU.
  $line_item->line_item_label = $product->sku;

  // Set the incoming parents line item's order_id.
  $line_item->order_id = $parent_line_item->order_id;

  // Wrap the line item and product to easily set field information.
  $line_item_wrapper = entity_metadata_wrapper('commerce_line_item', $line_item);
  $product_wrapper = entity_metadata_wrapper('commerce_product', $product);

  // Add the product reference value to the line item for the right language.
  $line_item_wrapper->commerce_product = $product->product_id;

  // Add the display URI if specified.
  if (!empty($line_item->data['context']['display_path'])) {
    $line_item_wrapper->commerce_display_path = $line_item->data['context']['display_path'];
  }
  else {
    $line_item_wrapper->commerce_display_path = '';
  }

  // Set the unit price on the line item object.
  $line_item_wrapper->commerce_unit_price = $product_wrapper->commerce_price
    ->value();

  // Add the base price to the components array.
  if (!commerce_price_component_load($line_item_wrapper->commerce_unit_price
    ->value(), 'base_price')) {
    $line_item_wrapper->commerce_unit_price->data = commerce_price_component_add($line_item_wrapper->commerce_unit_price
      ->value(), 'base_price', $line_item_wrapper->commerce_unit_price
      ->value(), TRUE);
  }

  // Add the parent line item.
  $line_item_wrapper->commerce_parent_line_item = $parent_line_item->line_item_id;

  // Return the line item.
  return $line_item;
}

/**
 * Ensures the product line item type contains a product reference field.
 *
 * This function is called by the line item module when it is enabled or this
 * module is enabled. It invokes this function using the configuration_callback
 * as specified above.
 */
function commerce_product_bundle_configure_line_item() {
  commerce_product_reference_create_instance('commerce_product', 'commerce_line_item', 'bundle', t('Bundled Product'));

  // Look for or add a display path textfield to the product line item type.
  $field_name = 'commerce_display_path';
  $field = field_info_field($field_name);
  $instance = field_info_instance('commerce_line_item', $field_name, 'bundle');
  if (empty($field)) {
    $field = array(
      'field_name' => $field_name,
      'type' => 'text',
      'cardinality' => 1,
      'entity_types' => array(
        'commerce_line_item',
      ),
      'translatable' => FALSE,
      'locked' => TRUE,
    );
    $field = field_create_field($field);
  }
  if (empty($instance)) {
    $instance = array(
      'field_name' => $field_name,
      'entity_type' => 'commerce_line_item',
      'bundle' => 'bundle',
      'label' => t('Display path'),
      'required' => TRUE,
      'settings' => array(),
      'widget' => array(
        'type' => 'text_textfield',
      ),
      'display' => array(
        'display' => array(
          'label' => 'hidden',
          'weight' => -10,
        ),
      ),
    );
    field_create_instance($instance);
  }

  // Setup the related commerce_line_item:
  $field_name = 'commerce_parent_line_item';
  $field = field_info_field($field_name);
  $instance = field_info_instance('commerce_line_item', $field_name, 'bundle');
  if (empty($field)) {
    $field = array(
      'field_name' => $field_name,
      'type' => 'commerce_line_item_reference',
      'cardinality' => 1,
      'entity_types' => array(
        'commerce_line_item',
      ),
      'translatable' => FALSE,
      'locked' => TRUE,
    );
    $field = field_create_field($field);
  }
  if (empty($instance)) {
    $instance = array(
      'field_name' => $field_name,
      'entity_type' => 'commerce_line_item',
      'bundle' => 'bundle',
      'label' => t('Parent Line Item'),
      'required' => TRUE,
      'settings' => array(),
      'widget' => array(
        'type' => 'commerce_line_item_manager',
      ),
      'display' => array(
        'display' => array(
          'label' => 'hidden',
          'weight' => -10,
        ),
      ),
    );
    field_create_instance($instance);
  }
}

/**
 * Implements hook_attribute_field().
 */
function commerce_product_bundle_attribute_field(&$element, &$line_item) {

  // Check if we have a commerce product line item.
  if (!in_array($line_item->type, commerce_product_line_item_types())) {
    return;
  }
  $element['bundles'] = array();
  $sub_items = commerce_product_bundle_get_sub_line_items($line_item, TRUE);
  if (count($sub_items) > 0) {
    $element['#attached']['css'][] = drupal_get_path('module', 'commerce_product_bundle') . '/theme/commerce_product_bundle_cart.css';
    foreach ($sub_items as $item) {
      $item_wrapper = entity_metadata_wrapper('commerce_line_item', $item);
      $product_attribute_view = field_attach_view('commerce_product', $item_wrapper->commerce_product
        ->value(), 'attribute_view');
      $element['bundles'][] = array(
        '#markup' => theme('commerce_product_bundle_attribute', array(
          'sub_line_item' => $item,
          'product_attribute_view' => drupal_render($product_attribute_view),
        )),
      );
    }
  }
}

/**
 * This function returns all sub line items which relates to the given
 * line item.
 *
 * @param obj $parent_line_item
 *   The parent line item to get the children of.
 *
 * @return array
 *   List of children line items.
 */
function commerce_product_bundle_get_sub_line_items(&$parent_line_item, $reset = FALSE) {
  if (!is_object($parent_line_item)) {
    return array();
  }
  if ($reset || !isset($parent_line_item->data['sub_line_items']) || count($parent_line_item->data['sub_line_items']) <= 0) {
    $query = new EntityFieldQuery();
    $entities = $query
      ->entityCondition('entity_type', 'commerce_line_item')
      ->entityCondition('bundle', 'bundle')
      ->fieldCondition('commerce_parent_line_item', 'line_item_id', $parent_line_item->line_item_id, '=')
      ->execute();
    if (!isset($entities['commerce_line_item'])) {
      return array();
    }
    foreach ($entities['commerce_line_item'] as $item) {
      $item = commerce_line_item_load($item->line_item_id);
      $line_item_wrapper = entity_metadata_wrapper('commerce_line_item', $item);
      if (is_object($line_item_wrapper->commerce_parent_line_item
        ->value()) && $line_item_wrapper->commerce_parent_line_item->line_item_id
        ->value() == $parent_line_item->line_item_id) {
        $items[$item->line_item_id] = $item;
      }
    }
    $parent_line_item->data['sub_line_items'] = $items;
  }
  return $parent_line_item->data['sub_line_items'];
}

/**
 * Implements hook_theme().
 *
 * @see theme_commerce_product_bundle_attribute().
 */
function commerce_product_bundle_theme() {
  return array(
    'commerce_product_bundle_attribute' => array(
      'variables' => array(
        'sub_line_item' => NULL,
        'product_attribute_view' => NULL,
      ),
    ),
  );
}

/**
 * Themes the attributes of the bundle products.
 *
 * @param array $variables
 *   Array of variables passed to the function.
 *
 * @return array
 *   Themed markup for displaying the attributes.
 *
 * @see commerce_product_bundle_theme().
 */
function theme_commerce_product_bundle_attribute($variables) {
  $sub_line_item = $variables['sub_line_item'];
  $product_attribute_view = $variables['product_attribute_view'];
  if (!empty($product_attribute_view)) {
    $product_attribute_view = '<div class="commerce-product-bundle-product-attributes">' . $product_attribute_view . '</div>';
  }
  $output = '<div class="commerce-product-bundle-sub-line-item"><div class="commerce-product-bundle-sub-line-item-title">';
  $output .= '<span class="commerce-product-bundle-quantity">' . (double) $sub_line_item->quantity . '</span>';
  $output .= ' x ';
  $output .= '<span class="commerce-product-bundle-title">' . commerce_line_item_title($sub_line_item) . '</span></div>';
  $output .= $product_attribute_view;
  $output .= '</div>';
  return $output;
}

/**
 * Implements hook_attribute_product_field_alter().
 */
function commerce_product_bundle_attribute_product_field_alter(&$element, $product, $product_field_name, $form, $form_state) {
  if ($product_field_name == 'commerce_price') {

    // First create a pseudo product line item that we will pass to Rules.
    $line_item = commerce_product_line_item_new($product);
    $line_item->line_item_id = 0;
    $subproducts = array();

    // Add sub products to the cart:
    foreach ($form_state['values']['bundle'] as $id => $item_values) {
      $subproduct = $form_state['bundle'][$id]['default_product'];
      $sub_line_item = commerce_product_bundle_line_item_new($subproduct, $line_item, $item_values['quantity']);
      $sub_line_items[] = $sub_line_item;
    }
    $line_item->data['sub_line_items'] = $sub_line_items;

    // Pass the line item to Rules.
    rules_invoke_event('commerce_product_calculate_sell_price', $line_item);
    $price = entity_metadata_wrapper('commerce_line_item', $line_item)->commerce_unit_price
      ->value();
    $element[0]['#markup'] = commerce_currency_format($price['amount'], $element['#items'][0]['currency_code'], $product);
  }
}

Functions

Namesort descending Description
commerce_product_bundle_add_to_cart Adds the specified product to a customer's shopping cart.
commerce_product_bundle_add_to_cart_form Builds the add to cart form, for product bundles.
commerce_product_bundle_add_to_cart_form_submit Submit function to add product bundles to the cart.
commerce_product_bundle_add_to_cart_form_validate Validation for the add to cart form.
commerce_product_bundle_attribute_field Implements hook_attribute_field().
commerce_product_bundle_attribute_product_field_alter Implements hook_attribute_product_field_alter().
commerce_product_bundle_commerce_line_item_type_info Implements hook_commerce_line_item_type_info().
commerce_product_bundle_configure_line_item Ensures the product line item type contains a product reference field.
commerce_product_bundle_field_formatter_default_settings Returns the default settings for the form display.
commerce_product_bundle_field_formatter_info Implements hook_field_formatter_info().
commerce_product_bundle_field_formatter_settings_form Implements hook_field_formatter_settings_form().
commerce_product_bundle_field_formatter_settings_summary Implements hook_field_formatter_settings_summary().
commerce_product_bundle_field_formatter_view Implements hook_field_formatter_view().
commerce_product_bundle_form_alter Implements of hook_bundle_form_alter().
commerce_product_bundle_get_sub_line_items This function returns all sub line items which relates to the given line item.
commerce_product_bundle_line_item_add_form Returns the elements necessary to add a product line item through a line item manager widget.
commerce_product_bundle_line_item_add_form_submit Adds the selected shipping information to a line item added via a line item manager widget.
commerce_product_bundle_line_item_delete_form_submit Submit function for the bundle line item delete form.
commerce_product_bundle_line_item_new Creates a new product line item populated with the proper product values.
commerce_product_bundle_line_item_title Returns an appropriate title for this line item.
commerce_product_bundle_theme Implements hook_theme().
commerce_product_bundle_update_cart Updates the specified product in a customer's shopping cart.
theme_commerce_product_bundle_attribute Themes the attributes of the bundle products.