Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

Proposal - feature based code organization

Open
#247 6 comments 2 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
25/100
Issue type
Refactor
Clarity
Needs clarification
Activity status
Active
Tech stack
typescript
Domain
frontend

Research direction

Start by reading ADR-002 and comparing the current src/authz-module structure and imports with the proposed src/authz tree, especially the feature index.ts boundaries. Resolve the listed open decisions about renames, dead code, ToastManager placement, and backend catalog timing before proceeding. Done means the organization and public interfaces follow the stated rules, with colocated tests still passing.

Written by the indexing model from the issue text.

Description

enhancement

Description

The current code organization does not follow the ADR-002.

Admin console is conceptualized to have modules (defined as the functional big blocks of the application such as authz). Each module is compose by features ("groups components based on high functional cohesion; meaning files that must change together to deliver a specific user outcome are kept together")

Where it fails

  1. Dependencies go both ways. components/RenderAdminRole.tsx imports audit-user/messages, and roles-permissions/utils.ts imports components/RoleCard/*. utils.tsx imports roles-permissions, while files inside roles-permissions' domain import utils. Code is scattered and authz-module/components/ holds code that have only 1 consumer.

  2. Sub-features import from their parent (rule I). authz-module/{constants,utils,messages}.ts, data/, hooks/ and components/ all belong to the parent, but every sub-feature imports them.

From the ADR: feature should never need to import from its parent or grandparents, and a more "general purpose" module should never be importing from a more specific one.

  1. DefineApplicationScopeStep imports roles-permissions/course/constants directly. OrgFilter and TableFooter are imported by file path because they have no public interface (index.ts).

From the ADR: A feature should have a public interface exposed via an index.js file in the module directory. Consumers of a feature should limit themselves to importing only from the public exports.

Proposed tree

[!NOTE]
I tested some variation it might have still some errors that can be improved (maybe some deeps are not needed but I wanted to focus on the features in authz).

Tests stay next to their source files and are left out of the tree. ← shows where code comes from.

src/
├── app.ts  routes.tsx  loaders.ts  providers.ts  Main.tsx  index.ts
├── constants.ts               # appId, CustomErrors, ERROR_STATUS
├── types.ts                   # app-wide / minimal third-party interfaces only
├── i18n/  slots/  setupTest.tsx  testUtils.tsx  global.d.ts  style.scss
│
├── components/                # generic UI: never imports a feature
│   ├── index.ts
│   ├── LoadingPage.tsx
│   ├── ErrorPage/             ← authz-module/components/ErrorPage
│   ├── ToastManager/          ← + its messages, DEFAULT/RETRY_TOAST_DELAY
│   └── messages.ts
├── data/                      # user account, validate/me, http helpers
│   ├── api.ts  hooks.ts  utils.ts  types.ts
│
└── authz/                     ← authz-module
    ├── index.ts               # export { authzRoutes }
    ├── routes.tsx
    ├── AuthzLayout.tsx         ← index.tsx
    ├── AuthzTitle.tsx         ← components/AuthZTitle.tsx 
    │   ── pages: at the root as compose of sub-features + AuthZTitle
    ├── AuthzHome.tsx          ← authz-home/index.tsx
    ├── AuditUserPage.tsx      ← audit-user/index.tsx (user header, redirect, AddRoleButton)
    ├── AssignRoleWizardPage.tsx
    ├── constants.ts           # paths, route ids, build*Path, TEAM_ACTIONS, team permission queries
    ├── messages.ts            # shared keys + ALL page keys (Home, AuditUser, Wizard) ✱
    ├── index.scss
    │
    ├── catalog/               ✱  holds today's static definitions (Feature will swaps to the API)
    │   ├── index.ts
    │   ├── types.ts           ← Role, Permission, Resource*, Org, Scope (from src/types.ts)
    │   ├── roles.ts           ← roles-permissions/{course,library}/constants, MAP_ROLE_KEY_TO_LABEL,
    │   │                        LIBRARY_ROLE_KEYS, getRolesFiltersOptions
    │   ├── permissions.ts     ← RoleCard/constants (actionsDictionary), getPermissionMetadata
    │   ├── scopes.ts          ← CONTEXT_TYPES, aggregate-scope key builders, getScopeContextType,
    │   │                        getAggregateScopeType, ALL_ORGS_KEY,
    │   │                        getScopeManageAction(Permission) ✱ (was unas
    │   ├── icons.ts           ← RESOURCE_ICONS, getScopeResourceIcon ✱
    │   ├── messages.ts        ← RoleCard/messages, AGGREGATE_SCOPE_LABELS
    │   └── data/  api.ts  hooks.ts           ← getOrgs/useOrgs, getScopes/useScopes
    │
    ├── course-authoring-flag/
    │   ├── index.ts
    │   ├── useCourseAuthoringFlag.ts
    │   ├── DisabledCourseActionButton.tsx   ← components/TableCells.tsx 
    │   ├── messages.ts
    │   └── data/              ← getCourseAuthoringFlagStates + hook
    │
    ├── roles-permissions/     # Roles & Permissions table
    │   ├── index.ts           # RolesPermissionsMatrix, ResourceTooltip, buildPermissionMatrixByResource
    │   ├── RolesPermissionsMatrix.tsx
    │   ├── components/        # PermissionTable  AnchorButton  ResourceTooltip
    │   ├── utils.ts
    │   └── messages.ts  
    │
    ├── role-assignments/           # "which roles does a user have": one resource, two views
    │   ├── index.ts           # TeamAssignmentsTable, UserAssignmentsTable,
    │   │                        
    │   ├── constants.ts       ← page sizes, MAX_TABLE_FILTERS_APPLIED, MAX_I
    │   │                        DJANGO_MANAGED_ROLES, ADMIN_ROLES ✱
    │   ├── types.ts           ← QuerySettings, FilterChoice, TeamMember, Tea
    │   │                        UserRole, RoleToDelete
    │   ├── messages.ts        # shared column headers (Role/Org/Scope/Action
    │   ├── cells/             ✱ one look for both tables
    │   │   ├── RoleBadge.tsx  ← AssignedRolesCell's RoleBadge / RoleBadgeCel
    │   │   ├── ScopeCell.tsx  ← ScopeNameCell + TableCells ScopeCell (+ ScopeLabel)
    │   │   └── OrgCell.tsx    ← OrgIconCell + TableCells OrgCell
    │   ├── components/        # table chrome shared by both tables
    │   │   ├── TableControlBar.tsx  MultipleChoiceFilter.tsx  SearchFilter.t
    │   │   ├── OrgFilter.tsx  RolesFilter.tsx  ScopesFilter.tsx
    │   │   ├── TableFooter.tsx  ExpandableButton.tsx
    │   │   └── getCellHeader.ts
    │   ├── hooks/             ✱ useExclusiveRowExpansion.ts
    │   ├── data/              
    │   │   ├── api.ts         ← getTeamMembersAssignments, getUserAssignedRo
    │   │   │                    assignTeamMembersRole, revokeUserRoles
    │   │   ├── hooks.ts       # + shared query keys
    │   │   └── useQuerySettings.ts
    │   ├── team-assignments/ 
    │   │   ├── TeamRoleAssignmentsTable.tsx
    │   │   ├── components/    # AssignedRolesCell  MoreRolesToggle  UserCell
    │   │   │                    ViewUserActionCell  UserAssignmentsSubTable
    │   │   └── messages.ts
    │   └── user-assignments/
    │       ├── UserRoleAssignmentsTable.tsx
    │       ├── components/    # PermissionsCell  ViewAllPermissionsCell  ActionsCell
    │       │                     UserPermissions  Render
    │       │                    RenderPermissionColumn  RenderPermissionInline  ✱ (renamed properly)  ConfirmDeletionModal
    │       └── messages.ts
    │
    └── assign-role/    ← role-assignation-wizard
        ├── index.ts           # AssignRoleWizard, AddRoleButton
        ├── AssignRoleWizard.tsx  AddRoleButton.tsx
        ├── components/        # SelectUsersAndRoleStep  HighlightedUsersInput
        ├── constants.ts  utils.ts  messages.ts
        ├── data/              ← validateUsers
        └── scope-step/       
            ├── DefineApplicationScopeStep.tsx
            ├── useScopeListData.ts  useScopePermissions.ts
            ├── components/    # ScopeFilterBar  ScopeList  OrgSection  ScopeCheckboxItem
            └── messages.ts

Rules

  1. Main components at the root, atoms in components/. A feature's or sub-feature's main component(s) (a page, a table, the wizard) live at its root, next to index.ts. Their smaller building blocks (cells, filters, modals, steps) go in components/, added only once there are several; a single one stays at the root. components/ is part of its feature, not a sub-feature: it has no index.ts, and only that feature and its sub-features import from it (as the two assignments tables share assignments/components/). A group of atoms that grows its own logic or data becomes a sub-feature (as scope-step does here).
  2. Paths: relative inside authz/, @src/... for app-level shared code and other features.
  3. Shared code lives in the closest folder that contains all its users.
    • Used by one sub-feature: it stays inside that sub-feature.
    • Used by two or more authz sub-features: it becomes a shared sub-feature under authz/.
    • Used by two or more top-level features: it moves to src/.
    • Don't move code up before a second user exists.

Open decisions

  • Renames: authz-module → authz, role-assignation-wizard → assign-role.
  • RoleOperationErrorStatus has no consumers outside constants.ts, so it may be dead code.
  • How to manage the Django admin roles, this are currently not supported by the consumed API endpoints until that we can removed or just add it in a feature but not use it.
  • Could the reorganization happen before or after the backend catalog API creation?. The catalog is the cornerstone of authz module, the public interface of this should keep the same or being close similar before or after the migration. Another solution could be keep the information in where it lives today and start building catalog from scratch.

Follow-ups and other implications

During the process there were some additional component specific analysis link to the component relocation

1. ErrorPage
  • The page works as a pair with CustomErrors (pages throw a code, the page turns it into text), and that contract already lives in src/constants.ts. Keeping the page in authz splits the pair across two levels, move it to src/components/ErrorPage

  • When a second feature arrives , we can allow pass props, the parent configures the page (ADR: "modules are configured by their parent"). Props are optional, default to today's values for example the page:

<ErrorBoundary
  fallbackRender={(props) => (
    <ErrorPage
      {...props}
      backAction={{ destination: getConfig().LMS_BASE_URL, label: messages.backToDashboard }}
      // only if a feature truly needs different copy: text-only, keyed by category, merged over defaults
      messageOverrides={{ [CustomErrors.NO_ACCESS]: { description: messages.noCourseAccess } }}
    />
  )}
/>
2. ToastManagerProvider placement

Validate: mount ToastManagerProvider in Main instead of src/providers.ts. frontend-base mounts app providers inside SiteProvider (CombinedAppProvider), so today the toast provider wraps the whole site: every app, header and footer. The instructor dashboard keeps its app-specific providers (AlertProvider, AccessErrorProvider) in Main for that reason. In Main it would still sit above the authz ErrorBoundary, so toasts would still survive the error page; toasts raised by a loader error (RouteErrorPage replaces Main) would not.

3. Catalog: a client of the backend catalog

The frontend carries its own copy of the authorization definitions: role and permission catalogs per scope type, role labels, icons, scope-key formats and team permissions, plus course/library either-or checks spread across catalog, roles-permissions, assignments and the wizard. The backend is taking ownership of all of it:

  • Roles, permissions and categories are defined in the authz schema (YAML merged in #445, loaded into models by #446), and ADR 0021 (PR #425) exposes them through the API, translated.
  • Roles can be user-defined at runtime (ADR 0016), and scope types are registered in backend code (ScopeData subclasses), so both can grow without a frontend release.

So catalog holds no definitions. It fetches them, looks them up and renders them generically.

Design.

    ├── catalog/               # client of the backend catalog: scope types, roles, permissions,
    │   │                        categories, orgs and scopes — all described by the API
    │   ├── index.ts           # public API: hooks, lookups, types
    │   ├── types.ts           # API-shaped: ScopeType, ScopeLevel, Role, Permission,
    │   │                        PermissionCategory, Org, Scope (leaf)
    │   ├── lookups.ts         # getScopeType(scopeTypes, scopeKey), getRole(roles, roleId),
    │   │                        getRolesFor(scopeType, roles); always return a value
    │   ├── icons.ts           # Paragon icon name → component, with a fallback
    │   ├── messages.ts        # fallback wording only
    │   └── data/
    │       ├── api.ts         # catalog endpoint(s) ← getScopes, getOrgs
    │       └── hooks.ts       # useAuthzCatalog ← useScopes, useOrgs

  • Definitions are data. types.ts mirrors the API responses: scope types, roles, permissions
    and categories. Nothing is keyed by importing a per-type constants file.
  • Lookups always return a value. getScopeType resolves a scope key by its namespace, and getRole a role by id. Inconsistent data, such as an assignment pointing at a role missing from the catalog, gets a fallback instead of undefined.
  • The frontend keeps presentation only: layout, the Paragon icon-name lookup, grouping.
const { scopeTypes, roles, permissions, categories, isLoading } = useAuthzCatalog();

[!NOTE]
The first implementation of catalog return the already known course and libraries scopes. However, this design goes beyond that to include unknown scopes. So it could be simplified at today's requirements.

4. Page placement

None of the three pages is a sub-feature once its table or wizard lives in a sub-feature. They are pages that compose sub-features, so they belong at the authz/ root as AuthzHome.tsx, AuditUserPage.tsx and AssignRoleWizardPage.tsx, next to AuthzTitle.tsx, which all three render.

[!NOTE]
The wizard will become a modal https://github.com/openedx/frontend-app-admin-console/issues/240 then there is only 2 pages left all composed by the features. And the assign workflow is still encapsulated in 1 feature.

5. AuthZLayout and AuthZTitle

AuthZTitlehas no consumer other thanAuthZLayout. AuthZLayout` forwards five props to it and adds two wrappers.

We can import AuthZTitle directly in the pages. and replace AuthZLayout with what is in index today + the wrapper.

// AuthzLayout.tsx: feature chrome, once for every page
<div className="authz-module bg-light-200">
  <QueryErrorResetBoundary>…<Outlet />…</QueryErrorResetBoundary>
</div>

// AuthzHome.tsx: a page renders its title, then its own content
<>
  <AuthzTitle pageTitle={pageTitle} actions={[<AddRoleButton key="add-role-button" />]} />
  <Tabs>…</Tabs>
</>

Additionally, part of AuthZTitle is unused: the exported ActionButton and the { label, icon, onClick } form of actions. We can delete this and simply the component.

6. Permission validation: use frontend-base's

src/data/{api,hooks}.ts, src/types.ts

The app carries its own client for validate/me:

  • validateUserPermissions in src/data/api.ts;
  • useValidateUserPermissions and useValidateUserPermissionsNonSuspense insrc/data/hooks.ts, keyed by adminConsoleQueryKeys.permissions;
  • PermissionValidationRequest / PermissionValidationResponse in src/types.ts.

frontend-base now ships the same client in runtime/authz: validatePermissions, usePermissions, permissionsQueryKeys and the types (PermissionValidationQuery, PermissionValidationAnswer). The app no longer needs to carry its own.

7. Routing and access (related to https://github.com/openedx/frontend-app-admin-console/issues/19)

src/routes.tsx, src/loaders.ts, src/Main.tsx, src/authz-module/{index,routes,constants}.tsx

What frontend-base gives us.

  • A data router. The shell builds one createBrowserRouter from every app's routes (shell/router/createRouter.js), and src/routes.tsx already uses loader: authenticatedLoader. So route loaders, errorElement, shouldRevalidate and useRouteLoaderData are available without an app-shell change.
  • A permissions API. validatePermissions(apiBaseUrl, query) and usePermissions(query, featureEnabled) in runtime/authz take a keyed query ({ canViewLibraryTeam: { action } }) and return a keyed answer ({ canViewLibraryTeam: true }). They match results on action + scope, which works for scope-less checks: the backend leaves scope out of those results (views.py:181-182).
  • Its own QueryClient, not exposed. The shell creates it (shell/site.tsx) and exports neither the client nor a way to configure it, so the app can't set query defaults, and loaders can't reach the cache (see "Why the loader can't use usePermissions yet").

Route tree. The authz pages are route objects the data router knows about, not descendant
<Routes> rendered inside a component. Each group of pages has a gate loader:

// src/routes.tsx (today: authz is the only feature, so the app route is the authz home path)
{
  id: 'org.openedx.frontend.route.adminConsole.main',
  path: AUTHZ_HOME_PATH,                      // '/admin-console/authz'
  loader: authenticatedLoader,
  lazy: () => import('./Main'),               // title + PageWrap + AuthzModule (layout with <Outlet />)
  errorElement: <RouteErrorPage />,           // loader errors land here
  handle: { roles: ['org.openedx.frontend.role.adminConsole'] },
  children: authzRoutes,
}

// authz/routes.tsx
[
  { id: AUTHZ_VIEW_ROUTE_ID,                  // home + user page: view-team gate
    loader: requirePermissions(VIEW_TEAM_PERMISSIONS_QUERY, MANAGE_TEAM_PERMISSIONS_QUERY),
    shouldRevalidate: revalidateOnSameUrl,
    children: [
      { index: true, lazy: AuthzHome },
      { path: 'user/:username', lazy: AuditUserPage },
    ] },
  { id: AUTHZ_MANAGE_ROUTE_ID,                // wizard: manage-team gate
    path: 'assign-role',
    loader: requirePermissions(MANAGE_TEAM_PERMISSIONS_QUERY, VIEW_TEAM_PERMISSIONS_QUERY),
    shouldRevalidate: revalidateOnSameUrl,
    lazy: AssignRoleWizardPage },
  { path: '*', Component: NotFoundError },    // outside the gates: Not Found for everyone
]

Access gates: route loaders.

// src/loaders.ts: reusable by every admin-console feature
export const requirePermissions = (gate, extra = {}) => async (args) => {
  const authRedirect = await authenticatedLoader(args);
  if (authRedirect) { return authRedirect; }
  const answer = await validatePermissions(getApiUrl(''), { ...gate, ...extra });
  if (!Object.keys(gate).some((key) => answer[key])) { throw new Error(CustomErrors.NO_ACCESS); }
  return answer;
};

[!note]
This give more flexibility at the time the app grows, and experiment with core and non-core modules integration.

Leftover TODO on the Role type
  • Role carries // TODO: remove unnecessary fields when libraries gets removed. That references the first application organization.
  • The related message authz.role.card.userCounter in components/RoleCard/messages.ts has no
    consumers, because the RoleCard component no longer exists.
Dominant language
TypeScript
Stars
1
Forks
13
Avg merge
1d 7h
Merged PRs (30d)
15

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from openedx/frontend-app-admin-console

All issues in openedx/frontend-app-admin-console

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.