if(재능이 없으면 시간으로 극복하라){return 성공;}

SAP BTP - React Micro Frontend를 Luigi에 붙이기

임의의 데이터를 만들기 위해 /react-core-mf/src/assets 경로에 products.js 파일 생성

export const ProductCollection = [
    {
        "id": 101,
        "name": "Mouse",
        "price": 45.0,
        "stock": 80,
        "icon": "product",
        "currencyCode": "EUR",
        "orderQuantity": 2,
        "description": "Wireless Gaming Mouse with Sensor"
    },
    {
        "id": 102,
        "name": "Keyboard",
        "price": 50.0,
        "stock": 0,
        "icon": "product",
        "currencyCode": "EUR",
        "orderQuantity": 1,
        "description": "A physical keyboard that uses an individual spring and switch for each key. Today, only premium keyboards are built with key switches."
    },
    {
        "id": 103,
        "name": "Optical Mouse",
        "price": 35.0,
        "stock": 4,
        "icon": "product",
        "currencyCode": "EUR",
        "orderQuantity": 2,
        "description": "Utilizing the latest optical sensing technology, the USB Optical Scroll Mouse records precise motion."
    },
    {
        "id": 104,
        "name": "Laptop Pro",
        "price": 1299.0,
        "stock": 11,
        "icon": "laptop",
        "currencyCode": "EUR",
        "orderQuantity": 3,
        "description": "Newest laptop featuring a touch-sensitive OLED display."
    },
    {
        "id": 105,
        "name": "Mouse 2",
        "price": 40.0,
        "stock": 20,
        "icon": "product",
        "currencyCode": "EUR",
        "orderQuantity": 6,
        "description": "The Mouse 2 is a computer mouse featuring a multi-touch acrylic surface for scrolling. The mouse features a lithium-ion rechareable battery and Lightning connector for charging and pairing."
    },
    {
        "id": 106,
        "name": "Printer",
        "price": 235.0,
        "stock": 24,
        "icon": "fx",
        "currencyCode": "EUR",
        "orderQuantity": 1,
        "description": "Affordable printer providing you with the optimal way to take care of all your printing needs."
    },
    {
        "id": 107,
        "name": "Phone 11",
        "price": 835.0,
        "stock": 45,
        "icon": "iphone",
        "currencyCode": "EUR",
        "orderQuantity": 8,
        "description": "The Phone 11 dimensions are 150.9mm x 75.7mm x 8.3mm (H x W x D). It weighs about 194 grams (6.84 ounces)."
    },
    {
        "id": 108,
        "name": "Phone 3a",
        "price": 299.0,
        "stock": 54,
        "icon": "desktop-mobile",
        "currencyCode": "EUR",
        "orderQuantity": 7,
        "description": "At 5.6 inches, the display is proportionate to the relatively small body of the phone."
    },
    {
        "id": 109,
        "name": "Game Console 4",
        "price": 330.0,
        "stock": 94,
        "icon": "video",
        "currencyCode": "EUR",
        "orderQuantity": 1,
        "description": "This is the fourth home video game console compatible with all gaming systems."
    },
    {
        "id": 110,
        "name": "Monitor",
        "price": 630.0,
        "stock": 20,
        "icon": "sys-monitor",
        "currencyCode": "EUR",
        "orderQuantity": 3,
        "description": "34'' Monitor, Display with stand Height adjustable (115 mm), titltable (-5° to 21°), rotatable (-30° to 30°) Security slot (cable lock sold separately), anti-theft slot for locking to stand (for display). Includes: DisplayPort cable, HDMI cable, Power cable, Stand, USB 3.0 Type-A to Type-B cable, USB-C cable."
    }
]

/react-core-mf/public/sampleapp.html 수정

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Luigi Simple React App</title>
    </head>

    <body>
        <noscript>You need to enable JavaScript to run this app.</noscript>
        <div id="root"></div>
    </body>
</html>

webpack.config.js의 HTMLWebpackPlugin을 설정하는 이유는 micro frontend도 다른 환경과 같은 환경을 가져가야 하기 때문에 html 파일을 생성하기 위함

...
    new HTMLWebpackPlugin(
        Object.assign(
            {},
            {
                inject: true,
                template: './public/sampleapp.html',
                filename: 'sampleapp.html'
            },
...

/react-core-mf/webpack.config.js 파일 수정 ( line 80에 Add this rule 부분 추가 )

...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', { targets: 'defaults' }],
              ['@babel/preset-react', { runtime: 'automatic' }]
            ]
          }
        }
      },
      /// <---Add this rule -->
      {
        test: /\.(css)$/,
        use: ['style-loader', 'css-loader'],
      },
      /// <----->
    ]
  },

navigation과 general settings와 관련된 간단한 요소를 사용해 첫 번째 “Home” navigation node를 생성하고 application을 반응형으로 만든다.

사용될 Luigi navigation 요소

  • pathSegment : URL에 추가된 text segment
  • label : navigation에 표시될 node의 이름
  • icon : label 옆에 표시될 SAP icon
  • viewUrl : 마이크로 프론트엔드의 URL
  1. /react-core-mf/public/luigi-config.js 파일 수정
Luigi.setConfig({
  navigation: {
    nodes: () => [
      {
        pathSegment: "home",
        label: "Home",
        icon: "home",
        viewUrl: "/sampleapp.html#/microfrontend/home",
      },
    ],
  },
  settings: {
    header: { title: "Luigi React App"},
    responsiveNavigation: "simpleMobileOnly",
    customTranslationImplementation: myTranslationProvider,
  },
  lifecycleHooks: {
    luigiAfterInit: () => {
      Luigi.i18n().setCurrentLocale(defaultLocale);
    },
  },
  communication: {
    customMessagesListeners: {
      "set-language": (msg) => {
        Luigi.i18n().setCurrentLocale(msg.locale);
      },
    },
  },
});

var defaultLocale = "en-US";
function myTranslationProvider() {
  var dict = {
    "en-US": { PRODUCTS: "Products", ORDERHISTORY: "Order History" },
  };
  return {
    getTranslation: function (label, interpolation, locale) {
      const local = locale || Luigi.i18n().getCurrentLocale() || defaultLocale
      return (
        dict[local][label] || label
      );
    },
  };
}

6. Configure router for “Home” view

  • React app 진입점 ‘index.js’ 변경.
  • ‘/react-core-mf/src/views’ 경로에 ‘home.js’ view 생성 > router 구성 > import Luigi Client
  • localization을 다룰 localization에서 사용될 language.js 생성
  1. /react-core-mf/src/index.js 파일 수정 (해당 파일이 없어 생성하여 진행)
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { HashRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './views/home.js';
import { dict } from './language.js';
import { addInitListener, addContextUpdateListener, removeContextUpdateListener, removeInitListener, uxManager} from '@luigi-project/client';
import { ThemeProvider } from "@ui5/webcomponents-react";

const container = document.getElementById('root');
const root = createRoot(container);

const App = () => {
  const [currentLocale, setCurrentLocale] = useState('en-US');
  const [initListener, setInitListener] = useState(null);
  const [contextUpdateListener, setContextUpdateListener] = useState(null);

  useEffect(() => {
    const updateCurrentLanguage = () => {
      setCurrentLocale(uxManager().getCurrentLocale())
    }

    setInitListener(
      addInitListener(() => {
        console.log("Luigi Client initialized.");
        // update current language upon Luigi Client initialization
        updateCurrentLanguage();
      })
    );

    setContextUpdateListener(
      addContextUpdateListener(() => {
        // update current language upon Luigi Client context update event
        updateCurrentLanguage();
      })
    );

    return function cleanup() {
      removeContextUpdateListener(contextUpdateListener);
      removeInitListener(initListener);
    };
  }, []);

  return (
    <ThemeProvider>
      <React.StrictMode>
        <Router basename="microfrontend">
          <Routes>
            <Route path="/home" element={<Home localeDict={dict[currentLocale]} currentLocale={currentLocale} />} />
          </Routes>
        </Router>
      </React.StrictMode>
    </ThemeProvider>
  );
};
root.render(<App />);

‘/react-core-mf/src/views/home.js’ 파일 수정

import React, { useState, useEffect } from 'react';
import "fundamental-styles/dist/fundamental-styles.css";
import { addInitListener, addContextUpdateListener, removeContextUpdateListener, removeInitListener, sendCustomMessage } from "@luigi-project/client";
import { Grid, Panel, Select, Option } from "@ui5/webcomponents-react";

const Home = (props) => {
  const [options] = useState([{ key: 'en-US', text: 'en-US' }]);

  function onChangeValue(event) {
    sendCustomMessage({
      id: "set-language",
      locale: event.detail.selectedOption.innerText,
    });
  }

  return (
    <Grid position="Center" defaultIndent="XL1 L1 M1 S1" defaultSpan="XL10 L10 M10 S10">
      <Panel headerText={props.localeDict.WELCOME_LUIGI} headerLevel="H3">
        <Select onChange={onChangeValue}>
          {options.map((language) => (
            <Option key={language.key}>{language.text}</Option>
          ))}
        </Select>
      </Panel>
    </Grid>
  );
};

export default Home;

‘/react-core-mf/src’ 경로에 language.js 파일 생성

export const dict = {
  "en-US": {
    ITEMS: "Products",
    STOCKS: "Stocks",
    SELECTLANGUAGE: "Please select a language",
    PRICE: "Price",
    WELCOME_LUIGI: "Welcome to Luigi - a micro-frontend framework",
    DESCRIPTION: "Description",
    PRODUCTADDED: "Product has been added to cart",
    AVAILABLE: "Available",
    AVAILABLEQUANT: "Available quantity: ",
    ADDTOCART: "Add to cart",
    BACK: "Back",
    OUTOFSTOCK: "Out of stock",
  },
};

“Products” 마이크로 프론트엔드에 대한 navigation node를 Luigi에 추가

‘/react-core-mf/public/luigi-config.js’ 파일 수정 (children에 Products node 추가)

navigation: {
    nodes: () => [
      {
        pathSegment: "home",
        label: "Home",
        icon: "home",
        viewUrl: "/sampleapp.html#/microfrontend/home",
        //<---Add the section below to the config file--->
        children: [
          {
            pathSegment: "products",
            label: "Products",
            icon: "product",
            viewUrl: "/sampleapp.html#/microfrontend/products",
          }
        ],
        // <------>
      },
    ],
  },

‘/react-core-mf/src/views/sample1.js’ 파일 이름 변경 후 파일 수정 ( sample1.js -> products.js )

import React, { useEffect, useState } from 'react';
import "../../node_modules/fundamental-styles/dist/fundamental-styles.css";
import "@ui5/webcomponents-icons/dist/AllIcons.js";
import { Grid, List, StandardListItem } from "@ui5/webcomponents-react";
import { linkManager } from "@luigi-project/client";
import { ProductCollection } from "../assets/products.js";

const Products = (props) => {
  const [listItems, setListItems] = useState([]);

  useEffect(() => {
    const tempList = [];
    ProductCollection.forEach((product) => {
      tempList.push(
        <StandardListItem id={product.id} key={product.id} additionalText={product.price + " " + product.currencyCode} additionalTextState="Information" description={product.description} growing="None" headerText={product.orderQuantity} icon={product.icon} type="Active" mode="None" onItemClick={() => handleItemClick(product.id)}>
          <p>{product.name}</p>
        </StandardListItem>
      );
      setListItems(tempList);
    });
  }, [])

  // navigates to productDetail microfrontend through Luigi Client linkManager API
  function handleItemClick(event) {
    linkManager().withParams({ root: "products" });
    linkManager().navigate(
      "/home/products/" + event.detail.item.id.toString()
    );
  };

  return (
    <Grid position="Center" defaultIndent="XL1 L1 M1 S1" defaultSpan="XL10 L10 M10 S10">
      <List headerText={props.localeDict.ITEMS + ": " + ProductCollection.length} onItemClick={handleItemClick}>
        {listItems}
      </List>
    </Grid>
  );
};
export default Products;

‘index.js’에 routing module 추가

//<---Around line 8, paste this line: --->:
import Products from "./views/products.js";
//<------>
   ...
<Router basename="microfrontend">
  <Routes>
    <Route path="/home" render={(props) => <Home {...props} localeDict={dict[this.state.currentLocale]} currentLocale={this.state.currentLocale} />} />
    //<---Around line 45, paste this line: --->
    <Route path="/products" element={<Products localeDict={dict[currentLocale]} />} />
    //<------>
  </Routes>
</Router>

image

8. Add “Product Detail” view to Luigi app

ProductDetail view 추가. Luigi dynamic parameter( 해당 tutorial에서는 :id 사용 )을 통해 각 product의 상세 정보를 볼 수 있음.

‘/react-core-mf/public/luigi-config.js’ 파일 수정
products node에 :id child node 추가

      children: [
                  {
                      pathSegment: "products",
                      label: "Products",
                      icon: "product",
                      viewUrl: "/sampleapp.html#/microfrontend/products",
                      //<---Paste the section below to your config:--->
                      keepSelectedForChildren: true,
                      children: [{
                          pathSegment: ':id',
                          viewUrl: '/sampleapp.html#/microfrontend/productDetail/:id',
                          context: { id: ':id' }
                      }]
                      //<------>
                  },

‘/react-core-mf/srv/views’ 경로에 ‘productDetail.js’ 파일 생성

import React, { useEffect, useState, useRef } from 'react';
import "../../node_modules/fundamental-styles/dist/fundamental-styles.css";
import "@ui5/webcomponents-icons/dist/AllIcons.js";
import {
    Grid, ObjectPage, Label, DynamicPageHeader, DynamicPageTitle, ObjectStatus, FlexBox, Button, Toast, ObjectPageSection, FormItem, Form, Text, Bar
} from "@ui5/webcomponents-react";
import { linkManager, getContext } from "@luigi-project/client";
import { ProductCollection } from "../assets/products.js";

const ProductDetail = (props) => {
    const [currentProduct, setCurrentProduct] = useState({});
    const [availability, setAvailability] = useState({
        state: "Warning",
        text: props.localeDict.OUTOFSTOCK
    });
    const toast = useRef(null);

    useEffect(() => {
        setProductAndAvailability();
    }, []);

    function setProductAndAvailability() {
        // get id from Luigi Client getContext API
        const id = getContext().id;

        let product = ProductCollection.find(
            (product) => product.id.toString() === id
        ) || ProductCollection[0]

        setCurrentProduct(product);
        product.stock ? setAvailability({ state: "Success", text: props.localeDict.AVAILABLE }) : ""
    }

    // Use Luigi Client linkManager API to navigate to the previous microfrontend
    function navBack() {
        // checks if there is a previous view in history
        if (linkManager().hasBack()) {
            // navigates to the previously opened microfrontend
            linkManager().goBack();
        } else {
            // navigates to the products page directly
            linkManager().navigate("/home/products");
        }
    };

    return (
        <Grid position="Center" defaultIndent="XL1 L1 M1 S1" defaultSpan="XL10 L10 M10 S10">
            <ObjectPage
                headerContent={
                    <DynamicPageHeader>
                        <FlexBox alignItems="Center" wrap="Wrap">
                            <FlexBox direction="Column" style=>
                                <Label>
                                    {props.localeDict.AVAILABLEQUANT + currentProduct.stock}
                                </Label>
                            </FlexBox>
                        </FlexBox>
                    </DynamicPageHeader>
                }
                footer={
                    <Bar design="FloatingFooter"
                        endContent={
                            <>
                                <Button design="Emphasized" onClick={() => { toast.current.show() }}>
                                    {props.localeDict.ADDTOCART}
                                </Button>
                                <Button design="Default" onClick={navBack}>
                                    {props.localeDict.BACK}
                                </Button>
                            </>
                        }
                    />
                }
                headerContentPinnable
                headerTitle={
                    <DynamicPageTitle
                        actions={
                            <>
                                <Toast ref={toast}>
                                    {props.localeDict.PRODUCTADDED}
                                </Toast>
                                <Button design="Emphasized" onClick={() => { toast.current.show() }}>
                                    {props.localeDict.ADDTOCART}
                                </Button>
                                <Button onClick={navBack}>
                                    {props.localeDict.BACK}
                                </Button>
                            </>
                        }
                        header={currentProduct.name}
                    >
                        <ObjectStatus state={availability.state}>
                            {availability.text}
                        </ObjectStatus>
                    </DynamicPageTitle>
                }
                onSelectedSectionChange={function noRefCheck() { }}
                selectedSectionId="details"
                showHideHeaderButton
                style=
            >
                <ObjectPageSection aria-label="Details" id="details" titleText="Details">
                    <Form columnsL={2} columnsM={2} columnsXL={3} labelSpanL={1} labelSpanM={1} labelSpanXL={1}>
                        <FormItem label="Name">
                            <Text>{currentProduct.name}</Text>
                        </FormItem>
                        <FormItem label={props.localeDict.DESCRIPTION}>
                            <Text>{currentProduct.description}</Text>
                        </FormItem>
                        <FormItem label={props.localeDict.PRICE}>
                            <Text>
                                {currentProduct.price + " " + currentProduct.currencyCode}
                            </Text>
                        </FormItem>
                    </Form>
                </ObjectPageSection>
            </ObjectPage>
        </Grid>
    );
};

export default ProductDetail;

‘index.js’를 아래와 같이 변경

return (
    <ThemeProvider>
        <React.StrictMode>
            <Router basename="microfrontend">
                <Routes>
                    <Route path="/home" element={<Home localeDict={dict[currentLocale]} currentLocale={currentLocale} />} />
                    <Route path="/products" element={<Products localeDict={dict[currentLocale]}/>}/>
                    <Route path="/productDetail/:id" element={<ProductDetail localeDict={dict[currentLocale]}/>}/>
                </Routes>
            </Router>
        </React.StrictMode>
    </ThemeProvider>
);

setCurrentProduct(product)에 있는 product를 이용하는 것으로 변경하여 정상적으로 동작하도록 수정.

재고가 있을 경우. image

재고가 없을 경우. image