Demo

Component library

Rabobank Foundation

Atomic Design

Example

Atom

Molecule

Organism

Template

Page

Layout composition

Template

Slots

Storybook

Visual Regression

Chromatic

See requirements change

Catch mistakes

PR

Framework Agnostic

Omni Search

Omni Search

export interface SearchRequest {
  query: string;
  searchConfig: SearchConfig;
  tab?: string;
  numberOfResults?: number;
  page?: number;
}

export function request({ ... }: SearchRequest): Promise<Results> {
  // ...
  return fetch(
    "https://content-search-service.apps.pcf-p02-we.rabobank.nl/internal/omni-search",
    {
      method: "POST",
      body: JSON.stringify(requestbody),
      headers: {
        "Content-Type": "application/json",
      },
    }
  )
    ...
}

App shell

OmniSearch

OmniSearch

OmniSearch

Angular

// ...
import { request, Results, SearchRequest } from "@global-sites-poc/shared/search";

@Injectable({
  providedIn: "root",
})
export class OmniSearchService {
  search$: Observable<Results>;
  private results = new Subject<Results>();

  error$: Observable<unknown>;
  private error = new Subject<unknown>();

  constructor() {
    this.search$ = this.results.asObservable();
    this.error$ = this.error.asObservable();
  }

  search(searchRequest: SearchRequest): void {
    request(searchRequest)
      .then((results) => this.results.next(results))
      .catch((error) => this.error.next(error));
  }
}

React

// ...
import { request, Results, SearchRequest } from "@global-sites-poc/shared/search";

function useOmniSearch(
  initialRequest: SearchRequest
): [
  (request: SearchRequest | Setter<SearchRequest>) => void,
  Results | undefined
] {
  const [searchRequest, setSearchRequest] =
    useState<SearchRequest>(initialRequest);
  const [searchResponse, setSearchResponse] = useState<Results>();

  useEffect(() => {
    if (searchRequest) {
      request(searchRequest).then(setSearchResponse);
    }
  }, [searchRequest.query]);

  return [setSearchRequest, searchResponse];
}

App shell

OmniSearch

OmniSearch

OmniSearch

@Component({
  selector: "global-sites-poc-omni-search",
  templateUrl: "./omni-search.component.html",
  styleUrls: ["./omni-search.component.scss"],
})
export class OmniSearchComponent implements OnInit {
  @Input() searchConfig: SearchConfig;
  search$: Observable<Results>;

  constructor(private omniSearch: OmniSearchService) {
    this.search$ = omniSearch.search$;
  }

  ngOnInit(): void {
    this.search();
  }

  onSearch(event: CustomEvent<string>): void {
    this.search({
      query: event.detail,
    });
  }

  private search(request: Partial<SearchRequest> = {}) {
    this.omniSearch.search({
      query: "",
      searchConfig: this.searchConfig,
      numberOfResults: 4,
      ...request,
    });
  }
}

Angular

<ng-container *ngIf="search$ | async as searchResponse">
  <omni-search
    pageDetailsText="Pagina's"
    [searchResults]="searchResponse.results"
    [totalCount]="searchResponse.totalCount"
  ></omni-search>
</ng-container>
export const OmniSearchContainer: React.FunctionComponent<{ slot: string }> = ({
  slot,
}) => {
  const [search, response] = useOmniSearch({
    query: "",
    searchConfig: "rabobanknl_nl",
    numberOfResults: 4,
  });

  const onSearch = (event: CustomEvent<string>) =>
    search((request: SearchRequest) => ({
      ...request,
      query: event.detail,
    }));

  return (
    <OmniSearch
      slot={slot}
      searchResults={response?.results ?? []}
      onDoSearch={onSearch}
      pageDetailsText={"Pagina's"}
    />
  );
};

React

Dynamic content

App shell

CMS

CMS Response

export interface Page {
    id: string;
    title: string;
    // ...
    redirect: Page_Redirect;
    view: PageView;
    regions: Page_Region[];
    // ...
}

export interface Page_Region {
    view: RegionView;
    entities: Page_Entity[];
    regions: Page_Region[];
}
export interface Page_Entity {
    type: EntityType;
    view: EntityView;
    content: any;
    id: Page_ComponentId;
    entityType: Page_Entity_Type;
}

Template

Slot

Component

CMS Response

export interface Page {
    id: string;
    title: string;
    // ...
    redirect: Page_Redirect;
    view: PageView;
    regions: Page_Region[];
    // ...
}

export interface Page_Region {
    view: RegionView;
    entities: Page_Entity[];
    regions: Page_Region[];
}
export interface Page_Entity {
    type: EntityType;
    view: EntityView;
    content: any;
    id: Page_ComponentId;
    entityType: Page_Entity_Type;
}

Component config

export const ENTITY_CONFIG: EntityConfigMap<
  // eslint-disable-next-line
  React.FunctionComponent<any> | React.LazyExoticComponent<any>
> = {
  [EntityType.GLOBALSITES__INTRO_VIDEO]: () =>
    import("../components/organisms/video-banner").then((m) => ({
      component: m.VideoBanner,
      mapper: m.mapper,
    })),
  /**
   * Configure component to type
   */
  [EntityType.GLOBALSITES__NAVIGATION]: () =>
    import("../components/organisms/app-header").then((m) => ({
      component: m.AppHeader,
      mapper: m.mapper,
    })),
  [EntityType.GLOBALSITES__FOUNDATION_PROJECTS_OVERVIEW]: {
    /**
     * Configure lazy loaded component + lazy loaded mapper to type + view
     */
    [FoundationProjectsOverviewView.FOUNDATION_PROJECTS_OVERVIEW]: () =>
      import("../components/organisms/image-tiles-block").then((m) => ({
        component: m.default,
        mapper: m.mapper,
      })),
  },
};

React/Angular

Type match

Type/view match

CMS App shell (Angular)

@Injectable({
  providedIn: "root",
})
export class CmsService {
  private readonly createPageAsync: CreatePageAsync<EntityConfig>;
  private readonly loadTemplateAsync: LoadTemplate;

  /**
   * Angular specific check to see if the template config is sync.
   */
  private static isSyncTemplateConfig(
    template: TemplateConfig
  ): template is SyncTemplateConfig {
    return !!template.prototype && !!template.prototype.constructor.name;
  }

  constructor(
    @Inject(CMS_CONFIG)
    { entityConfigMap, templateConfigMap, defaultTemplate }: CmsConfig
  ) {
    this.createPageAsync = createPageFactory<EntityConfig>(entityConfigMap);

    this.loadTemplateAsync = (view: PageView) => {
      const templateConfig = templateConfigMap[view] ?? defaultTemplate;
      return CmsService.isSyncTemplateConfig(templateConfig)
        ? Promise.resolve(templateConfig)
        : templateConfig();
    };
  }

  loadTemplate(view: PageView): Observable<EntityConfig> {
    return from(this.loadTemplateAsync(view));
  }

  createPage(page: Page): Observable<ParsedPage<EntityConfig>> {
    return from(this.createPageAsync(page));
  }
}
const createPage = createPageFactory<React.FunctionComponent>(ENTITY_CONFIG);

const DEFAULT_TEMPLATE = FoundationAboutUsTemplate;

type DynamicPage = {
  page: ParsedPage<React.FunctionComponent>;
  Template: React.FunctionComponent;
};

export function useDynamicPage(pageResponse: Page): DynamicPage | undefined {
  const [data, setData] = useState<DynamicPage>();

  useEffect(() => {
    (async () => {
      const page = await createPage(pageResponse);
      const Template = TEMPLATE_CONFIG[page.view] || DEFAULT_TEMPLATE;
      setData({ Template, page: page });
    })();
  }, [pageResponse]);

  return data;
}

CMS App shell (React)

@Component({
  selector: "global-sites-poc-dynamic-cms",
  templateUrl: "./dynamic-cms.component.html",
  styleUrls: ["./dynamic-cms.component.scss"],
})
export class DynamicCmsComponent implements AfterViewInit {
  @ViewChild("slots", { read: ViewContainerRef })
  slotsRef: ViewContainerRef;

  @ViewChild("template", { read: ViewContainerRef })
  templateRef: ViewContainerRef;

  constructor(private cmsService: CmsService) {}

  ngAfterViewInit() {
    forkJoin([
      this.cmsService.createPage(PAGE),
      this.cmsService.loadTemplate(PAGE.view),
    ]).subscribe(([{ regions }, template]) => {
      const {
        location: { nativeElement: templateElement },
      } = this.templateRef.createComponent(template);

      regions.forEach(({ components, view }) => {
        components.forEach(async ({ component, data }) => {
          const {
            instance,
            location: { nativeElement },
          } = this.slotsRef.createComponent(component);

          Object.keys(data).forEach((key) => (instance[key] = data[key]));

          nativeElement.slot = view;

          /**
           * DOM Hack that puts components inside the template.
           */
          nativeElement.parentElement.removeChild(nativeElement);
          templateElement.appendChild(nativeElement);
        });
      });
    });
  }
}

CMS Render (Angular)

export const DynamicCms = () => {
  const dynamicPage = useDynamicPage(FOUNDATION_ABOUT_US_PAGE);

  if (!dynamicPage) {
    return <div>Loading</div>;
  }

  const {
    Template,
    page: { regions },
  } = dynamicPage;

  return (
    <Template>
      {regions.map(({ view, components }) =>
        components.map(({ component: Component, data }, i) => (
          <Component {...data} slot={view} key={`${view}-${i}`} />
        ))
      )}
    </Template>
  );
};

CMS Render (React)

Development philosophy demo

By rachnerd

Development philosophy demo

  • 243