AEM Edge Delivery - Universal Editor Custom Data Type Context Aware Spreadsheet Styles


Universal Editor Properties Panel Extension for Custom Data Type eaem:multi-style-picker  to load styles from a spreadsheet config eg. /content/eaem-dev-eds/block-styles.html

1) Styles are added in a spreadsheet at any level of site structure eg. /content/eaem-dev-eds/block-styles.html

2) Spreadsheet node name is configured in block model eg. "sourceAEMNodeName" : "block-styles"

3) Custom Data Type eaem:multi-style-picker tries to fetch styles with a bottom-up configuration look-up

4) Selected styles are applied in Block Decoration JS





Configure Styles in SpreadSheet



Styles Applied





1) For detailed instructions on setting up Universal Editor Extension and Publishing to your Org check this post, steps below load it from a locally running app so https://localhost:9080 

2) Open terminal, login to aio and create an app. While going through the prompts make sure you select the Universal Editor Extension Template and Add a Custom Renderer... 

                              > aio login

                              > aio app init eaem-ue-multi-style-picker

3) Add the following code in src\universal-editor-ui-1\web-src\src\components\ExtensionRegistration.js, make note of the dataType set to  eaem:multi-style-picker

import { Text } from "@adobe/react-spectrum";
import { register } from "@adobe/uix-guest";
import { extensionId } from "./Constants";
import metadata from '../../../../app-metadata.json';

function ExtensionRegistration() {
  const init = async () => {
    const guestConnection = await register({
      id: extensionId,
      metadata,
      methods: {
        canvas: {
          getRenderers() {
            return [
              {
                extension: extensionId,
                dataType: 'eaem:multi-style-picker',
                url: '/index.html#/multi-style-picker'
              }
            ];
          },
        },
      },
    });
  };
  init().catch(console.error);

  return <Text>IFrame for integration with Host (AEM)...</Text>
}

export default ExtensionRegistration;


4) Add a route /index.html#/multi-style-picker for the extension in src\universal-editor-ui-1\web-src\src\components\App.js

          <Route exact path="index.html" element={<ExtensionRegistration />} />
          <Route exact path="multi-style-picker" element={<MultiStylePicker />} />


5) Add the Context Aware Multi Select Style code in src\universal-editor-ui-1\web-src\src\components\MultiStylePicker.js for reading the styles and fill the dropdown...

import React, { useState, useEffect } from 'react'
import { attach } from "@adobe/uix-guest"
import {
  Provider,
  defaultTheme,
  View,
  ComboBox,
  Flex,
  ActionButton,
  Text,
  Item
} from '@adobe/react-spectrum'
import Close from '@spectrum-icons/workflow/Close'

import { extensionId } from "./Constants"

const PICKLIST_CONFIG_NODE_NAME = 'block-styles';
const STYLE_NAME_KEY = 'Style Name';
const STYLE_CLASS_KEY = 'Style Class';

export default function MultiStylePicker () {
  const [guestConnection, setGuestConnection] = useState()
  const [textValue, setTextValue] = useState('');
  const [options, setOptions] = useState([]);
  const [loading, setLoading] = useState(true);
  const [label, setLabel] = useState('Select Style');

  const findPicklistConfig = async (AEM_HOST, parentPath, connection, configFileName) => {
    let searchPath = parentPath;
   
    // Walk up the hierarchy until we find config file or reach /content
    while (searchPath && searchPath.startsWith('/content')) {
      const configUrl = `${AEM_HOST}${searchPath}/${configFileName}`;
     
      const requestOptions = {
        headers: {
          'Authorization': `Bearer ${connection.sharedContext.get("token")}`
        }
      };
     
      try {
        const response = await fetch(configUrl, requestOptions);
       
        if (response.status !== 404) {
          console.log(`Found ${configFileName} at: ${searchPath}/${configFileName}`);
          return `${searchPath}/${configFileName}`;
        }
      } catch (error) {
        console.log(`Error fetching ${searchPath}/${configFileName}:`, error);
      }
     
      if (searchPath === '/content') {
        break;
      }
      searchPath = searchPath.substring(0, searchPath.lastIndexOf('/')) || '/content';
    }
   
    console.log(`No ${configFileName} found in hierarchy`);

    return null;
  };

  const fetchPicklistOptions = async (AEM_HOST, configPath, connection) => {
    if (!configPath) return [];
   
    const configUrl = `${AEM_HOST}${configPath}`;
    const requestOptions = {
      headers: {
        'Authorization': `Bearer ${connection.sharedContext.get("token")}`
      }
    };
   
    try {
      const response = await fetch(configUrl, requestOptions);
     
      if (response.status === 404) {
        console.log('Config file not found (404)');
        return [];
      }
     
      const configData = await response.json();
     
      let optionsList = [];
     
      if (configData && configData['jcr:content']) {
        const jcrContent = configData['jcr:content'];
       
        for (const key in jcrContent) {
          const node = jcrContent[key];
         
          if (typeof node === 'object' && node !== null && node['jcr:primaryType'] === 'nt:unstructured') {
            const styleName = node[STYLE_NAME_KEY];
            const styleClass = node[STYLE_CLASS_KEY];
           
            if (styleName && styleClass) {
              optionsList.push({
                label: styleName,
                value: styleClass
              });
            }
          }
        }
      }
     
      return optionsList;
    } catch (error) {
      console.error('Error fetching or parsing config:', error);
      return [];
    }
  };

  const getAemHost = (editorState) => {
    let host = editorState.connections.aemconnection.substring(editorState.connections.aemconnection.indexOf('xwalk:') + 6);
   
    if (host.includes('?ref=')) {
      host = host.split('?ref=')[0];
    }
   
    return host;
  }

  const getParentPagePath = (url) => {
    if (!url) return '/content';
   
    try {
      const urlObj = new URL(url);
      const pathname = urlObj.pathname;
     
      return pathname.substring(0,pathname.lastIndexOf('.html')).substring(0,pathname.lastIndexOf('/'));
    } catch (error) {
      console.error('Error parsing page URL:', error);
      return '/content';
    }
  }

  useEffect(() => {
    (async () => {
      const connection = await attach({ id: extensionId })
      setGuestConnection(connection);

      const model = await connection.host.field.getModel();

      if (model.label) {
        setLabel(model.label);
      }

      let configFileName = PICKLIST_CONFIG_NODE_NAME;
     
      if (model.sourceAEMNodeName) {
        configFileName = model.sourceAEMNodeName;
      }

      configFileName = `${configFileName}.2.json`;

      const currentValue = await connection.host.field.getValue() || '';
      setTextValue(currentValue);

      const editorState = await connection.host.editorState.get();

      if(editorState){
        const aemHost = getAemHost(editorState);

        const parentPath = getParentPagePath(editorState.location);
       
        const configPath = await findPicklistConfig(aemHost, parentPath, connection, configFileName);

        const picklistOptions = await fetchPicklistOptions(aemHost, configPath, connection);

        setOptions(picklistOptions);
       
        setLoading(false);
      }
    })()
  }, [])

  const handleSelectionChange = (selectedValue) => {
    if (!selectedValue) return;
   
    const existingTags = textValue ? textValue.split(' ').map(v => v.trim()).filter(v => v) : [];
   
    if (existingTags.includes(selectedValue)) {
      return;
    }
   
    const newValue = existingTags.length > 0 ? `${textValue.trim()} ${selectedValue}` : selectedValue;
   
    setTextValue(newValue);
    guestConnection?.host.field.onChange(newValue);
  }

  const handleTagRemove = (tagToRemove) => {
    const tags = textValue ? textValue.split(' ').map(v => v.trim()).filter(v => v) : [];
    const remainingTags = tags.filter(tag => tag !== tagToRemove);
    const newValue = remainingTags.join(' ');
    setTextValue(newValue);
    guestConnection?.host.field.onChange(newValue);
  }

  const selectedTags = textValue
    ? [...new Set(textValue.split(' ').map(v => v.trim()).filter(v => v))]
    : [];
 
  const getStyleName = (styleClass) => {
    const option = options.find(opt => opt.value === styleClass);
    return option ? option.label : styleClass;
  };

  return (
    <Provider theme={defaultTheme}  colorScheme='dark'>
      <View UNSAFE_style={{ overflow: 'hidden', padding: '16px 16px 16px 0' }}>
        <ComboBox
          onSelectionChange={handleSelectionChange}
          label={label}
          isDisabled={loading}
          width="100%"
        >
          {options.map(option => (
            <Item key={option.value}>{option.label}</Item>
          ))}
        </ComboBox>
        <View UNSAFE_style={{ marginTop: '16px' }}>
          <Text UNSAFE_style={{ display: 'block', marginBottom: '8px', fontSize: '12px' }}>
            Selected Styles
          </Text>
          <Flex wrap gap="size-100">
            {selectedTags.map(tag => (
              <View
                key={tag}
                UNSAFE_style={{
                  display: 'inline-flex',
                  alignItems: 'center',
                  gap: '4px',
                  padding: '4px 8px',
                  backgroundColor: '#3a3a3a',
                  border: '1px solid #6b6b6b',
                  borderRadius: '4px',
                  fontSize: '13px',
                  color: '#fff'
                }}
              >
                <Text>{getStyleName(tag)}</Text>
                <ActionButton
                  isQuiet
                  onPress={() => handleTagRemove(tag)}
                  UNSAFE_style={{
                    minWidth: '10px',
                    minHeight: '10px',
                    width: '10px',
                    height: '10px',
                    cursor: 'pointer',
                    padding: 0
                  }}
                >
                  <Close UNSAFE_style={{ width: '8px', height: '8px' }} />
                </ActionButton>
              </View>
            ))}
          </Flex>
        </View>
      </View>
    </Provider>
  )
}


6) Set the custom dataType as "component": "eaem:multi-style-picker" in your Block Model blocks\hero\_hero.json

"models": [
    {
      "id": "hero",
      "fields": [    
        ...
        {
          "component": "eaem:multi-style-picker",
          "name": "eaemBlockStyles",
          "value": "",
          "label": "Block Styles",
          "valueType": "string",
          "sourceAEMNodeName" : "block-styles"
        },
        {
          "component": "eaem:multi-style-picker",
          "name": "eaemTextStyles",
          "value": "",
          "label": "Text Styles",
          "valueType": "string",
          "sourceAEMNodeName" : "text-styles"
        }
      ]
    }
  ]


7) The block rendering needs decoration to read the selected styles and apply on rendered markup blocks\hero\hero.js

export default function decorate(block) {
  const paragraphs = block.querySelectorAll('p');
 
  paragraphs.forEach((p) => {
    const text = p.textContent.trim();
   
    // Check if text starts with "eaem-block"
    if (text.startsWith('eaem-block')) {
      const classNames = text.split(/\s+/).filter(cls => cls.startsWith('eaem-block'));
     
      classNames.forEach(className => {
        block.classList.add(className);
      });
     
      const row = p.closest('div.hero > div');
      if (row) {
        row.remove();
      }
    }
   
    // Check if text starts with "eaem-text"
    if (text.startsWith('eaem-text')) {
      const classNames = text.split(/\s+/).filter(cls => cls.startsWith('eaem-text'));
     
      const textDiv = block.querySelector('[data-aue-prop="text"]');
      if (textDiv) {
        classNames.forEach(className => {
          textDiv.classList.add(className);
        });
      }
     
      const row = p.closest('div.hero > div');
      if (row) {
        row.remove();
      }
    }
  });
}

No comments:

Post a Comment