diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ccaa7d..4b26fd32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` 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 diff --git a/django_email_learning/platform/views/base.py b/django_email_learning/platform/views/base.py index aaaadf53..97e02e42 100644 --- a/django_email_learning/platform/views/base.py +++ b/django_email_learning/platform/views/base.py @@ -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) diff --git a/django_email_learning/platform/views/organisations.py b/django_email_learning/platform/views/organisations.py index c7b67ce8..cf0d98f5 100644 --- a/django_email_learning/platform/views/organisations.py +++ b/django_email_learning/platform/views/organisations.py @@ -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 _ @@ -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= + 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."), diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 59bfa569..4833c857 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -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=`` 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': '', + }, + ], + }, + } + **SIDEBAR.CUSTOM_COMPONENT** Optional configuration for injecting a custom component in the platform sidebar. diff --git a/frontend/platform/organization/Organization.jsx b/frontend/platform/organization/Organization.jsx index b46fe9ff..f8b6be34 100644 --- a/frontend/platform/organization/Organization.jsx +++ b/frontend/platform/organization/Organization.jsx @@ -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")); @@ -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'; @@ -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; @@ -70,12 +73,19 @@ function Organization() { // ?tab= 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= 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; @@ -228,6 +238,15 @@ function Organization() { label={localeMessages["api_keys"]} /> )} + {customTabs.map((tab) => ( + } + iconPosition="start" + label={tab.label} + /> + ))} @@ -546,6 +565,14 @@ function Organization() { )} + + {/* Custom tabs */} + {customTabs.filter((tab) => tab.name === activeTab).map((tab) => ( + + ))} diff --git a/frontend/src/test/platform/Organization.test.jsx b/frontend/src/test/platform/Organization.test.jsx index 1fbde33d..217b613e 100644 --- a/frontend/src/test/platform/Organization.test.jsx +++ b/frontend/src/test/platform/Organization.test.jsx @@ -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: '' }, + ]; + + it('shows a custom tab after the built-in ones and renders its component when opened', async () => { + const user = userEvent.setup(); + const { container } = renderWithProviders(, { + 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(, { + 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(, { + appContext: { + ...baseAppContext, + isOrganizationAdmin: true, + organizationCustomTabs: [{ name: 'members', label: 'Impostor', componentTag: '' }], + }, + }); + + 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(, { + appContext: { + ...baseAppContext, + isOrganizationAdmin: true, + organizationCustomTabs: [ + { name: 'integrations', label: 'Integrations', componentTag: '' }, + ], + }, + }); + + await screen.findByRole('tab', { name: /Integrations/i }); + const element = container.querySelector('integrations-tab'); + expect(element).not.toBeNull(); + expect(element.hasAttribute('onclick')).toBe(false); + }); + }); }); diff --git a/pyproject.toml b/pyproject.toml index 6b9a68ba..ae4d436c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = "payam.nj@gmail.com"} diff --git a/tests/platform/test_views/test_single_organization_view.py b/tests/platform/test_views/test_single_organization_view.py index 5ff5ffad..89b982cd 100644 --- a/tests/platform/test_views/test_single_organization_view.py +++ b/tests/platform/test_views/test_single_organization_view.py @@ -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": "", + }, + {"NAME": "billing", "LABEL": "Billing", "COMPONENT_TAG": ""}, + ] + }, + } + + response = org_admin_client.get(URL) + + assert response.context["appContext"]["organizationCustomTabs"] == [ + { + "name": "integrations", + "label": "Integrations", + "componentTag": "", + "scriptUrl": "/static/integrations.js", + "styleUrl": "/static/integrations.css", + }, + { + "name": "billing", + "label": "Billing", + "componentTag": "", + "scriptUrl": None, + "styleUrl": None, + }, + ] + assert "/static/integrations.js" in response.context["navbarComponentScriptUrls"] + assert "/static/integrations.css" in response.context["navbarComponentStyleUrls"]