Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

Changes prior to v1.0.0 are available in the [git history](https://github.com/AvaCodeSolutions/django-email-learning/commits/master).

## [7.21.0] - 2026-10-22

### Added

- **The organization page can show your own tabs.** List them in `ORGANIZATION.CUSTOM_TABS` in `DJANGO_EMAIL_LEARNING`, each with a `NAME`, a `LABEL`, and the `SCRIPT_URL`, `STYLE_URL` and `COMPONENT_TAG` of a custom element, the same way as the sidebar and dashboard custom components. They come after the built-in tabs, and `?tab=<NAME>` opens one, so it can be linked to. Override `SingleOrganization.get_organization_custom_tabs()` to build them per organization.

## [7.20.0] - 2026-10-16

### Added
Expand Down
13 changes: 8 additions & 5 deletions django_email_learning/platform/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,14 @@ def get_context_data(self, **kwargs) -> Dict[str, Any]: # type: ignore[no-untyp

def render_to_response(self, context, **response_kwargs): # type: ignore[no-untyped-def]
app_context = context.get("appContext", {})
# Dashboard's custom components share the same {componentTag, scriptUrl, styleUrl}
# shape as navbarCustomComponents, so their assets are deduped into the same
# head-injected style/script URL lists rather than duplicating this logic.
components = list(app_context.get("navbarCustomComponents", [])) + list(
app_context.get("dashboardCustomComponents", {}).values()
# Dashboard's custom components and the organization page's custom tabs share the
# same {componentTag, scriptUrl, styleUrl} shape as navbarCustomComponents, so their
# assets are deduped into the same head-injected style/script URL lists rather than
# duplicating this logic.
components = (
list(app_context.get("navbarCustomComponents", []))
+ list(app_context.get("dashboardCustomComponents", {}).values())
+ list(app_context.get("organizationCustomTabs", []))
)
context["navbarComponentStyleUrls"] = _unique(component.get("styleUrl") for component in components)
context["navbarComponentScriptUrls"] = _unique(component.get("scriptUrl") for component in components)
Expand Down
24 changes: 23 additions & 1 deletion django_email_learning/platform/views/organisations.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Dict
from typing import Dict, List, Optional

from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
Expand Down Expand Up @@ -94,8 +95,29 @@ def get_context_data(self, **kwargs): # type: ignore[no-untyped-def]
context["appContext"]["apiKeyScopes"] = [
{"value": value, "label": str(label)} for value, label in ApiKeyScope.choices
]
context["appContext"]["organizationCustomTabs"] = self.get_organization_custom_tabs(organization)
return context

def get_organization_custom_tabs(self, organization: Organization) -> List[Dict[str, Optional[str]]]:
"""
Extra tabs shown after the built-in ones, in order, each as
{name, label, componentTag, scriptUrl, styleUrl}. `name` is what ?tab=<name>
opens; one that clashes with a built-in tab is ignored by the page.
Configure via ORGANIZATION.CUSTOM_TABS in settings, or override this method
to build the tabs per organization (e.g. to put its id on the component tag).
"""
configured = getattr(settings, "DJANGO_EMAIL_LEARNING", {}).get("ORGANIZATION", {}).get("CUSTOM_TABS", [])
return [
{
"name": tab["NAME"],
"label": str(tab["LABEL"]),
"componentTag": tab["COMPONENT_TAG"],
"scriptUrl": tab.get("SCRIPT_URL"),
"styleUrl": tab.get("STYLE_URL"),
}
for tab in configured
]

def get_locale_messages(self) -> Dict[str, str]:
return {
"no_users_in_organization": _("No users in this organization yet."),
Expand Down
33 changes: 33 additions & 0 deletions docs/source/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,39 @@ Optional configuration for reordering, dropping, or extending the sections shown
},
}

**ORGANIZATION.CUSTOM_TABS**

Optional list of extra tabs on an organization's page, shown in order after the built-in ones (General Info, Members, Newsletters, API Keys). Use it for settings that belong to the organization but not to the library, such as the connection to a payment provider. Like the page itself, the tabs are only shown to the organization's admins.

- ``NAME``: The tab's key. ``?tab=<NAME>`` on the organization page opens it, so it can be linked to. A name that clashes with a built-in tab (``general_info``, ``members``, ``newsletters``, ``api_keys``) is ignored.
- ``LABEL``: The tab's title. Use ``gettext_lazy`` to translate it.
- ``SCRIPT_URL``: URL of the JavaScript that registers your custom element.
- ``STYLE_URL``: Optional stylesheet URL for the component (use ``None`` if not needed).
- ``COMPONENT_TAG``: HTML rendered in the tab while it is open.

The component can read the organization's id from ``organizationId`` in the page's ``app-context`` JSON script. To build the tabs per organization instead, for example to put its id on the tag or to show a tab only on some plans, override ``SingleOrganization.get_organization_custom_tabs(organization)``, which returns the same tabs as ``{name, label, componentTag, scriptUrl, styleUrl}``.

.. code-block:: python

from django.utils.translation import gettext_lazy as _

DJANGO_EMAIL_LEARNING = {
'SITE_BASE_URL': 'https://yourdomain.com',
'ENCRYPTION_SECRET_KEY': 'your-very-long-random-string',
'JWT_SECRET_KEY': 'another-very-long-random-string',
'ORGANIZATION': {
'CUSTOM_TABS': [
{
'NAME': 'integrations',
'LABEL': _('Integrations'),
'SCRIPT_URL': 'url/path-to-your-component.js',
'STYLE_URL': None,
'COMPONENT_TAG': '<your-integrations-tab></your-integrations-tab>',
},
],
},
}

**SIDEBAR.CUSTOM_COMPONENT**

Optional configuration for injecting a custom component in the platform sidebar.
Expand Down
31 changes: 29 additions & 2 deletions frontend/platform/organization/Organization.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ import InfoIcon from '@mui/icons-material/Info';
import PublicIcon from '@mui/icons-material/Public';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import VpnKeyOutlinedIcon from '@mui/icons-material/VpnKeyOutlined';
import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined';
import { useState, useEffect } from "react";
import apiClient from "../../src/apiClient.js";
import { Button } from "@mui/material";
import render, { useAppContext } from "../../src/render";
import { sanitizeEndpointUrl, sanitizeImageUrl, sanitizeUrl } from '../../src/sanitizeUrl.js';
import { sanitizeComponentHtml } from '../../src/sanitizeHtml.js';

const UserForm = lazy(() => import("./components/UserForm.jsx"));
const DeleteUserDialog = lazy(() => import("./components/DeleteUserDialog.jsx"));
Expand All @@ -39,6 +41,7 @@ const NewApiKeyDialog = lazy(() => import("./components/NewApiKeyDialog.jsx"));
const RevokeApiKeyDialog = lazy(() => import("./components/RevokeApiKeyDialog.jsx"));

const DEFAULT_TAB = 'general_info';
const BUILT_IN_TAB_NAMES = ['general_info', 'members', 'newsletters', 'api_keys'];

const apiKeyStatusOf = (apiKey) => {
if (apiKey.revoked_at) return 'revoked';
Expand All @@ -57,7 +60,7 @@ function Organization() {
const [generalInfoFormKey, setGeneralInfoFormKey] = useState(0);
const [publicUrlCopied, setPublicUrlCopied] = useState(false);

const { localeMessages, direction, userRole, isOrganizationAdmin, isPlatformAdmin, apiBaseUrl: rawApiBaseUrl, platformBaseUrl: rawPlatformBaseUrl, organizationId, currentUserId, availableFeatures = [] } = useAppContext();
const { localeMessages, direction, userRole, isOrganizationAdmin, isPlatformAdmin, apiBaseUrl: rawApiBaseUrl, platformBaseUrl: rawPlatformBaseUrl, organizationId, currentUserId, availableFeatures = [], organizationCustomTabs = [] } = useAppContext();
const apiBaseUrl = sanitizeEndpointUrl(rawApiBaseUrl);
const platformBaseUrl = sanitizeUrl(rawPlatformBaseUrl);
const canEditOrganization = isPlatformAdmin || isOrganizationAdmin;
Expand All @@ -70,12 +73,19 @@ function Organization() {

// ?tab=<name> opens a specific tab on load; anything unknown - or a tab this
// user cannot see - falls back to the default one.
const availableTabs = [
const builtInTabs = [
'general_info',
'members',
...(newslettersEnabled ? ['newsletters'] : []),
...(canManageApiKeys ? ['api_keys'] : []),
];
// Tabs the deployment adds through ORGANIZATION.CUSTOM_TABS, after the built-in
// ones. A name that clashes with a built-in tab would make ?tab=<name> ambiguous,
// so such a tab is dropped rather than shadowing the built-in one.
const customTabs = organizationCustomTabs.filter(
(tab) => tab?.name && tab.componentTag && !BUILT_IN_TAB_NAMES.includes(tab.name)
);
const availableTabs = [...builtInTabs, ...customTabs.map((tab) => tab.name)];
const [activeTab, setActiveTab] = useState(() => {
const requestedTab = new URLSearchParams(window.location.search).get('tab');
return availableTabs.includes(requestedTab) ? requestedTab : DEFAULT_TAB;
Expand Down Expand Up @@ -228,6 +238,15 @@ function Organization() {
label={localeMessages["api_keys"]}
/>
)}
{customTabs.map((tab) => (
<Tab
key={tab.name}
value={tab.name}
icon={<ExtensionOutlinedIcon fontSize="small" />}
iconPosition="start"
label={tab.label}
/>
))}
</Tabs>

<Box sx={{ p: { xs: 1, sm: 2 } }}>
Expand Down Expand Up @@ -546,6 +565,14 @@ function Organization() {
</Box>
</>
)}

{/* Custom tabs */}
{customTabs.filter((tab) => tab.name === activeTab).map((tab) => (
<Box
key={tab.name}
dangerouslySetInnerHTML={{ __html: sanitizeComponentHtml(tab.componentTag) }}
/>
))}
</Box>
</Box>
</Grid>
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/test/platform/Organization.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,67 @@ describe('Organization', () => {

await waitFor(() => expect(writeText).toHaveBeenCalledWith('https://example.com/public/organization/1/'));
});
describe('custom tabs', () => {
const organizationCustomTabs = [
{ name: 'integrations', label: 'Integrations', componentTag: '<integrations-tab organization-id="1"></integrations-tab>' },
];

it('shows a custom tab after the built-in ones and renders its component when opened', async () => {
const user = userEvent.setup();
const { container } = renderWithProviders(<Organization />, {
appContext: { ...baseAppContext, isOrganizationAdmin: true, organizationCustomTabs },
});

const tabs = await screen.findAllByRole('tab');
expect(tabs[tabs.length - 1]).toHaveTextContent('Integrations');
expect(container.querySelector('integrations-tab')).toBeNull();

await user.click(screen.getByRole('tab', { name: /Integrations/i }));

const element = container.querySelector('integrations-tab');
expect(element).not.toBeNull();
expect(element.getAttribute('organization-id')).toBe('1');
});

it('opens a custom tab named in the tab query string', async () => {
window.history.replaceState({}, '', '/organizations/1/?tab=integrations');
const { container } = renderWithProviders(<Organization />, {
appContext: { ...baseAppContext, isOrganizationAdmin: true, organizationCustomTabs },
});

expect(await selectedTabName()).toMatch(/Integrations/i);
expect(container.querySelector('integrations-tab')).not.toBeNull();
});

it('ignores a custom tab whose name clashes with a built-in tab', async () => {
renderWithProviders(<Organization />, {
appContext: {
...baseAppContext,
isOrganizationAdmin: true,
organizationCustomTabs: [{ name: 'members', label: 'Impostor', componentTag: '<impostor-tab></impostor-tab>' }],
},
});

await screen.findByRole('tab', { name: /Members/i });
expect(screen.queryByRole('tab', { name: /Impostor/i })).toBeNull();
});

it('strips event handlers from the custom component tag', async () => {
window.history.replaceState({}, '', '/organizations/1/?tab=integrations');
const { container } = renderWithProviders(<Organization />, {
appContext: {
...baseAppContext,
isOrganizationAdmin: true,
organizationCustomTabs: [
{ name: 'integrations', label: 'Integrations', componentTag: '<integrations-tab onclick="alert(1)"></integrations-tab>' },
],
},
});

await screen.findByRole('tab', { name: /Integrations/i });
const element = container.querySelector('integrations-tab');
expect(element).not.toBeNull();
expect(element.hasAttribute('onclick')).toBe(false);
});
});
});
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "django-email-learning"
version = "7.20.0"
version = "7.21.0"
description = "A platform for creating and delivering learning materials via email within a Django application. It provides tools for content management, user role-based administration, and scheduler integration for automated content delivery."
authors = [
{name = "Payam Najafizadeh",email = "[email protected]"}
Expand Down
44 changes: 44 additions & 0 deletions tests/platform/test_views/test_single_organization_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,47 @@ def test_api_key_scopes_are_passed_to_the_frontend(org_admin_client):

assert [scope["value"] for scope in scopes] == list(ApiKeyScope.values)
assert all(scope["label"] for scope in scopes)


def test_no_custom_tabs_by_default(org_admin_client):
response = org_admin_client.get(URL)
assert response.context["appContext"]["organizationCustomTabs"] == []


def test_custom_tabs_come_from_settings_and_load_their_assets(org_admin_client, settings):
settings.DJANGO_EMAIL_LEARNING = {
**settings.DJANGO_EMAIL_LEARNING,
"ORGANIZATION": {
"CUSTOM_TABS": [
{
"NAME": "integrations",
"LABEL": "Integrations",
"SCRIPT_URL": "/static/integrations.js",
"STYLE_URL": "/static/integrations.css",
"COMPONENT_TAG": "<integrations-tab></integrations-tab>",
},
{"NAME": "billing", "LABEL": "Billing", "COMPONENT_TAG": "<billing-tab></billing-tab>"},
]
},
}

response = org_admin_client.get(URL)

assert response.context["appContext"]["organizationCustomTabs"] == [
{
"name": "integrations",
"label": "Integrations",
"componentTag": "<integrations-tab></integrations-tab>",
"scriptUrl": "/static/integrations.js",
"styleUrl": "/static/integrations.css",
},
{
"name": "billing",
"label": "Billing",
"componentTag": "<billing-tab></billing-tab>",
"scriptUrl": None,
"styleUrl": None,
},
]
assert "/static/integrations.js" in response.context["navbarComponentScriptUrls"]
assert "/static/integrations.css" in response.context["navbarComponentStyleUrls"]
Loading