Skip to content

Commit

Permalink
chore: updates
Browse files Browse the repository at this point in the history
  • Loading branch information
adamstankiewicz committed Aug 19, 2024
1 parent 263841c commit ae7f974
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 73 deletions.
14 changes: 13 additions & 1 deletion env.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
const config = {
JS_FILE_VAR: 'JS_FILE_VAR_VALUE_FOR_EXAMPLE_APP',
componentPropOverrides: {
selectors: {
targets: {
example: {
'data-dd-privacy': 'mask', // Custom `data-*` attribute (e.g., Datadog)
'data-hj-suppress': '', // Custom `data-*` attribute (e.g., Hotjar)
Expand All @@ -20,6 +20,18 @@ const config = {
},
},
example2: {
'data-dd-privacy': 'mask', // Custom `data-*` attribute (e.g., Datadog)
'data-hj-suppress': '', // Custom `data-*` attribute (e.g., Hotjar)
className: 'fs-mask', // Custom `className` attribute (e.g., Fullstory)
onClick: (e) => { // Custom `onClick` attribute
console.log('[env.config] onClick event for example2', e);
},
style: { // Custom `style` attribute
background: 'blue',
color: 'white',
},
},
example3: {
'data-dd-action-name': 'example name', // Custom `data-*` attribute (e.g., Datadog)
},
},
Expand Down
90 changes: 66 additions & 24 deletions example/ComponentPropOverridesPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,23 @@ import PropTypes from 'prop-types';
import { AppContext, useComponentPropOverrides, withComponentPropOverrides } from '@edx/frontend-platform/react';

// Example via `useComponentPropOverrides` (hook)
const ExampleComponentWithPropOverrides = forwardRef(({ children, ...rest }, ref) => {
const ExampleComponentWithDefaultPropOverrides = forwardRef(({ children, ...rest }, ref) => {
const propOverrides = useComponentPropOverrides('example', rest);
return <span ref={ref} {...propOverrides}>{children}</span>;
});
ExampleComponentWithPropOverrides.displayName = 'ExampleComponentWithPropOverrides';
ExampleComponentWithPropOverrides.propTypes = {
ExampleComponentWithDefaultPropOverrides.displayName = 'ExampleComponentWithDefaultPropOverrides';
ExampleComponentWithDefaultPropOverrides.propTypes = {
children: PropTypes.node.isRequired,
};

const ExampleComponentWithAllowedPropOverrides = forwardRef(({ children, ...rest }, ref) => {
const propOverrides = useComponentPropOverrides('example2', rest, {
allowedPropNames: ['className', 'style', 'onClick'],
});
return <span ref={ref} {...propOverrides}>{children}</span>;
});
ExampleComponentWithAllowedPropOverrides.displayName = 'ExampleComponentWithAllowedPropOverrides';
ExampleComponentWithAllowedPropOverrides.propTypes = {
children: PropTypes.node.isRequired,
};

Expand All @@ -23,7 +34,7 @@ ExampleComponent.displayName = 'ExampleComponent';
ExampleComponent.propTypes = {
children: PropTypes.node.isRequired,
};
const ExampleComponentWithPropOverrides2 = withComponentPropOverrides('example2')(ExampleComponent);
const ExampleComponentWithPropOverrides3 = withComponentPropOverrides('example3')(ExampleComponent);

function jsonStringify(obj) {
const replacer = (key, value) => {
Expand All @@ -35,22 +46,28 @@ function jsonStringify(obj) {
return JSON.stringify(obj, replacer, 2);
}

export default function ComponentPropOverridesPage() {
const { config } = useContext(AppContext);
const firstRef = useRef(null);
const secondRef = useRef(null);
const [firstNode, setFirstNode] = useState(null);
const [secondNode, setSecondNode] = useState(null);
function useExample() {
const ref = useRef(null);
const [node, setNode] = useState(null);

useEffect(() => {
if (firstRef.current) {
setFirstNode(firstRef.current.outerHTML);
}
if (secondRef.current) {
setSecondNode(secondRef.current.outerHTML);
if (ref.current) {
setNode(ref.current.outerHTML);
}
}, []);

return {
ref,
node,
};
}

export default function ComponentPropOverridesPage() {
const { config } = useContext(AppContext);
const firstExample = useExample();
const secondExample = useExample();
const thirdExample = useExample();

const { componentPropOverrides } = config;

return (
Expand Down Expand Up @@ -79,31 +96,56 @@ export default function ComponentPropOverridesPage() {

{/* Example 1 (useComponentPropOverrides) */}
<h3><code>useComponentPropOverrides</code> (hook)</h3>
<h4>Default support prop overrides</h4>
<p>
<ExampleComponentWithPropOverrides
ref={firstRef}
By default, only <code>data-*</code> attributes and <code>className</code> props are
supported; other props will be ignored. You may opt-in to non-default prop
overrides by extending the <code>allowedPropNames</code> option.
</p>
<p>
<ExampleComponentWithDefaultPropOverrides
ref={firstExample.ref}
// eslint-disable-next-line no-console
onClick={(e) => console.log('ExampleComponentWithPropOverrides clicked', e)}
style={{ borderBottom: '4px solid red' }}
className="example-class"
>
Example 1
</ExampleComponentWithPropOverrides>
</ExampleComponentWithDefaultPropOverrides>
</p>
<i>Result:</i>{' '}
<pre>
<code>{firstNode}</code>
<code>{firstExample.node}</code>
</pre>

{/* Example 2 (withComponentPropOverrides) */}
<h3><code>withComponentPropOverrides</code> (HOC)</h3>
{/* Example 2 (useComponentPropOverrides) */}
<h4>Opt-in to specific prop overrides with <code>allowedPropNames</code></h4>
<p>
<ExampleComponentWithPropOverrides2 ref={secondRef}>
<ExampleComponentWithAllowedPropOverrides
ref={secondExample.ref}
// eslint-disable-next-line no-console
onClick={(e) => console.log('ExampleComponentWithPropOverrides clicked', e)}
style={{ borderBottom: '4px solid red' }}
className="example-class"
>
Example 2
</ExampleComponentWithPropOverrides2>
</ExampleComponentWithAllowedPropOverrides>
</p>
<i>Result:</i>{' '}
<pre>
<code>{secondExample.node}</code>
</pre>

{/* Example 3 (withComponentPropOverrides) */}
<h3><code>withComponentPropOverrides</code> (HOC)</h3>
<p>
<ExampleComponentWithPropOverrides3 ref={thirdExample.ref}>
Example 3
</ExampleComponentWithPropOverrides3>
</p>
<i>Result:</i>{' '}
<pre>
<code>{secondNode}</code>
<code>{thirdExample.node}</code>
</pre>
</div>
);
Expand Down
83 changes: 52 additions & 31 deletions src/react/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const useTrackColorSchemeChoice = () => {

/**
* @typedef {object} ComponentPropOverrides
* @property {Record<string, ComponentPropOverride>} selectors - A mapping of element selectors to custom props.
* @property {Record<string, ComponentPropOverride>} targets - A mapping of component targets to custom props.
*/

/**
Expand All @@ -66,22 +66,29 @@ export const useTrackColorSchemeChoice = () => {
*/

/**
* A React hook that processes the given `selector` to extend/merge component props
* @typedef {object} ComponentPropOverridesOptions
* @property {string[]} [allowedPropNames] - The list of prop names allowed to be overridden.
* @property {boolean} [allowsDataAttributes] - Whether to allow `data-*` attributes to be applied.
*/

/**
* A React hook that processes the given `target` to extend/merge component props
* with any corresponding attributes/values based on configuration.
*
* This hook looks up the specified `selector` in the `componentPropOverrides` configuration,
* This hook looks up the specified `target` in the `componentPropOverrides` configuration,
* and if a match is found, it merges the component's props with the mapped attributes/values
* per the configured element selectors.
* per the configured component targets.
*
* @param {string} selector - The element selector used to identify custom props per configuration.
* @param {string} target - The component target used to identify custom props per configuration.
* @param {Record<string, any>} props - The original props object passed to the component.
* @param {ComponentPropOverridesOptions} [options] - Optional configuration for the hook.
* @returns {Record<string, any>} An updated props object with custom props merged in.
*
* @example
* // Given a configuration like:
* {
* componentPropOverrides: {
* selectors: {
* targets: {
* example: {
* 'data-dd-privacy': 'mask',
* 'data-hj-suppress': '',
Expand All @@ -100,38 +107,52 @@ export const useTrackColorSchemeChoice = () => {
* @memberof module:React
* @see module:React.withComponentPropOverrides
*/
export function useComponentPropOverrides(selector, props) {
export function useComponentPropOverrides(target, props, options = {}) {
/** @type {AppConfigWithComponentPropOverrides} */
const { componentPropOverrides } = getConfig();
if (!selector || !componentPropOverrides) {
const propOverridesForTarget = componentPropOverrides?.targets?.[target];
if (!target || !propOverridesForTarget) {
return props;
}

const {
// Allow for custom prop overrides to be applied to the component. These are
// separate from any `data-*` attributes, which are always supported.
allowedPropNames = ['className'],
// Allow for any `data-*` attributes to be applied to the component.
allowsDataAttributes = true,
} = options;

const updatedProps = { ...props };
Object.entries(componentPropOverrides.selectors).forEach(([currentSelector, currentSelectorConfig]) => {
if (currentSelector !== selector) {
// Skip if the specificed `currentSelector` does not match selector
// Apply the configured attributes/values/classes for the matched target
Object.entries(propOverridesForTarget).forEach(([attributeName, attributeValue]) => {
const isAllowedPropName = allowedPropNames.includes(attributeName);
const isDataAttribute = allowsDataAttributes && attributeName.startsWith('data-');
const isAllowedPropOverride = isAllowedPropName || isDataAttribute;
if (!isAllowedPropOverride) {
// Skip applying the override prop if it's not allowed.
return;
}
// Apply the configured attributes/values/classes for the matched selector
Object.entries(currentSelectorConfig).forEach(([attributeName, attributeValue]) => {
// Parse attributeValue as empty string if not provided, or falsey value is given (e.g., `undefined`).
let transformedAttributeValue = !attributeValue ? '' : attributeValue;
if (attributeName === 'className') {
// Append the `className` to the existing `className` prop value (if any)
transformedAttributeValue = [updatedProps.className, attributeValue].join(' ').trim();
} else if (attributeName === 'style' && typeof attributeValue === 'object') {
// Merge the `style` object with the existing `style` prop object (if any)
transformedAttributeValue = { ...updatedProps.style, ...attributeValue };
} else if (typeof attributeValue === 'function') {
// Merge the function with the existing prop's function
const oldFn = updatedProps[attributeName];
transformedAttributeValue = oldFn ? (...args) => {
oldFn(...args);
attributeValue(...args);
} : attributeValue;
}
updatedProps[attributeName] = transformedAttributeValue;
});

// Parse attributeValue as empty string if not provided, or falsey value is given (e.g., `undefined`).
let transformedAttributeValue = !attributeValue ? '' : attributeValue;
if (attributeName === 'className') {
// Append the `className` to the existing `className` prop value (if any)
transformedAttributeValue = [updatedProps.className, attributeValue].join(' ').trim();
} else if (attributeName === 'style' && typeof attributeValue === 'object') {
// Merge the `style` object with the existing `style` prop object (if any)
transformedAttributeValue = { ...updatedProps.style, ...attributeValue };
} else if (typeof attributeValue === 'function') {
// Merge the function with the existing prop's function
const oldFn = updatedProps[attributeName];
transformedAttributeValue = oldFn ? (...args) => {
oldFn(...args);
attributeValue(...args);
} : attributeValue;
}

// Update the props with the transformed attribute value
updatedProps[attributeName] = transformedAttributeValue;
});
return updatedProps;
}
9 changes: 5 additions & 4 deletions src/react/withComponentPropOverrides.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import { useComponentPropOverrides } from './hooks';
* the `useComponentPropOverrides` hook to merge any custom props from configuration with the
* actual component props.
*
* @param {string} [selector] - The selector used to identify any custom props for a given element.
* @param {string} [target] - The target used to identify any custom props for a given element.
* @param {import('./hooks').ComponentPropOverridesOptions} [options] - Optional configuration for the HOC.
*
* @example
* // Given a configuration like:
* {
* componentPropOverrides: {
* selectors: {
* targets: {
* example: {
* 'data-dd-privacy': 'mask',
* 'data-hj-suppress': '',
Expand All @@ -32,9 +33,9 @@ import { useComponentPropOverrides } from './hooks';
* @see module:React.useComponentPropOverrides
* @memberof module:React
*/
const withComponentPropOverrides = (selector) => (WrappedComponent) => {
const withComponentPropOverrides = (target, options = {}) => (WrappedComponent) => {
const WithComponentPropOverrides = forwardRef((props, ref) => {
const propOverrides = useComponentPropOverrides(selector, props);
const propOverrides = useComponentPropOverrides(target, props, options);
return <WrappedComponent ref={ref} {...propOverrides} />;
});
WithComponentPropOverrides.displayName = `withComponentPropOverrides(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
Expand Down
Loading

0 comments on commit ae7f974

Please sign in to comment.