Proposal - feature based code organization
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
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
-
Dependencies go both ways.
components/RenderAdminRole.tsximportsaudit-user/messages, androles-permissions/utils.tsimportscomponents/RoleCard/*.utils.tsximportsroles-permissions, while files insideroles-permissions' domain importutils. Code is scattered andauthz-module/components/holds code that have only 1 consumer. -
Sub-features import from their parent (rule I).
authz-module/{constants,utils,messages}.ts,data/,hooks/andcomponents/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.
DefineApplicationScopeStepimportsroles-permissions/course/constantsdirectly.OrgFilterandTableFooterare 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 inauthz).
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
- 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 toindex.ts. Their smaller building blocks (cells, filters, modals, steps) go incomponents/, 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 noindex.ts, and only that feature and its sub-features import from it (as the twoassignmentstables shareassignments/components/). A group of atoms that grows its own logic or data becomes a sub-feature (asscope-stepdoes here). - Paths: relative inside
authz/,@src/...for app-level shared code and other features. - 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. RoleOperationErrorStatushas no consumers outsideconstants.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
authzmodule, 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 buildingcatalogfrom 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 insrc/constants.ts. Keeping the page in authz splits the pair across two levels, move it tosrc/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 (
ScopeDatasubclasses), 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.tsmirrors the API responses: scope types, roles, permissions
and categories. Nothing is keyed by importing a per-type constants file. - Lookups always return a value.
getScopeTyperesolves a scope key by its namespace, andgetRolea role by id. Inconsistent data, such as an assignment pointing at a role missing from the catalog, gets a fallback instead ofundefined. - 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:
validateUserPermissionsinsrc/data/api.ts;useValidateUserPermissionsanduseValidateUserPermissionsNonSuspenseinsrc/data/hooks.ts, keyed byadminConsoleQueryKeys.permissions;PermissionValidationRequest/PermissionValidationResponseinsrc/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
createBrowserRouterfrom every app'sroutes(shell/router/createRouter.js), andsrc/routes.tsxalready usesloader: authenticatedLoader. So route loaders,errorElement,shouldRevalidateanduseRouteLoaderDataare available without an app-shell change. - A permissions API.
validatePermissions(apiBaseUrl, query)andusePermissions(query, featureEnabled)inruntime/authztake a keyed query ({ canViewLibraryTeam: { action } }) and return a keyed answer ({ canViewLibraryTeam: true }). They match results onaction+scope, which works for scope-less checks: the backend leavesscopeout 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 useusePermissionsyet").
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
Rolecarries// TODO: remove unnecessary fields when libraries gets removed. That references the first application organization.- The related message
authz.role.card.userCounterincomponents/RoleCard/messages.tshas no
consumers, because theRoleCardcomponent no longer exists.
- Dominant language
- TypeScript
- Stars
- 1
- Forks
- 13
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 15
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from openedx/frontend-app-admin-console
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
openedx/frontend-app-admin-console#260 ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 Half a day Newbie friendliness 75/100
openedx/frontend-app-admin-console#259 ·
Maintainers usually reply within 1 day
-
User audit view: course-scope delete control can stay "course authoring disabled" incorrectlyPossibly taken @dcoa claimed this 4 days ago. Open
Difficulty 3/5 1-2 days Newbie friendliness 55/100
openedx/frontend-app-admin-console#258 ·
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 55/100
openedx/frontend-app-admin-console#257 ·
Maintainers usually reply within 1 day
-
enhancement
Difficulty 5/5 Over a week Newbie friendliness 15/100
openedx/frontend-app-admin-console#256 ·
Maintainers usually reply within 1 day
All issues in openedx/frontend-app-admin-console
Similar issues
-
area: aria/combobox gemini-triaged
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
angular/components#33973 ·
Maintainers usually reply within 1 day
-
bug priority: P2
Difficulty 1/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day
-
[bug] status, list and push crash with an uncaught NotInitializedError stack trace when teamai is not initializedPossibly taken @jay23606 claimed this today. Openbug good first issue help wanted
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Tencent/teamai-cli#1227 ·
Maintainers usually reply within 1 day
-
[Tool]: YapOpen
Difficulty 1/5 1-3 hours Newbie friendliness 62/100
withkarann/aifoxx#417 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
sindresorhus/eslint-plugin-unicorn#3921 ·
Maintainers usually reply within 1 day