openedx / frontend-platform

A framework for Open edX micro-frontend applications.
http://openedx.github.io/frontend-platform
GNU Affero General Public License v3.0
32 stars 63 forks source link

feat: support ability for consumers to add custom props / HTML attributes defined via configuration to components #723

Closed adamstankiewicz closed 3 weeks ago

adamstankiewicz commented 1 month ago

Description

Context

Open edX instances (e.g., 2U) rely on third-party tools for logging and analytics (e.g., Datadog, Hotjar). Some of these tools end up ingesting PII into things such as session replays, etc. that should otherwise be masked.

Example vendor-specific approaches to masking UI elements from their ingested data:

Currently, such attributes for Hotjar are hardcoded throughout the platform (search results), despite not all instances of Open edX using Hotjar. While these attributes are fairly benign, the current approach does not support extending the masking of PII to other vendors (e.g., Datadog).

Related, there are other vendor-specific HTML attributes and class names unrelated to PII this approach would also be applicable to. As such, the following implementation is generic to support "custom props" on annotated elements, regardless of whether it's for PII masking or otherwise.

Solution

Introduces a React hook (useComponentPropOverrides) and a Higher-Order-Component (withComponentPropOverrides) as a mechanism to allow consumers to add one or more custom props (e.g., to mask PII within session replays) to any component that accepts prop spreading (e.g., ...props).

Configuration

May be configured by env.config.js or the MFE runtime configuration with componentPropOverrides (open to other config property names):

{
  componentPropOverrides: {
    targets: {
      username: {
        '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)
      },
    },
  },
}

Usage

Consider the following JSX containing PII (i.e., username):

<p>Hi there, {authenticatedUser.username}.</p>

useComponentPropOverrides (hook)

function UsernameWithPropOverrides({ children, ...rest }) {
  const propOverrides = useComponentPropOverrides('username', rest);
  return <span {...propOverrides}>{children}</span>;
}
UsernameWithPropOverrides.propTypes = {
  children: PropTypes.node.isRequired,
};

export default function AuthenticatedPage() {
  const { authenticatedUser, config } = useContext(AppContext);
  return (
      <p>Hi there, <UsernameWithPropOverrides>{authenticatedUser.username}</UsernameWithPropOverrides>.</p>
  );
}

withComponentPropOverrides (HOC)

function Username({ children, ...rest }) {
  return <span {...rest}>{children}</span>;
}
Username.propTypes = {
  children: PropTypes.node.isRequired,
};
const UsernameWithPropOverrides = withComponentPropOverrides('username')(Username);

export default function AuthenticatedPage() {
  const { authenticatedUser, config } = useContext(AppContext);
  return (
      <p>Hi there, <UsernameWithPropOverrides>{authenticatedUser.username}</UsernameWithPropOverrides>.</p>
  );
}

By using useComponentPropOverrides (hook) or withComponentPropOverrides (HOC) to mask the username from third-party tools with the above componentPropOverrides configuration, the resulting HTML would render as the following:

<p>Hi there, <span data-dd-privacy="mask" data-hj-suppress="" class="fs-mask">edx</span>.</p>

It does this by attempting to find a match between the component's specified selector and the componentPropOverrides.targets.* defined via configuration. If the specified selector matches one of the selectors defined via configuration, any configured props and their values be returned.

Special cases

By default, components supporting configurable prop overrides only works with the prop className and any prop prefixed with data- (e.g., data-dd-privacy). Any other configuration prop name is ignored and not applied during rendering.

In certain cases, components may opt-in to supporting overrides of explicitly named prop names (e.g., onClick). By making the special cases like function handlers or style opt-in within the MFE's base code, it provides opportunity to discuss/review which prop overrides are officially supported in any given component beyond the default data-* attributes or className prop.

className

Custom class name(s) will be concatenated with any existing className prop values that might already be passed to the component.

style

Custom style properties will be shallow merged with any existing style properties that might already exist for the component. If the style properties overlap, the custom style's value takes precedence.

Functions (e.g., onClick)

If a custom prop is defined as a function (i.e., supported when customComponentProps is defined via env.config) but the component itself already has an onClick function handler, the component's onClick function will be executed first before calling the custom onClick handler. Both functions otherwise receive the same functions args.

Merge checklist:

Post merge:

codecov[bot] commented 1 month ago

Codecov Report

All modified and coverable lines are covered by tests :white_check_mark:

Project coverage is 83.89%. Comparing base (ba3ff7e) to head (5b53e4c). Report is 1 commits behind head on master.

Additional details and impacted files ```diff @@ Coverage Diff @@ ## master #723 +/- ## ========================================== + Coverage 83.41% 83.89% +0.48% ========================================== Files 40 41 +1 Lines 1073 1105 +32 Branches 197 210 +13 ========================================== + Hits 895 927 +32 Misses 166 166 Partials 12 12 ```

:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.

adamstankiewicz commented 3 weeks ago

Closed in favor of https://github.com/openedx/frontend-plugin-framework/pull/84