Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: expose getRoutes to share routes with private env.config.js files #1143

Merged
merged 14 commits into from
Aug 12, 2024

Conversation

adamstankiewicz
Copy link
Member

@adamstankiewicz adamstankiewicz commented Aug 6, 2024

Ticket: ENT-9274

Related PRs:

CHANGELOG:

  • Refactors route data structure from <Route> JSX elements to an array of objects, such that the router tree may be exposed by a utility function getRoutes that may be shared between the MFE source code and env.config.js(x).
  • Migrates route-related and some query related code paths to TypeScript, ensuring proper typing in all route loaders, route definitions, some React Query related stuff, etc.

Given the output from getRoutes, the flattened route paths result is as follows:

[
    "/invite/:enterpriseCustomerInviteKey",
    "/:enterpriseSlug?",
    "/:enterpriseSlug?/search/:pathwayUUID?",
    "/:enterpriseSlug?/academies/:academyUUID",
    "/:enterpriseSlug?/pathway/:pathwayUUID/progress",
    "/:enterpriseSlug?/program/:programUUID",
    "/:enterpriseSlug?/program/:programUUID/progress",
    "/:enterpriseSlug?/skills-quiz",
    "/:enterpriseSlug?/:courseType?/course/:courseKey",
    "/:enterpriseSlug?/:courseType?/course/:courseKey/enroll/:courseRunKey",
    "/:enterpriseSlug?/:courseType?/course/:courseKey/enroll/:courseRunKey/complete",
    "/:enterpriseSlug?/licenses/:activationKey/activate",
    "/:enterpriseSlug?/videos/:videoUUID",
    "/:enterpriseSlug?/*",
    "/*"
]

TODO: Tests are WIP.

For all changes

  • Ensure adequate tests are in place (or reviewed existing tests cover changes)
  • Ensure English strings are marked for translation. See documentation for more details.

Only if submitting a visual change

  • Ensure to attach screenshots
  • Ensure to have UX team confirm screenshots

package.json Outdated
"@edx/frontend-enterprise-catalog-search": "10.5.0",
"@edx/frontend-enterprise-hotjar": "6.0.0",
"@edx/frontend-enterprise-logistration": "8.0.0",
"@edx/frontend-enterprise-utils": "8.0.0",
"@edx/frontend-logging": "^4.3.0",
Copy link
Member Author

@adamstankiewicz adamstankiewicz Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] this needs to be removed from this file. it a private NPM package installed only by 2U's GoCD build+deploy MFE pipeline via env.config.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] does this still need to be either pinned/removed?

package.json Outdated
"@edx/frontend-platform": "7.1.0",
"@edx/openedx-atlas": "0.6.0",
"@edx/reactifex": "2.2.0",
"@loadable/component": "5.16.3",
"@lukemorales/query-key-factory": "1.3.4",
"@openedx/frontend-slot-footer": "^1.0.3",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] should be pinned

Comment on lines +51 to +52
"react-router": "6.18.0",
"react-router-dom": "6.18.0",
Copy link
Member Author

@adamstankiewicz adamstankiewicz Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[context] resolves a bug with matchPath and its handling of optional route parameters (e.g., /:enterpriseSlug?)

@@ -44,7 +44,7 @@ const Layout = () => {
<main id="content" className="fill-vertical-space">
<Outlet />
</main>
<SiteFooter />
<FooterSlot />
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] Misc. improvement to support a plugin-enabled footer; basically a no-op though.

Comment on lines +22 to +24
// Set custom attributes for logging service
const loggingService = getLoggingService();
loggingService.setCustomAttribute('enterprise_customer_uuid', enterpriseCustomer.uuid);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] Ensures enterprise_customer_uuid is tagged for Datadog RUM events (e.g., to facet/query session replays by specific enterprise customers).

src/routes.jsx Outdated
Comment on lines 10 to 15
function getRouteLoader(routeLoaderFn, queryClient) {
if (!queryClient) {
return null;
}
return routeLoaderFn(queryClient);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] When getRoutes is used from env.config.js, there is not yet access to a queryClient. Instead, I've made getRoutes conditional such that route loaders that depend on the queryClient are only included in the route definitions if a queryClient is present.

src/routes.jsx Outdated
),
},
];
if (hasNotFoundRoutes) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] need to sanity check if the two conditional NotFoundPage routes (based on hasNotFoundRoutes) are still needed now that the approach has changed slightly.

Hunch is these two routes can safely always be included now, simplifying this logic slightly.

src/routes.jsx Outdated
...getOtherRoutes(),
...enterpriseSlugRoutes,
];
if (hasNotFoundRoutes) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] need to sanity check if the two conditional NotFoundPage routes (based on hasNotFoundRoutes) are still needed now that the approach has changed slightly.

Hunch is these two routes can safely always be included now, simplifying this logic slightly.

src/routes.jsx Outdated
};
}

export function extractPaths(routes, basePath = '/') {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] needs docstring / types

src/routes.jsx Outdated
return paths;
}

export function replaceRouteParamsInPath(viewPath, routePaths) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] needs docstring / types

src/routes.jsx Outdated
if (!value) {
return;
}
viewPathCopy = viewPathCopy.replaceAll(value, '?');
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] the most important part, finally 😜

@adamstankiewicz adamstankiewicz changed the title feat: expose getRoutes API to share with private env.config.js files feat: expose getRoutes to share routes with private env.config.js files Aug 6, 2024
src/routes.jsx Outdated
Comment on lines 294 to 296
if (route.children?.length > 0) {
paths = [...paths, ...extractPaths(route.children, `${currentPath}/`)];
}
Copy link
Member

@brobro10000 brobro10000 Aug 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit/question] Since its recursive, should there be a base case before entering the possible recursion that checks explicitly for !route.children?.length and return

  routes.forEach((route) => {
    let currentPath = basePath;

    // Append the current route's path to the base path
    if (route.path) {
      currentPath += route.path;
    }

    if (!route.index) {
      paths.push(currentPath);
    }
   
   if(!route.children?.length) {
      return
   }
    // Recursively handle nested routes (children)
    if (route.children?.length > 0) {
      paths = [...paths, ...extractPaths(route.children, `${currentPath}/`)];
    }
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a similar question - I think you'd want to terminate and return an empty list when there aren't any more children of a route, maybe? Right now, when there aren't children, we're eventually, after the for-loop exhausts all the routes, going to return all of paths as the recursive response at the top of the recursion stack. The recursion inside the for-loop is throwing me off, so I could easily be wrong. :p

Copy link
Member Author

@adamstankiewicz adamstankiewicz Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another way to think of this logic (wrt the interaction between the recursion and the .forEach) might be that the recursive logic is on a individual, per-route basis, but shares/extends/concatenates to the same array of flattened paths we've already come across.

The base case exists but is implicit, with the existing conditional on whether there are route.children defined. Without route.children, it pushes the current route's path to the flattened list and moves onto the next route in the top-level list, if any (essentially an early return). With children, it traverses the nested routes for their paths, continuing to push onto the same flattened array from the first iteration.

Example input:

const routes = [
  {
    path: '/:enterpriseSlug?',
    element: <Outlet />, // Outlet renders child route(s)
    loader: [() => {}], // exemplary
    children: [
      {
        index: true,
        element: <Dashboard />,
      },
      {
        path: 'search',
        element: <Search />,
      },
      {
        path: ':courseType?/course/:courseKey',
        element: <Outlet />,
        children: [
          {
            index: true,
            element: <CourseAbout />,
          },
          {
            path: 'enroll/:courseRunKey',
            element: <CourseEnroll />,
          },
        ],
      },
      // ...
    ]
  }
];

Example output:

[
  '/:enterpriseSlug?',
  '/:enterpriseSlug?/search',
  '/:enterpriseSlug?/:courseType?/course/:courseKey',
  '/:enterpriseSlug?/:courseType?/course/:courseKey/enroll/:courseRunKey',
  // ...
]

See the PR description for full list of flattened route paths contained by the application, generated by this recursive logic.

Right now, when there aren't children, we're eventually, after the for-loop exhausts all the routes, going to return all of paths as the recursive response at the top of the recursion stack.

Correct, if a route has no children, it only adds its own path to the known paths (if any). With children, it collects all the nested routes into paths, too. Once the .forEach exhausts all the routes, it returns all the paths that were collected as a flattened array.

[inform/aside] I plan on simplifying it slightly as the following:

export function flattenRoutePaths(routes: Types.RouteObject[], basePath = '/') {
  let paths: string[] = [];
  routes.forEach((route) => {
    // Construct the full path by combining basePath with route.path
    const fullPath = `${basePath}${(route.path || '')}`;

    // Add the full path to the result if the route has a path
    if (route.path) {
      paths.push(fullPath);
    }

    // Recursively process the route's children (if any)
    if (Array.isArray(route.children)) {
      paths = paths.concat(flattenRoutePaths(route.children, `${fullPath}/`));
    }
  });
  return paths;
}

tsconfig.json Outdated
Comment on lines 6 to 7
"jsx": "react-jsx",
"lib": ["ES2021"],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] Hoping to contribute these upstream to @edx/typescript-config, as they are generally applicable to all MFEs, not just this one. See openedx/typescript-config#12 for more details.

Copy link

codecov bot commented Aug 8, 2024

Codecov Report

Attention: Patch coverage is 96.39640% with 12 lines in your changes missing coverage. Please review.

Project coverage is 88.05%. Comparing base (42c06b0) to head (066857f).

Files Patch % Lines
src/components/utils/search.js 60.00% 8 Missing ⚠️
src/components/app/data/queries/queryKeyFactory.js 93.10% 2 Missing ⚠️
...c/components/skills-quiz/SkillsContextProvider.jsx 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1143      +/-   ##
==========================================
+ Coverage   87.12%   88.05%   +0.92%     
==========================================
  Files         392      393       +1     
  Lines        8172     8268      +96     
  Branches     1984     1984              
==========================================
+ Hits         7120     7280     +160     
+ Misses       1002      946      -56     
+ Partials       50       42       -8     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Member

@brobro10000 brobro10000 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of nits that needs to be addressed over (what looks like) some leftover test data and commented out code bits.
One non-blocking question about the queryClient being set to optional as an argument.

Approving to unblock 👍🏽

package.json Outdated
"@edx/frontend-enterprise-catalog-search": "10.5.0",
"@edx/frontend-enterprise-hotjar": "6.0.0",
"@edx/frontend-enterprise-logistration": "8.0.0",
"@edx/frontend-enterprise-utils": "8.0.0",
"@edx/frontend-logging": "^4.3.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] does this still need to be either pinned/removed?

@@ -73,7 +73,7 @@ export default function makeRootLoader(queryClient) {

// Redirect user to search page, for first-time users with no assignments.
redirectToSearchPageForNewUser({
enterpriseSlug,
enterpriseSlug: enterpriseSlug as string,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] possible test data left in?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this is TypeScript, it knows enterpriseSlug is either undefined or a string based on the params object type. The enterpriseSlug as string is coercing the value as a string given the redirectToSearchPageForNewUser does not accept enterpriseSlug as undefined, thus throwing a TypeScript error. In this part of the code, enterpriseSlug is known to only be a string, so this is telling TypeScript that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh yeah I am just noticing its not wrapped in a set of quotation marks denoting as a string 😅 .

Comment on lines 112 to 122
// {
// enterpriseSlug: mockEnterpriseCustomerTwo.slug,
// enterpriseCustomer: mockEnterpriseCustomerTwo,
// activeEnterpriseCustomer: mockEnterpriseCustomer,
// allLinkedEnterpriseCustomerUsers: [
// { enterpriseCustomer: mockEnterpriseCustomer },
// { enterpriseCustomer: mockEnterpriseCustomerTwo },
// ],
// isStaffUser: false,
// shouldRedirectToSearch: false,
// },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] some commented out code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

D'oh, great catch!

@@ -115,7 +125,7 @@ export default function makeCourseLoader(queryClient) {
// If learner is an assignment-only learner and is not assigned to the currently
// viewed course, redirect to the Dashboard page route.
if (isAssignmentOnlyLearner && !isCourseAssigned) {
throw redirect(generatePath('/:enterpriseSlug', { enterpriseSlug }));
throw redirect(generatePath('/:enterpriseSlug', { enterpriseSlug: enterpriseSlug as string }));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] some remaining test data

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the other instance, this one can be removed, though. The CourseRouteParams coerces the enterpriseSlug to only be string already.

Comment on lines 27 to 35
// jest.mock('@edx/frontend-platform/auth', () => ({
// ...jest.requireActual('@edx/frontend-platform/auth'),
// configure: jest.fn(),
// }));
// jest.mock('@edx/frontend-platform/logging', () => ({
// ...jest.requireActual('@edx/frontend-platform/logging'),
// configure: jest.fn(),
// getLoggingService: jest.fn(),
// }));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] some commented out code

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I recall intentionally leaving this commented when I copied it from another route loader, as I had a hunch it wasn't needed (it wasn't). I will clean up the other loader tests that have these same Jest mocks unnecessarily.

/**
* Returns the routes nested under the enterprise slug prefix.
*/
function getEnterpriseSlugRoutes(queryClient?: Types.QueryClient) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question/curious] If the loaders are now gating for the query client should the query client be an optional argument? Are there any instances where we don't expect the query client to exist before the routes are loaded and that being an acceptable outcome (as to not gate our loaders with the following code snippet)?

if (!authenticatedUser || !queryClient) {
      return null;
    }

Should we just make the queryClient a non optional as an additional gating step for getEnterpriseSlugRoutes? (This might just be a gap in my TS knowledge too)

Copy link
Member Author

@adamstankiewicz adamstankiewicz Aug 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getRoutes is called in two places: createAppRouter (internal) and env.config.js (external). Because env.config is calling getRoutes without passing a queryClient (i.e., one does not exist yet), the getRoutes and child functions like getEnterpriseSlugRoutes need to conditionally handle when queryClient is missing. The interaction with getRouteLoader checks whether queryClient is present and, if so, passed it to the route loader.

The shared route loader types themselves (i.e.,Types.MakeRouteLoaderFunction) defines any route loader creator functions as conditionally accepting a queryClient as not all route loaders in the app use a query client.

To be able to remove the if (!authenticatedUser || !queryClient) { gating in a route loader, we could create a new loader-specific type that extends the base Types.MakeRouteLoaderFunction type, telling TypeScript that this particular loader requires queryClient.

// base makeRouteLoader type
export type MakeRouteLoaderFunction = (queryClient?: QueryClient) => RouteLoaderFunction;

// extended loader-specific makeRouteLoaderType that requires queryClient
type MakeRootLoaderFunction = (queryClient: Types.QueryClient) => Types.RouteLoaderFunction;

const makeRootLoader: MakeRootLoaderFunction = function makeRootLoader(queryClient) {
  // no more check for `!queryClient` needed
}

Edit: I'm going to introduce a new common type:

export type MakeRouteLoaderFunction = (queryClient?: QueryClient) => RouteLoaderFunction;
export type MakeRouteLoaderFunctionWithQueryClient = (queryClient: QueryClient) => RouteLoaderFunction;

Any route loader that requires queryClient will use MakeRouteLoaderFunctionWithQueryClient; all others can use MakeRouteLoaderFunction.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this explanation! Leave unresolved for knowledge sharing purposes IMO.

@adamstankiewicz adamstankiewicz marked this pull request as ready for review August 12, 2024 13:39
@adamstankiewicz adamstankiewicz merged commit 10362ba into master Aug 12, 2024
7 checks passed
@adamstankiewicz adamstankiewicz deleted the ags/ent-9274 branch August 12, 2024 16:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants