diff --git a/inc/plugins/class-dynamic-content.php b/inc/plugins/class-dynamic-content.php index 80354989d..2bf17a902 100644 --- a/inc/plugins/class-dynamic-content.php +++ b/inc/plugins/class-dynamic-content.php @@ -60,7 +60,26 @@ public function apply_dynamic_content( $content ) { } $position = strlen( $position ); - $content = substr_replace( $content, $replacement, $position, strlen( $string_to_replace ) ); + if ( is_array( $replacement ) ) { + $re = '/#otterDynamicLink\/?.[^"]*/'; + $matches = array(); + $num = preg_match_all( $re, $content, $matches, PREG_SET_ORDER, 0 ); + + if ( isset( $matches[0] ) ) { + $link = $this->apply_link_button( $matches[0] ); + } + $_content = array_fill( 0, count( $replacement ), $content ); + $content = ''; + foreach ( $replacement as $key => $value ) { + $updated_content = substr_replace( $_content[ $key ], $value, $position, strlen( $string_to_replace ) ); + if ( isset( $link ) && is_array( $link ) && isset( $link[ $key ] ) ) { + $updated_content = str_replace( $matches[0], $link[ $key ], $updated_content ); + } + $content .= $updated_content; + } + } else { + $content = substr_replace( $content, $replacement, $position, strlen( $string_to_replace ) ); + } } return $content; @@ -132,18 +151,65 @@ public function apply_magic_tags( $data ) { /** * Filter post content for dynamic link. * - * @param string $content Post content. + * @param string $content Post content. + * @param int|null $key Optional key for multiple dynamic links in the same content. * * @return string */ - public function apply_dynamic_link( $content ) { + public function apply_dynamic_link( $content, $key = null ) { if ( false === strpos( $content, '[^"\'<>]+)["\']|data-target=["\'](?P[^"\'<>]+)["\']|data-meta-key=["\'](?P[^"\'<>]+)["\']|data-context=["\'](?P[^"\'<>]+)["\']|[a-zA-Z-]+=["\'][^"\'<>]+["\']))*\s*>(?[^ $].*?)<\s*\/\s*o-dynamic-link>/'; - return preg_replace_callback( $re, array( $this, 'apply_link' ), $content ); + $matches = array(); + $num = preg_match_all( $re, $content, $matches, PREG_SET_ORDER, 0 ); + if ( isset( $num ) && 0 === $num ) { + return $content; + } + + $resolved = array(); + $clone_count = 1; + foreach ( $matches as $match ) { + $value = $this->apply_link( $match, $key ); + $resolved[] = $value; + if ( is_array( $value ) ) { + $clone_count = max( $clone_count, count( $value ) ); + } + } + + // Replace the content for multiple dynamic links in the same content. + if ( $clone_count > 1 ) { + $output = ''; + for ( $i = 0; $i < $clone_count; $i++ ) { + $clone = $content; + foreach ( $matches as $j => $match ) { + $value = $resolved[ $j ]; + $replacement = is_array( $value ) + ? ( isset( $value[ $i ] ) ? $value[ $i ] : '' ) + : $value; + $pos = strpos( $clone, $match[0] ); + if ( false !== $pos ) { + $clone = substr_replace( $clone, $replacement, $pos, strlen( $match[0] ) ); + } + } + $output .= $clone; + } + return $output; + } + + // Replace the content for single dynamic link in the content. + $index = 0; + return preg_replace_callback( + $re, + function ( $data ) use ( &$resolved, &$index ) { + $value = isset( $resolved[ $index ] ) ? $resolved[ $index ] : $data[0]; + $index++; + return is_string( $value ) ? $value : $data[0]; + }, + $content + ); } /** @@ -178,9 +244,54 @@ public function apply_dynamic_images( $content ) { $rest_url = get_rest_url( null, 'otter/v1' ); $rest_url = preg_replace( '/([^A-Za-z0-9\s_-])/', '\\\\$1', $rest_url ); - $re = '/' . $rest_url . '\/dynamic\/?.[^"]*/'; + $re = '/' . $rest_url . '\/dynamic\/?.[^"]*/'; + $matches = array(); + $num = preg_match_all( $re, $content, $matches, PREG_SET_ORDER, 0 ); + if ( isset( $num ) && 0 === $num ) { + return $content; + } - return preg_replace_callback( $re, array( $this, 'apply_images' ), $content ); + $resolved = array(); + $clone_count = 1; + foreach ( $matches as $match ) { + $value = $this->apply_images( $match ); + $resolved[] = $value; + if ( is_array( $value ) ) { + $clone_count = max( $clone_count, count( $value ) ); + } + } + + // Replace the content for multiple dynamic images in the same content. + if ( $clone_count > 1 ) { + $output = ''; + for ( $i = 0; $i < $clone_count; $i++ ) { + $clone = $content; + foreach ( $matches as $j => $match ) { + $value = $resolved[ $j ]; + $replacement = is_array( $value ) + ? ( isset( $value[ $i ] ) ? $value[ $i ] : '' ) + : $value; + $pos = strpos( $clone, $match[0] ); + if ( false !== $pos ) { + $clone = substr_replace( $clone, $replacement, $pos, strlen( $match[0] ) ); + } + } + $output .= $clone; + } + return $output; + } + + // Replace the content for single dynamic image in the content. + $index = 0; + return preg_replace_callback( + $re, + function ( $data ) use ( &$resolved, &$index ) { + $value = isset( $resolved[ $index ] ) ? $resolved[ $index ] : $data[0]; + $index++; + return is_string( $value ) ? $value : $data[0]; + }, + $content + ); } /** @@ -188,7 +299,7 @@ public function apply_dynamic_images( $content ) { * * @param array $data Dynamic request. * - * @return string|void + * @return string|string[]|void */ public function apply_images( $data ) { if ( ! isset( $data[0] ) ) { @@ -267,11 +378,28 @@ public function get_image( $data ) { * @param array $data Dynamic request. * @param bool $magic_tags Is a request for Magic Tags. * - * @return string + * @return string|string[] */ public function apply_data( $data, $magic_tags = false ) { $value = $this->get_data( $data, $magic_tags ); + if ( is_array( $value ) ) { + $updated_value = array(); + foreach ( $value as $key => $val ) { + $_value = $this->apply_formatting( $val, $data ); + + if ( isset( $data['default'] ) && false !== strpos( $data['default'], 'apply_dynamic_link( $data['default'], $key ); + if ( ! empty( $link ) ) { + $_value = preg_replace( '/().*?(<\/a>)/', '$1' . $_value . '$2', $link ); + } + } + + $updated_value[] = $_value; + } + return $updated_value; + } + if ( isset( $data['before'] ) || isset( $data['after'] ) ) { $value = $this->apply_formatting( $value, $data ); } @@ -310,7 +438,7 @@ public function apply_formatting( $value, $data ) { * @param array $data Dynamic request. * @param bool $magic_tags Is a request for Magic Tags. * - * @return string + * @return string|string[] */ public function get_data( $data, $magic_tags ) { if ( ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || true === $magic_tags ) { @@ -610,11 +738,12 @@ public static function query_string_to_array( $qry ) { /** * Apply dynamic data. * - * @param array $data Dynamic request. + * @param array $data Dynamic request. + * @param int|null $key Optional key for multiple dynamic links in the same content. * - * @return string + * @return string|string[] */ - public function apply_link( $data ) { + public function apply_link( $data, $key = null ) { $link = $this->get_link( $data ); if ( empty( $link ) ) { @@ -627,6 +756,23 @@ public function apply_link( $data ) { $attrs = 'target="_blank"'; } + if ( is_array( $link ) ) { + $value = array(); + foreach ( $link as $link_item ) { + $value[] = sprintf( + '%s', + esc_url( $link_item ), + $attrs, + wp_kses_post( $data['text'] ) + ); + } + + if ( null !== $key ) { + return isset( $value[ $key ] ) ? $value[ $key ] : ''; + } + + return $value; + } $value = sprintf( '%s', esc_url( $link ), @@ -642,7 +788,7 @@ public function apply_link( $data ) { * * @param array $data Dynamic request. * - * @return string|void + * @return string|string[]|void */ public function apply_link_button( $data ) { if ( ! isset( $data[0] ) ) { @@ -666,7 +812,7 @@ public function apply_link_button( $data ) { * * @param array $data Dynamic request. * - * @return string|void + * @return string|string[]|void */ public function get_link( $data ) { if ( ! isset( $data['type'] ) ) { diff --git a/plugins/otter-pro/inc/plugins/class-dynamic-content.php b/plugins/otter-pro/inc/plugins/class-dynamic-content.php index 65ee6fdbb..6ed7596e9 100644 --- a/plugins/otter-pro/inc/plugins/class-dynamic-content.php +++ b/plugins/otter-pro/inc/plugins/class-dynamic-content.php @@ -213,14 +213,25 @@ public function get_post_meta( $data ) { * * @param array $data Dynamic Data. * - * @return string + * @return string|string[] */ public function get_acf( $data ) { $default = isset( $data['default'] ) ? esc_html( $data['default'] ) : ''; $meta = ''; - if ( isset( $data['metaKey'] ) ) { - $meta = get_field( esc_html( $data['metaKey'] ), $data['context'], true ); + if ( ! isset( $data['metaKey'] ) ) { + return $default; + } + + $field_key = esc_html( $data['metaKey'] ); + $meta = get_field( $field_key, $data['context'] ); + + // Get the ACF repeater's sub-field value. + if ( ( null === $meta || false === $meta || '' === $meta ) && function_exists( 'acf_get_field' ) ) { + $sub_value = $this->get_acf_repeater_sub_field( $field_key, $data['context'] ); + if ( null !== $sub_value ) { + return $sub_value; + } } if ( is_array( $meta ) ) { @@ -256,6 +267,177 @@ public function get_acf( $data ) { return esc_html( $meta ); } + /** + * Get ACF Repeater Sub-field Values. + * + * @param string $sub_field_key ACF field key of the targeted sub-field. + * @param int $post_id Post ID. + * @return string[]|null Array of sub-field values, or null on failure. + */ + private function get_acf_repeater_sub_field( $sub_field_key, $post_id ) { + $field = acf_get_field( $sub_field_key ); + if ( ! $field || ! isset( $field['parent'] ) ) { + return null; + } + + $path = array( $field['name'] ); + $cursor = $field; + + while ( true ) { + $parent = acf_get_field( (int) $cursor['parent'] ); + if ( ! $parent ) { + break; + } + array_unshift( $path, $parent['name'] ); + $cursor = $parent; + } + + // The top-level ancestor must be a repeater field. + if ( 'repeater' !== $cursor['type'] ) { + return null; + } + + $rows = get_field( $cursor['key'], $post_id ); + if ( ! is_array( $rows ) || empty( $rows ) ) { + return null; + } + + // $path[0] is the top-level repeater's own name — already consumed by + // get_field(). Pass the remainder as the drill-down path. + $values = $this->collect_acf_sub_field_values( $rows, array_slice( $path, 1 ) ); + + if ( empty( $values ) ) { + return null; + } + + $items = array(); + foreach ( $values as $v ) { + $items[] = esc_html( $v ); + } + + return $items; + } + + /** + * Recursively collect values of a specific sub-field from ACF repeater rows. + * + * @param mixed $rows Repeater rows at the current depth level. + * @param string[] $path Remaining field names leading to the target leaf. + * @return string[] Flat array of unescaped leaf string values. + */ + private function collect_acf_sub_field_values( $rows, $path ) { + if ( empty( $path ) || ! is_array( $rows ) ) { + return array(); + } + + $field_name = array_shift( $path ); + $collected = array(); + + foreach ( $rows as $row ) { + if ( ! is_array( $row ) || ! isset( $row[ $field_name ] ) ) { + continue; + } + + $value = $row[ $field_name ]; + + if ( empty( $path ) ) { + // Leaf node: collect non-empty strings. + if ( is_string( $value ) && '' !== $value ) { + $collected[] = $value; + } + } elseif ( is_array( $value ) ) { + // Intermediate node pointing to a nested repeater. + $collected = array_merge( + $collected, + $this->collect_acf_sub_field_values( $value, $path ) + ); + } + } + + return $collected; + } + + /** + * Get ACF Repeater Sub-field Image Values. + * + * @param string $sub_field_key ACF field key of the image sub-field. + * @param int $post_id Post ID. + * @return mixed[] Flat array of raw image values, or empty array on failure. + */ + private function get_acf_repeater_sub_field_image( $sub_field_key, $post_id ) { + if ( ! function_exists( 'acf_get_field' ) ) { + return array(); + } + + $field = acf_get_field( $sub_field_key ); + if ( ! $field || ! isset( $field['parent'] ) ) { + return array(); + } + + // Walk up the ancestor chain to find the top-level repeater. + $path = array( $field['name'] ); + $cursor = $field; + + while ( true ) { + $parent = acf_get_field( (int) $cursor['parent'] ); + if ( ! $parent ) { + break; + } + array_unshift( $path, $parent['name'] ); + $cursor = $parent; + } + + if ( 'repeater' !== $cursor['type'] ) { + return array(); + } + + $rows = get_field( $cursor['key'], $post_id ); + if ( ! is_array( $rows ) || empty( $rows ) ) { + return array(); + } + + return $this->collect_acf_sub_field_images( $rows, array_slice( $path, 1 ) ); + } + + /** + * Recursively collect image values of a specific sub-field from ACF repeater rows. + * + * @param mixed $rows Repeater rows at the current depth level. + * @param string[] $path Remaining field names leading to the target leaf. + * @return mixed[] Flat array of raw image values. + */ + private function collect_acf_sub_field_images( $rows, $path ) { + if ( empty( $path ) || ! is_array( $rows ) ) { + return array(); + } + + $field_name = array_shift( $path ); + $collected = array(); + + foreach ( $rows as $row ) { + if ( ! is_array( $row ) || ! isset( $row[ $field_name ] ) ) { + continue; + } + + $value = $row[ $field_name ]; + + if ( empty( $path ) ) { + // Leaf node: collect any non-empty image value. + if ( ! empty( $value ) ) { + $collected[] = $value; + } + } elseif ( is_array( $value ) ) { + // Intermediate node: recurse into nested repeater rows. + $collected = array_merge( + $collected, + $this->collect_acf_sub_field_images( $value, $path ) + ); + } + } + + return $collected; + } + /** * Get Author Meta. * @@ -478,6 +660,15 @@ public function evaluate_content_media_server( $path, $request ) { if ( 'acf' === $type && ! empty( $meta ) && class_exists( 'ACF' ) ) { $field = get_field( $meta, $context ); + // Fall back to repeater sub-field traversal when the field is a + // sub-field inside a repeater (get_field() returns nothing for those). + if ( empty( $field ) ) { + $images = $this->get_acf_repeater_sub_field_image( $meta, $context ); + if ( ! empty( $images ) ) { + $field = $images[0]; + } + } + if ( ! empty( $field ) ) { if ( is_array( $field ) && isset( $field['ID'] ) ) { $path = wp_get_original_image_path( $field['ID'] ); @@ -509,7 +700,7 @@ public function evaluate_content_media_server( $path, $request ) { * @param array $data Request data. * * @since 2.0.9 - * @return string + * @return string|string[] */ public function evaluate_content_media_content( $value, $data ) { if ( 'postMeta' === $data['type'] && isset( $data['meta'] ) && ! empty( $data['meta'] ) ) { @@ -538,18 +729,21 @@ public function evaluate_content_media_content( $value, $data ) { if ( 'acf' === $data['type'] && ! empty( $data['meta'] ) && class_exists( 'ACF' ) ) { $field = get_field( $data['meta'], $data['context'] ); - if ( ! empty( $field ) ) { - if ( is_array( $field ) && isset( $field['url'] ) ) { - $value = $field['url']; - } - - if ( is_string( $field ) ) { - $value = $field; + // Fall back to repeater sub-field traversal when the field is a + // sub-field inside a repeater (get_field() returns nothing for those). + if ( empty( $field ) ) { + $images = $this->get_acf_repeater_sub_field_image( $data['meta'], $data['context'] ); + if ( ! empty( $images ) ) { + $value = array(); + foreach ( $images as $image ) { + $value[] = $this->get_attachment_url( $image ); + } + return $value; } + } - if ( is_int( $field ) ) { - $value = wp_get_attachment_image_url( $field, 'full' ); - } + if ( ! empty( $field ) ) { + $value = $this->get_attachment_url( $field ); } } @@ -585,6 +779,29 @@ public function get_image_id_from_url( $url ) { return false; } + /** + * Get attachment URL from ACF field value. + * + * @param mixed $field ACF field value. + * @return string URL of the attachment, or empty string if it cannot be determined. + */ + private function get_attachment_url( $field ) { + $value = ''; + if ( is_array( $field ) && isset( $field['url'] ) ) { + $value = $field['url']; + } + + if ( is_string( $field ) ) { + $value = $field; + } + + if ( is_int( $field ) ) { + $value = wp_get_attachment_image_url( $field, 'full' ); + } + + return esc_url( $value ); + } + /** * The instance method for the static class. * Defines and returns the instance of the static class. diff --git a/src/pro/components/acf-image-select/index.js b/src/pro/components/acf-image-select/index.js index ceeb215ce..dce04261f 100644 --- a/src/pro/components/acf-image-select/index.js +++ b/src/pro/components/acf-image-select/index.js @@ -4,37 +4,28 @@ import { __ } from '@wordpress/i18n'; import { + BaseControl, Placeholder, - SelectControl, Spinner } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; +import { flattenACFFieldOptions } from '../../helpers/acf-field-options'; + +const ALLOWED_ACF_IMAGE_TYPES = [ 'image', 'url' ]; + const ACFImageSelect = ({ label, value, onChange }) => { - const { fields, isLoaded } = useSelect( select => { + const { groups, isLoaded } = useSelect( select => { const { groups } = select( 'otter-pro' ).getACFData(); const isLoaded = select( 'otter-pro' ).isACFLoaded(); - const fields = []; - - groups.forEach( group => { - group.fields.forEach( field => { - if ( 'image' === field.type || 'url' === field.type ) { - fields.push({ - label: field.label, - value: field.key - }); - } - }); - }); - return { - fields, + groups, isLoaded }; }, []); @@ -44,18 +35,24 @@ const ACFImageSelect = ({ } return ( - + + + ); }; diff --git a/src/pro/helpers/acf-field-options.js b/src/pro/helpers/acf-field-options.js new file mode 100644 index 000000000..0b0bd4f87 --- /dev/null +++ b/src/pro/helpers/acf-field-options.js @@ -0,0 +1,59 @@ +/** + * Shared ACF field option renderer. + * + * Centralises the logic for flattening ACF field trees into a valid flat list + * of is + * invalid HTML and not supported by browsers). + * + * @param {Array} fields ACF field objects at the current nesting level. + * @param {string[]} allowedTypes ACF field types to render as selectable , + // Sub-fields indented one level deeper. + ...flattenACFFieldOptions( subFields || [], allowedTypes, depth + 1 ) + ]; + } + + if ( allowedTypes.includes( type ) ) { + return [ + + ]; + } + + return []; + } ); +}; diff --git a/src/pro/plugins/data/index.js b/src/pro/plugins/data/index.js index 087d30ff6..d05f47e1c 100644 --- a/src/pro/plugins/data/index.js +++ b/src/pro/plugins/data/index.js @@ -148,20 +148,32 @@ registerStore( 'otter-pro', { if ( data?.success ) { const { groups } = data; - const fields = groups - ?.map( ({ fields, data }) => { - return fields.map( field => { - field.urlLocation = `${ window.themeisleGutenberg?.rootUrl || '' }/wp-admin/post.php?post=${ data.ID }&action=edit`; - return field; - }); - }) - .flat() - .reduce( ( acc, field ) => { + + /** + * Recursively flattens ACF fields, including sub-fields, into a single-level object keyed by field key. + * + * @param {Array} fieldList - Array of ACF field objects. + * @param {string} urlLocation - Admin edit URL for the parent group. + * @param {Object} acc - Accumulator object. + * @return {Object} The populated accumulator. + */ + const flattenFields = ( fieldList, urlLocation, acc = {} ) => { + fieldList.forEach( field => { if ( field.key && field.label ) { - acc[ field.key ] = pick( field, [ 'label', 'type', 'prepend', 'append', 'default_value', 'value', 'urlLocation' ]); + field.urlLocation = urlLocation; + acc[ field.key ] = pick( field, [ 'label', 'type', 'prepend', 'append', 'default_value', 'value', 'urlLocation', 'sub_fields' ]); + } + if ( field.sub_fields?.length ) { + flattenFields( field.sub_fields, urlLocation, acc ); } - return acc; - }, {}); + }); + return acc; + }; + + const fields = groups?.reduce( ( acc, { fields: groupFields, data: groupData }) => { + const urlLocation = `${ window.themeisleGutenberg?.rootUrl || '' }/wp-admin/post.php?post=${ groupData.ID }&action=edit`; + return flattenFields( groupFields, urlLocation, acc ); + }, {}); return actions.setACFData( groups, fields, true ); } diff --git a/src/pro/plugins/dynamic-content/link-edit.js b/src/pro/plugins/dynamic-content/link-edit.js index c56a31de0..4ad0a68e0 100644 --- a/src/pro/plugins/dynamic-content/link-edit.js +++ b/src/pro/plugins/dynamic-content/link-edit.js @@ -14,6 +14,8 @@ import { useSelect } from '@wordpress/data'; import { Fragment } from '@wordpress/element'; +import { flattenACFFieldOptions } from '../../helpers/acf-field-options'; + const ALLOWED_ACF_TYPES = [ 'url' ]; @@ -55,16 +57,7 @@ const Edit = ({ key={ group?.data?.key } label={ group?.data?.title } > - { group?.fields - ?.filter( ({ key, label, type }) => key && label && ALLOWED_ACF_TYPES.includes( type ) ) - .map( ({ key, label }) => ( - - ) ) } + { flattenACFFieldOptions( group?.fields || [], ALLOWED_ACF_TYPES ) } ); }) } diff --git a/src/pro/plugins/dynamic-content/value-edit.js b/src/pro/plugins/dynamic-content/value-edit.js index 0a2a5f46c..8ee688441 100644 --- a/src/pro/plugins/dynamic-content/value-edit.js +++ b/src/pro/plugins/dynamic-content/value-edit.js @@ -17,6 +17,8 @@ import { useSelect } from '@wordpress/data'; import { Fragment } from '@wordpress/element'; +import { flattenACFFieldOptions } from '../../helpers/acf-field-options'; + const ALLOWED_ACF_TYPES = [ 'button_group', 'checkbox', @@ -255,16 +257,7 @@ const Edit = ({ key={ group?.data?.key } label={ group?.data?.title } > - { group?.fields - ?.filter( ({ key, label, type }) => key && label && ALLOWED_ACF_TYPES.includes( type ) ) - .map( ({ key, label }) => ( - - ) ) } + { flattenACFFieldOptions( group?.fields || [], ALLOWED_ACF_TYPES ) } ); }) }