diff --git a/.gitignore b/.gitignore index 16e9cbed7..28cf5727f 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ tests/test_data/rapl/* credentials* .codecarbon.config* scripts/agent-vm.personal.config.sh + +# Vite cache +.vite/ diff --git a/carbonserver/.env.example b/carbonserver/.env.example index ce8189276..9c0a405b2 100644 --- a/carbonserver/.env.example +++ b/carbonserver/.env.example @@ -1 +1,6 @@ DATABASE_URL= + +# Matomo analytics for server-side events (account created, first run received). +# Off unless both are set. Same instance and site as the webapp's VITE_MATOMO_*. +MATOMO_URL= +MATOMO_SITE_ID= diff --git a/carbonserver/carbonserver/api/infra/repositories/repository_runs.py b/carbonserver/carbonserver/api/infra/repositories/repository_runs.py index c14fb25e8..0919c22ca 100644 --- a/carbonserver/carbonserver/api/infra/repositories/repository_runs.py +++ b/carbonserver/carbonserver/api/infra/repositories/repository_runs.py @@ -117,6 +117,14 @@ def get_runs_from_experiment(self, experiment_id) -> List[Run]: raise EmptyResultException(f"No runs for experiment {experiment_id}") return [self.map_sql_to_schema(e) for e in res] + def count_runs_from_experiment(self, experiment_id) -> int: + with self.session_factory() as session: + return ( + session.query(func.count(SqlModelRun.id)) + .filter(SqlModelRun.experiment_id == experiment_id) + .scalar() + ) + @staticmethod def map_sql_to_schema(run: SqlModelRun) -> Run: """Convert a models.Run to a schemas.Run diff --git a/carbonserver/carbonserver/api/routers/authenticate.py b/carbonserver/carbonserver/api/routers/authenticate.py index e6abe8481..ef5ff8be7 100644 --- a/carbonserver/carbonserver/api/routers/authenticate.py +++ b/carbonserver/carbonserver/api/routers/authenticate.py @@ -19,6 +19,8 @@ LOGGER = logging.getLogger(__name__) SESSION_COOKIE_NAME = "user_session" +# Raw id_token, kept only as the hint for provider-side logout. +ID_TOKEN_COOKIE_NAME = "user_id_token" router = APIRouter() @@ -79,12 +81,23 @@ async def get_login( url = f"{base_url}home" response = auth_provider.create_redirect_response(url) + # A Secure cookie is dropped outright over plain http, which is every + # local dev session, so the requirement is relaxed there. + cookie_options = { + "httponly": True, + "secure": settings.environment not in ("local", "develop"), + "samesite": "lax", + } response.set_cookie( - SESSION_COOKIE_NAME, - token["access_token"], - httponly=True, - secure=True, + SESSION_COOKIE_NAME, token["access_token"], **cookie_options ) + # Its own cookie rather than the session: the session is a signed + # client-side cookie capped at ~4KB, and an id_token is large enough to + # push it over and get it dropped silently. + if isinstance(token.get("id_token"), str): + response.set_cookie( + ID_TOKEN_COOKIE_NAME, token["id_token"], **cookie_options + ) return response return await auth_provider.get_authorize_url(request, str(login_url)) @@ -94,13 +107,17 @@ async def get_login( async def logout( request: Request, response: Response, - auth_user: UserWithAuthDependency = Depends(UserWithAuthDependency), auth_provider: Optional[OIDCAuthProvider] = Depends( Provide[ServerContainer.auth_provider] ), ): """ - Logout user by clearing session and removing cookie + Logout user by ending the provider's SSO session, clearing our own session + and removing the cookie. + + Deliberately takes no auth dependency: the session token is verified against + the provider, so an expired or invalid one would be refused with a 401 before + this ran — which is exactly when the user most needs to be able to sign out. """ if auth_provider is None: return RedirectResponse(settings.default_redirect_url) @@ -110,10 +127,41 @@ async def logout( if access_token: await auth_provider.revoke_token(access_token) - base_url = request.base_url - response = auth_provider.create_redirect_response(str(base_url)) + id_token = request.cookies.get(ID_TOKEN_COOKIE_NAME) + + # Land the user back on the dashboard, not on the API host. The trailing + # slash is deliberate: the provider matches this against the client's + # registered post-logout URIs exactly. + post_logout_url = ( + f"{settings.frontend_url.rstrip('/')}/" + if settings.frontend_url + else str(request.base_url) + ) + redirect_url = await auth_provider.get_end_session_url( + post_logout_url, id_token=id_token + ) + + response = auth_provider.create_redirect_response(redirect_url or post_logout_url) response.delete_cookie(SESSION_COOKIE_NAME) + response.delete_cookie(ID_TOKEN_COOKIE_NAME) if hasattr(request, "session"): request.session.clear() return response + + +@router.get("/auth/account", name="account") +@inject +async def account( + auth_provider: Optional[OIDCAuthProvider] = Depends( + Provide[ServerContainer.auth_provider] + ), +): + """ + Send the user to the provider's account console, which owns their email and + password. The issuer lives in the server config only, so the frontend links + here rather than needing the URL itself. + """ + if auth_provider is None: + return RedirectResponse(settings.default_redirect_url) + return auth_provider.create_redirect_response(auth_provider.get_account_url()) diff --git a/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py b/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py index b38eb5f1b..76559902c 100644 --- a/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py +++ b/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py @@ -7,6 +7,7 @@ import logging from typing import Any, Dict, Optional +from urllib.parse import urlencode from authlib.integrations.starlette_client import OAuth from fastapi import Response @@ -96,6 +97,50 @@ async def revoke_token(self, token: str) -> None: except Exception as e: LOGGER.warning("Token revocation failed (non-blocking): %s", e) + async def get_end_session_url( + self, post_logout_redirect_uri: str, id_token: Optional[str] = None + ) -> Optional[str]: + """Build the provider's RP-initiated logout URL (OIDC session management). + + Revoking the access token only invalidates our own credential — the + provider keeps its SSO session, so the next login silently re-issues a + token and the user never appears to have signed out. Sending the browser + here is what actually ends that session. + + Returns None when the provider exposes no end_session_endpoint, so the + caller can fall back to a plain local logout. + """ + try: + metadata = await self.client.load_server_metadata() + except Exception as e: # never block logout on a metadata failure + LOGGER.warning("Could not load OIDC metadata for logout: %s", e) + return None + + end_session_endpoint = metadata.get("end_session_endpoint") + if not end_session_endpoint: + LOGGER.debug( + "OIDC provider does not expose an end_session_endpoint, " + "falling back to a local-only logout" + ) + return None + + params = {"post_logout_redirect_uri": post_logout_redirect_uri} + # A redirect target is only honoured alongside one of these two; the + # id_token is the stronger hint, but it is absent if the session was + # established before we started keeping it. + if id_token: + params["id_token_hint"] = id_token + else: + params["client_id"] = settings.oidc_client_id + return f"{end_session_endpoint}?{urlencode(params)}" + + @staticmethod + def get_account_url() -> str: + """The provider-hosted account console, where users manage their own + email and password. Derived from the issuer the same way the login URL + is, so there is nothing extra to configure.""" + return f"{settings.oidc_issuer_url.rstrip('/')}/account/" + @staticmethod def create_redirect_response(url: str) -> Response: """RedirectResponse doesn't work with clevercloud, so we return a HTML page with a script to redirect the user diff --git a/carbonserver/carbonserver/api/services/matomo_tracker.py b/carbonserver/carbonserver/api/services/matomo_tracker.py new file mode 100644 index 000000000..ffbdd5000 --- /dev/null +++ b/carbonserver/carbonserver/api/services/matomo_tracker.py @@ -0,0 +1,51 @@ +import logging +import secrets +import threading +from typing import Optional + +import httpx + +LOGGER = logging.getLogger(__name__) + + +class MatomoTracker: + """Send events to Matomo's HTTP Tracking API for things the browser cannot + see, such as the account being created on the API side. + + Events carry a category, an action and an optional name, and nothing that + identifies a person: the visitor id is random per event, so they only + count occurrences. Sending is best effort and never blocks or fails the + request that triggered it. Without a URL and site id it does nothing. + """ + + def __init__(self, url: str = "", site_id: str = "") -> None: + self._endpoint = f"{url.rstrip('/')}/matomo.php" if url else "" + self._site_id = site_id + + @property + def enabled(self) -> bool: + return bool(self._endpoint and self._site_id) + + def track_event( + self, category: str, action: str, name: Optional[str] = None + ) -> None: + if not self.enabled: + return + params = { + "idsite": self._site_id, + "rec": "1", + "apiv": "1", + "e_c": category, + "e_a": action, + "_id": secrets.token_hex(8), + "rand": secrets.token_hex(4), + } + if name: + params["e_n"] = name + threading.Thread(target=self._send, args=(params,), daemon=True).start() + + def _send(self, params: dict) -> None: + try: + httpx.get(self._endpoint, params=params, timeout=3.0) + except httpx.HTTPError as error: + LOGGER.debug("Matomo event not sent: %s", error) diff --git a/carbonserver/carbonserver/api/services/run_service.py b/carbonserver/carbonserver/api/services/run_service.py index 801166204..45c87afd1 100644 --- a/carbonserver/carbonserver/api/services/run_service.py +++ b/carbonserver/carbonserver/api/services/run_service.py @@ -5,6 +5,7 @@ from carbonserver.api.infra.repositories.repository_runs import SqlAlchemyRepository from carbonserver.api.schemas import Run, RunCreate, User from carbonserver.api.services.auth_context import AuthContext +from carbonserver.api.services.matomo_tracker import MatomoTracker def _not_allowed() -> UserException: @@ -21,12 +22,22 @@ def __init__( self, run_repository: SqlAlchemyRepository, auth_context: AuthContext, + matomo_tracker: MatomoTracker | None = None, ): self._repository = run_repository self._auth_context = auth_context + self._matomo_tracker = matomo_tracker def add_run(self, run: RunCreate, user: User = None) -> Run: created_run = self._repository.add_run(run) + # The last step of the activation funnel: an experiment receiving its + # first run shows someone actually integrated the package. + if ( + self._matomo_tracker + and self._matomo_tracker.enabled + and self._repository.count_runs_from_experiment(run.experiment_id) == 1 + ): + self._matomo_tracker.track_event("Activation", "first_run_received") return created_run def read_run(self, run_id: UUID, user: User = None) -> Run: diff --git a/carbonserver/carbonserver/api/services/signup_service.py b/carbonserver/carbonserver/api/services/signup_service.py index f6f42d12c..0e3a2c79e 100644 --- a/carbonserver/carbonserver/api/services/signup_service.py +++ b/carbonserver/carbonserver/api/services/signup_service.py @@ -13,6 +13,7 @@ from carbonserver.api.infra.repositories.repository_users import ( SqlAlchemyRepository as UserRepository, ) +from carbonserver.api.services.matomo_tracker import MatomoTracker from carbonserver.api.schemas import ( OrganizationCreate, ProjectCreate, @@ -29,10 +30,12 @@ def __init__( user_repository: UserRepository, organization_repository: OrganizationRepository, project_repository: ProjectRepository, + matomo_tracker: MatomoTracker | None = None, ) -> None: self._user_repository: UserRepository = user_repository self._organization_repository: OrganizationRepository = organization_repository self._project_repository: ProjectRepository = project_repository + self._matomo_tracker = matomo_tracker def sign_up( self, @@ -41,6 +44,8 @@ def sign_up( created_user = self._user_repository.create_user(user) subscribed_user = self.new_user_setup(created_user) LOGGER.info(f"User {subscribed_user.id} created") + if self._matomo_tracker: + self._matomo_tracker.track_event("Activation", "account_created") return subscribed_user def subscribe_user_to_org( diff --git a/carbonserver/carbonserver/config.py b/carbonserver/carbonserver/config.py index 30c6c6c21..53312037f 100644 --- a/carbonserver/carbonserver/config.py +++ b/carbonserver/carbonserver/config.py @@ -40,6 +40,11 @@ class Settings(BaseSettings): "", validation_alias=AliasChoices("DEFAULT_REDIRECT_URL", "default_redirect_url"), ) + # Matomo analytics for server-side events. Off unless both are set. + matomo_url: str = Field("", validation_alias=AliasChoices("MATOMO_URL", "matomo_url")) + matomo_site_id: str = Field( + "", validation_alias=AliasChoices("MATOMO_SITE_ID", "matomo_site_id") + ) environment: str = Field("production") jwt_key: str = Field("", validation_alias=AliasChoices("JWT_KEY", "jwt_key")) api_port: int = Field(8080, validation_alias=AliasChoices("API_PORT", "api_port")) diff --git a/carbonserver/carbonserver/container.py b/carbonserver/carbonserver/container.py index cd6fab150..7e2967850 100644 --- a/carbonserver/carbonserver/container.py +++ b/carbonserver/carbonserver/container.py @@ -17,6 +17,7 @@ ) from carbonserver.api.services.emissions_service import EmissionService from carbonserver.api.services.experiments_service import ExperimentService +from carbonserver.api.services.matomo_tracker import MatomoTracker from carbonserver.api.services.organization_service import OrganizationService from carbonserver.api.services.project_service import ProjectService from carbonserver.api.services.project_token_service import ProjectTokenService @@ -174,10 +175,17 @@ class ServerContainer(containers.DeclarativeContainer): auth_context=auth_context, ) + matomo_tracker = providers.Singleton( + MatomoTracker, + url=settings.matomo_url, + site_id=settings.matomo_site_id, + ) + run_service = providers.Factory( RunService, run_repository=run_repository, auth_context=auth_context, + matomo_tracker=matomo_tracker, ) sign_up_service = providers.Factory( @@ -185,4 +193,5 @@ class ServerContainer(containers.DeclarativeContainer): user_repository=user_repository, organization_repository=organization_repository, project_repository=project_repository, + matomo_tracker=matomo_tracker, ) diff --git a/carbonserver/tests/api/routers/test_authenticate.py b/carbonserver/tests/api/routers/test_authenticate.py index 61a3533dc..8b28425e1 100644 --- a/carbonserver/tests/api/routers/test_authenticate.py +++ b/carbonserver/tests/api/routers/test_authenticate.py @@ -176,3 +176,95 @@ async def test_revoke_token_exception(oidc_provider, mock_oidc_client): # Should not raise await oidc_provider.revoke_token("test-access-token") + + +# --- RP-initiated logout tests --- + + +@pytest.mark.asyncio +async def test_end_session_url_prefers_id_token_hint(oidc_provider, mock_oidc_client): + """The id_token is the hint the provider trusts, so it wins when present.""" + mock_oidc_client.load_server_metadata.return_value = { + "end_session_endpoint": "https://auth.example.com/logout", + } + + url = await oidc_provider.get_end_session_url( + "https://dashboard.test", id_token="raw-id-token" + ) + + assert url.startswith("https://auth.example.com/logout?") + assert "id_token_hint=raw-id-token" in url + assert "post_logout_redirect_uri=https%3A%2F%2Fdashboard.test" in url + assert "client_id" not in url + + +@pytest.mark.asyncio +async def test_end_session_url_falls_back_to_client_id(oidc_provider, mock_oidc_client): + """Sessions opened before we kept the id_token still need a redirect target, + which the provider only honours alongside a client_id.""" + mock_oidc_client.load_server_metadata.return_value = { + "end_session_endpoint": "https://auth.example.com/logout", + } + + url = await oidc_provider.get_end_session_url("https://dashboard.test") + + assert "client_id=" in url + assert "id_token_hint" not in url + + +@pytest.mark.asyncio +async def test_end_session_url_without_endpoint(oidc_provider, mock_oidc_client): + """No end_session_endpoint means a local-only logout, not a failure.""" + mock_oidc_client.load_server_metadata.return_value = {} + + assert await oidc_provider.get_end_session_url("https://dashboard.test") is None + + +@pytest.mark.asyncio +async def test_end_session_url_metadata_failure(oidc_provider, mock_oidc_client): + """Logout must never break because the provider is unreachable.""" + mock_oidc_client.load_server_metadata.side_effect = ConnectionError("unreachable") + + assert await oidc_provider.get_end_session_url("https://dashboard.test") is None + + +def test_account_url_derives_from_issuer(monkeypatch): + """The account console is derived from the issuer, so there is no separate + setting to keep in sync.""" + monkeypatch.setattr( + "carbonserver.api.services.auth_providers.oidc_auth_provider.settings." + "oidc_issuer_url", + "https://authentication.codecarbon.io/realms/codecarbon/", + raising=False, + ) + + assert ( + OIDCAuthProvider.get_account_url() + == "https://authentication.codecarbon.io/realms/codecarbon/account/" + ) + + +@pytest.mark.asyncio +async def test_login_keeps_raw_id_token_in_its_own_cookie(monkeypatch): + """The logout hint must live outside the size-capped session cookie, and + only a raw JWT string is usable as a hint.""" + monkeypatch.setattr(authenticate.settings, "frontend_url", "https://dashboard.test") + token = {"access_token": "access-secret", "id_token": "raw.id.token"} + request = MagicMock() + request.url_for.return_value = "https://api.test/auth/login" + request.base_url = "https://api.test/" + request.session = {} + auth_provider = MagicMock() + auth_provider.client.authorize_access_token = AsyncMock(return_value=token) + auth_provider.create_redirect_response.side_effect = lambda url: Response(url) + + response = await authenticate.get_login( + request=request, + code="authorization-code", + sign_up_service=MagicMock(), + auth_provider=auth_provider, + ) + + cookies = response.headers.getlist("set-cookie") + assert any(c.startswith("user_id_token=raw.id.token;") for c in cookies) + assert "id_token" not in request.session diff --git a/carbonserver/tests/api/service/test_matomo_tracker.py b/carbonserver/tests/api/service/test_matomo_tracker.py new file mode 100644 index 000000000..0289a7a74 --- /dev/null +++ b/carbonserver/tests/api/service/test_matomo_tracker.py @@ -0,0 +1,25 @@ +from unittest import mock + +from carbonserver.api.services.matomo_tracker import MatomoTracker + + +def test_tracker_is_disabled_without_url_and_site_id(): + assert not MatomoTracker().enabled + assert not MatomoTracker(url="https://matomo.example.com").enabled + assert not MatomoTracker(site_id="3").enabled + + +def test_tracker_sends_nothing_when_disabled(): + with mock.patch("carbonserver.api.services.matomo_tracker.httpx.get") as get: + MatomoTracker().track_event("Activation", "account_created") + get.assert_not_called() + + +def test_tracker_sends_an_anonymous_event(): + tracker = MatomoTracker(url="https://matomo.example.com/", site_id="3") + with mock.patch("carbonserver.api.services.matomo_tracker.threading.Thread") as t: + tracker.track_event("Activation", "account_created") + params = t.call_args.kwargs["args"][0] + assert params["idsite"] == "3" + assert (params["e_c"], params["e_a"]) == ("Activation", "account_created") + assert set(params) <= {"idsite", "rec", "apiv", "e_c", "e_a", "e_n", "_id", "rand"} diff --git a/carbonserver/tests/api/service/test_organization_service.py b/carbonserver/tests/api/service/test_organization_service.py index a7e1c3ccc..cc183843d 100644 --- a/carbonserver/tests/api/service/test_organization_service.py +++ b/carbonserver/tests/api/service/test_organization_service.py @@ -121,6 +121,42 @@ def test_organization_service_add_user_by_mail_adds_non_admin_member(): ) +def test_organization_service_remove_user_requires_admin(): + repository_mock: OrganizationRepository = mock.Mock(spec=OrganizationRepository) + user_repository_mock: UserRepository = mock.Mock(spec=UserRepository) + auth_context = mock.Mock() + auth_context.can_write_organization.return_value = False + organization_service: OrganizationService = OrganizationService( + user_repository=user_repository_mock, + organization_repository=repository_mock, + auth_context=auth_context, + ) + + with pytest.raises(UserException) as exc_info: + organization_service.remove_user( + organization_id=ORG_ID, user_id=USER_ID_1, user=DUMMY_USER + ) + + assert exc_info.value.error.code == NotAllowedErrorEnum.NOT_IN_ORGANISATION + user_repository_mock.unsubscribe_user_from_org.assert_not_called() + + +def test_organization_service_remove_user_removes_an_admin_when_another_remains(): + repository_mock: OrganizationRepository = mock.Mock(spec=OrganizationRepository) + user_repository_mock: UserRepository = mock.Mock(spec=UserRepository) + organization_service = _organization_service(repository_mock, user_repository_mock) + other_admin = ORG_MEMBER.model_copy(update={"is_admin": True}) + repository_mock.list_users.return_value = [ORG_USER, other_admin] + + organization_service.remove_user( + organization_id=ORG_ID, user_id=other_admin.id, user=DUMMY_USER + ) + + user_repository_mock.unsubscribe_user_from_org.assert_called_once_with( + user_id=other_admin.id, organization_id=ORG_ID + ) + + def test_organiation_service_retrieves_all_existing_organizations(): expected_org_ids_list = [ORG_ID, ORG_ID_2] repository_mock: OrganizationRepository = mock.Mock(spec=OrganizationRepository) diff --git a/carbonserver/tests/api/service/test_run_service.py b/carbonserver/tests/api/service/test_run_service.py index 09b900c55..9e9580525 100644 --- a/carbonserver/tests/api/service/test_run_service.py +++ b/carbonserver/tests/api/service/test_run_service.py @@ -149,3 +149,28 @@ def test_run_service_retrieves_correct_last_run_for_project_id(): actual_run = run_service.read_project_last_run(PROJECT_ID, START_DATE, END_DATE) assert actual_run.id == expected_run_id + + +def test_run_service_tracks_only_the_first_run_of_an_experiment(): + repository_mock: SqlAlchemyRepository = mock.Mock(spec=SqlAlchemyRepository) + tracker_mock = mock.Mock(enabled=True) + run_service = RunService( + repository_mock, auth_context=FakeAuthContext(), matomo_tracker=tracker_mock + ) + repository_mock.add_run.return_value = RUN_1 + run_to_create = RunCreate( + id=RUN_ID, + timestamp="2021-04-04T08:43:00+02:00", + experiment_id=EXPERIMENT_ID, + ) + + repository_mock.count_runs_from_experiment.return_value = 1 + run_service.add_run(run_to_create) + tracker_mock.track_event.assert_called_once_with( + "Activation", "first_run_received" + ) + + tracker_mock.reset_mock() + repository_mock.count_runs_from_experiment.return_value = 2 + run_service.add_run(run_to_create) + tracker_mock.track_event.assert_not_called() diff --git a/docs/how-to/agent-instructions.md b/docs/how-to/agent-instructions.md index 54aaeabb5..0d9454e87 100644 --- a/docs/how-to/agent-instructions.md +++ b/docs/how-to/agent-instructions.md @@ -34,6 +34,8 @@ Here's what you need to know to navigate and contribute effectively. - Modern React web application - Connects to the API backend - Run with: `cd webapp && pnpm dev` + - **Before editing anything under `webapp/`, read `webapp/AGENTS.md` first** — + it is binding for all webapp work (propose before editing, never commit). ### Key Directories diff --git a/privacy_policy_en.md b/privacy_policy_en.md index c5bcab6dc..7767260f9 100644 --- a/privacy_policy_en.md +++ b/privacy_policy_en.md @@ -153,6 +153,10 @@ We use both Session and Persistent Cookies for the purposes set out below: +#### Usage Analytics + +The web dashboard measures usage with a self-hosted Matomo instance: pages visited and a few actions (for example creating a project or applying a date range). Matomo runs in cookieless mode there, so it stores nothing on Your device, and these events carry no account identifier, email or IP-derived profile. Some events, such as account creation, are recorded by the API and are equally anonymous. + For more information about the cookies we use and your choices regarding cookies, please visit our Cookies Policy or the Cookies section of our Privacy Policy. Cookies are not used for API/SDK authentication. diff --git a/webapp/.env.development b/webapp/.env.development index 6c316dc40..3fc96656a 100644 --- a/webapp/.env.development +++ b/webapp/.env.development @@ -10,6 +10,13 @@ VITE_BASE_URL=http://localhost:3000 VITE_USE_MOCK_DATA=true +# Matomo analytics. Tracking is off unless both are set, so leave them empty +# locally. Production uses the webapp site (ID 3) of the shared instance. +# The API reads MATOMO_URL / MATOMO_SITE_ID (same values) for server-side +# events such as account creation. +VITE_MATOMO_URL=https://tgudxkgxiqpdslkkqaz5-matomo.services.clever-cloud.com/ +VITE_MATOMO_SITE_ID=3 + # Used by share-project-button to produce shareable public links. # Any 32-byte (256-bit) hex string works for dev. VITE_PROJECT_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef diff --git a/webapp/.env.example b/webapp/.env.example index a58959bd0..407e40b13 100644 --- a/webapp/.env.example +++ b/webapp/.env.example @@ -14,11 +14,14 @@ VITE_BASE_URL=http://localhost:3000 # is required. VITE_USE_MOCK_DATA=false +# Matomo analytics. Tracking is off unless both are set, so leave them empty +# locally. Production uses the webapp site (ID 3) of the shared instance. +# The API reads MATOMO_URL / MATOMO_SITE_ID (same values) for server-side +# events such as account creation. +VITE_MATOMO_URL= +VITE_MATOMO_SITE_ID= + # AES key used to encrypt project ids in shareable public links. Must be # 64 hex chars (256 bits). Keep stable across the fleet — rotating it # invalidates existing links. VITE_PROJECT_ENCRYPTION_KEY= - -# Fief profile base URL — surfaces a "Profile" item in the navbar that -# links to the IdP's account page. Leave empty to hide it. -VITE_FIEF_BASE_URL= diff --git a/webapp/AGENTS.md b/webapp/AGENTS.md new file mode 100644 index 000000000..dad4964ae --- /dev/null +++ b/webapp/AGENTS.md @@ -0,0 +1,198 @@ +# Webapp agent instructions + +Scope: everything under `webapp/`. The repository root has its own `AGENTS.md` +covering the Python package and the API — this file adds to it, and wins where +the two disagree about webapp code. + +## Working agreement + +1. **Propose before editing.** Read the relevant code first, then say what will + change and where, and stop for approval. Do not edit on the same turn as the + proposal — the review gate is the point, so an unrequested implementation is + the failure mode even when the change looks obvious. +2. **Follow the tests.** Check `tests/` for coverage of the feature being + touched and update it in the same change. If nothing covers it but a similar + component or page is tested, ask before adding new tests rather than + inventing a convention. +3. **Sweep the docs.** After a change, check the `*.md` files for anything it + invalidates and update them briefly — what is necessary, not a rewrite. +4. **Hand over the commit; never make it.** See [Commits](#commits) — this one + has no exceptions. + +## Commits + +**Never commit. Never stage.** Not with `git commit`, not with `git add`, not as +a convenience at the end of a task — leave both to the user, every time. + +Finish instead by giving the commit message as text: medium length, neither a +one-liner nor a changelog. Only what matters, not verbose — what changed, and +why it matters. + +Then ask whether to fold anything already staged into the same message, rather +than assuming either way. + +## Stack + +React 19 + Vite + TypeScript, Tailwind v4, Radix primitives, SWR for fetching, +Zod for response schemas, `sonner` for toasts, `react-router-dom` v7. `pnpm` is +the package manager, Node 24 in CI. `@/` resolves to `src/`. + +## Layout + +- `src/api/` — one module per resource, plus `client.ts` (fetch wrapper), + `schemas.ts` (Zod types), `swr.ts` (hooks), `errors.ts`. +- `src/components/` — kebab-case files. `src/components/ui/` holds the shared + primitives, `src/components/icons/` the SVG icons. +- `src/pages/` — PascalCase, one per route; `src/layouts/`, `src/hooks/`, + `src/helpers/`, `src/utils/` as named. +- `tests/` — Vitest + Testing Library, mirroring `src/`. `e2e/` — Playwright. + +## Component conventions + +There are two generations of UI primitives in `src/components/ui/`. The +redesign ones — `form-field.tsx`, `modal-header.tsx`, `primary-button.tsx`, +`secondary-button.tsx`, `icon-button.tsx`, `menu.tsx`, `tab-nav.tsx`, +`breadcrumb.tsx` — carry the current design. The older shadcn ones — +`input.tsx`, `label.tsx`, `button.tsx`, `card.tsx` — remain only because +pre-redesign screens still use them. **New work uses the redesign layer.** + +Modals share one shell; copy it rather than restating it: + +```tsx + + {/* The design's own close control lives in the header, so the shared + corner button is omitted. */} + + +
+ +
+ + Create thing + +
+ +
+
+``` + +`create-project-modal.tsx`, `create-experiment-modal.tsx` and +`create-organization-modal.tsx` are the reference implementations. Submission +goes through `toast.promise`; the submit button is disabled while pending and +while the required field is blank, instead of validating on click. + +## Implementing a Figma design + +The task supplies the frame link, and sometimes a note that the screen closely +resembles an existing one — verify that claim against the design before leaning +on it. Implement every state the design provides in one pass. + +### The four things that matter most + +1. **Responsive, not pixel-perfect.** Frames are 1440x1024 desktop only; there + is no mobile design. Don't reproduce absolute coordinates or fixed sizes — + one container gutter for horizontal placement, gaps for vertical rhythm, + working down to ~320px. Make the mobile calls yourself, coherently. + +2. **Ask when the design shows what the app doesn't have.** This is a UI + redesign, not an architecture redesign. Data with no API field, menus whose + contents are undrawn, controls with no defined action — stop and ask rather + than inventing endpoints or features. Batch every question into one round + before coding, recommendation first. + + Decide these yourself instead of asking: contradictory measurements for one + edge (pick one, comment why), sizes that differ between two frames of the + same component, and elements that are plainly a Figma component default + rather than intent. Report what you decided afterwards. + +3. **Extend the redesign; don't start a parallel style.** First find out where + the redesign stands: read whatever screens and shared primitives already + follow it. If nothing does, this is the first page and you are setting the + vocabulary — build the recurring treatments as shared components from the + start rather than inline, so the next page has something to inherit. If work + already exists, take the page shell from it, reuse every primitive that fits, + and extend one with a prop rather than forking it; add new shared components + only where the design genuinely introduces something the existing set has no + answer for. Either way, shared chrome — navigation, account menu — stays in + its own components: never rebuild an in-page nav, even where an older frame + draws one. + +4. **Components, not class-string constants.** Never `const FOO = "flex +items-center …"` as a styling layer, and no helpers module of class strings. + A repeated treatment is a component in `ui/`. Local constants are fine only + when two elements inside one component share them. + +### House style for design work + +- Tokens only: the design system's colour and type tokens, and the spacing and + radius scales in the Tailwind config. No hardcoded hex or font sizes. +- kebab-case filenames, PascalCase components; pages stay PascalCase. +- Comments are sparse and concise, and explain reasoning only: why a value + differs from the design, how an unspecified case was resolved. Never narrate + the code or explain routine implementation. No long blocks, no Figma node + ids. Wrap at 80 columns. +- Respect `prefers-reduced-motion`; tie labels to inputs, keep focus-visible + rings, set `aria-current` on the current item, give icon-only buttons + `sr-only` text. +- Verify with `tsc`, eslint, prettier, vitest and `vite build`, and update any + test whose copy changed. You cannot see the app running — it needs the API — + so state what is unverified rather than implying you checked it. + +### Known traps — check only the ones the page actually hits + +Ignore the rest; none of these are things to go hunting for. + +- **A `type-*` class not taking effect.** If a primitive from the underlying + component library sets its own `text-*`, twMerge keeps both and the utility + wins (a 24px title renders at 18px). There, and only there, name the size as + a utility; everywhere else `type-*` remains the convention. +- **Building a dialog:** ours are flex-centred and fade + rise in place + (`cc-dialog-*`). Don't reintroduce transform centring or `slide-in-from-*`. +- **Adding a dropdown to a padded trigger:** Radix anchors to the trigger's + box, not its glyph — cancel the padding with `sideOffset`/`alignOffset`. +- **Gating an SWR key on transient UI state** (a menu's `open`, say): the data + vanishes when it flips and the UI visibly regroups. Latch the condition. +- **A mutation that refetches while a form is open:** key reset effects on ids + or fields so the refresh can't clobber in-progress edits. +- **Rows in a list:** the row text lights as one item on hover, with the + actions cell excluded so its small trigger reports its own hitbox, and + padding on the link so the whole band is clickable. Follow whatever row the + redesign already has. +- **Forms:** a real `
` so Enter submits, the primary disabled until the + required fields are filled, no closing on failure, transient state reset on + open. +- **A modal that duplicates an existing page:** prefer opening in place, and + ask before deleting anything. + +### When the work is done + +Report (a) everything that differed from the design and every decision the +design didn't specify, grouped and concise, for the PR remarks, and (b) a +conventional-commit message, per [Commits](#commits). + +## Styling + +Design tokens live in `tailwind.config.ts` and `src/globals.css`: `cc-*` colors, +`h-control`, `rounded-field`, `shadow-dialog`, the `type-*` type classes, and +the `rail`/`gauge`/`heading` spacing. Use them — do not hardcode hex values or +one-off pixel sizes. Merge classes with `cn()` from `@/helpers/utils`. + +## Comments + +Components carry a block comment explaining _why_ the thing is shaped the way it +is, often citing the Figma frame it comes from. Match that density: explain the +decision, not the syntax. + +## Commands + +```bash +pnpm dev # dev server on http://localhost:3000 +pnpm test:run # unit tests once (pnpm test watches) +pnpm test:e2e # Playwright +pnpm lint # eslint +pnpm format # prettier --write (4-space indent, from .prettierrc) +npx tsc --noEmit # typecheck +``` diff --git a/webapp/CLAUDE.md b/webapp/CLAUDE.md new file mode 100644 index 000000000..18e8e6e1c --- /dev/null +++ b/webapp/CLAUDE.md @@ -0,0 +1,6 @@ +# Webapp + +@./AGENTS.md + +The above is binding for all work under `webapp/`. This file is gitignored; +`AGENTS.md` is the checked-in source of truth. diff --git a/webapp/README.md b/webapp/README.md index 1f330efe4..377d7f694 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -12,6 +12,9 @@ Before you begin, ensure you have the following installed on your system: ## Getting Started +> Contributing with an AI agent? Read [AGENTS.md](./AGENTS.md) first — it holds +> the webapp's conventions and the expected workflow. + Follow these steps to get the project up and running on your local machine: 1. **Open a terminal and go to the /webapp folder** diff --git a/webapp/index.html b/webapp/index.html index 2f6b0bae9..4d8da11fb 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -4,8 +4,10 @@ + + CodeCarbon diff --git a/webapp/public/fonts/Disket-mono_EULA.pdf b/webapp/public/fonts/Disket-mono_EULA.pdf new file mode 100644 index 000000000..026af39e0 Binary files /dev/null and b/webapp/public/fonts/Disket-mono_EULA.pdf differ diff --git a/webapp/public/fonts/DisketMono-Bold.ttf b/webapp/public/fonts/DisketMono-Bold.ttf new file mode 100644 index 000000000..a33bfccb3 Binary files /dev/null and b/webapp/public/fonts/DisketMono-Bold.ttf differ diff --git a/webapp/public/fonts/DisketMono-Regular.ttf b/webapp/public/fonts/DisketMono-Regular.ttf new file mode 100644 index 000000000..b564c60ac Binary files /dev/null and b/webapp/public/fonts/DisketMono-Regular.ttf differ diff --git a/webapp/src/api/auth.ts b/webapp/src/api/auth.ts index 011768726..29a292a9e 100644 --- a/webapp/src/api/auth.ts +++ b/webapp/src/api/auth.ts @@ -3,20 +3,44 @@ function trimTrailingSlash(value: string): string { } /** - * Build the OAuth login URL. Returns `null` when `VITE_API_URL` is not - * configured (typically a local `pnpm dev` session with no `.env`), - * so callers can render a disabled button instead of crashing the page. + * Build a URL on the auth endpoints. Returns `null` when `VITE_API_URL` is + * not configured (typically a local `pnpm dev` session with no `.env`), + * so callers can render a disabled control instead of crashing the page. */ -export function buildLoginUrl(): string | null { +function buildAuthUrl(path: string, action: string): string | null { const apiBase = trimTrailingSlash(import.meta.env.VITE_API_URL ?? ""); if (!apiBase) { console.warn( - "[auth] VITE_API_URL is not set — login is disabled. " + + `[auth] VITE_API_URL is not set — ${action} is disabled. ` + "Configure it in webapp/.env (see .env.example).", ); return null; } - return new URL(`${apiBase}/auth/login`).toString(); + return new URL(`${apiBase}${path}`).toString(); +} + +/** Build the OAuth login URL, or `null` when the API is not configured. */ +export function buildLoginUrl(): string | null { + return buildAuthUrl("/auth/login", "login"); +} + +/** Build the logout URL, or `null` when the API is not configured. */ +export function buildLogoutUrl(): string | null { + return buildAuthUrl("/auth/logout", "logout"); +} + +/** + * Build the URL that sends the user to the identity provider's account + * console, or `null` when the API is not configured. The API owns the + * issuer URL and redirects, the same way it does for login. + */ +export function buildAccountUrl(): string | null { + return buildAuthUrl("/auth/account", "the account link"); +} + +function redirectTo(url: string | null): void { + if (!url) return; + window.location.assign(url); } /** @@ -25,7 +49,15 @@ export function buildLoginUrl(): string | null { * helpful message instead. */ export function redirectToLogin(): void { - const url = buildLoginUrl(); - if (!url) return; - window.location.assign(url); + redirectTo(buildLoginUrl()); +} + +/** Navigate the browser to the logout endpoint. No-op without API config. */ +export function redirectToLogout(): void { + redirectTo(buildLogoutUrl()); +} + +/** Navigate the browser to the account console. No-op without API config. */ +export function redirectToAccount(): void { + redirectTo(buildAccountUrl()); } diff --git a/webapp/src/api/mock/data.ts b/webapp/src/api/mock/data.ts index 0d6f0a748..d768e4c95 100644 --- a/webapp/src/api/mock/data.ts +++ b/webapp/src/api/mock/data.ts @@ -4,6 +4,7 @@ import type { ExperimentReport, Organization, OrganizationReport, + OrganizationUser, IProjectToken, RunMetadata, User, @@ -242,6 +243,30 @@ function makeRunRow(args: { }; } +/* + * Fixture timestamps are anchored to the current date rather than hardcoded, so + * the mock always has data inside the dashboards' default 30-day window. They are + * spread across that window so that narrowing the date range visibly changes the + * numbers — which is the point of having a date filter to exercise. + */ +const DAY_MS = 24 * 60 * 60 * 1000; +const daysAgo = (days: number, hour = 10) => { + const d = new Date(Date.now() - days * DAY_MS); + d.setUTCHours(hour, 0, 0, 0); + return d.toISOString(); +}; + +/** Spacing between samples in `makeEmissionSeries`, in seconds. */ +export const EMISSION_INTERVAL_SECONDS = 5 * 60; + +const AT = { + baseline1: daysAgo(20), + baseline2: daysAgo(20, 11), + optimized1: daysAgo(5), + production: daysAgo(2), + tokenLastUsed: daysAgo(1, 8), +}; + // ─── Composed data (built top-down from the aggregate root) ──────────────── const organization = makeOrganization({ @@ -294,7 +319,7 @@ const experimentBaseline = makeExperiment({ projectId: ID.projects.training, name: "Baseline run", description: "First experiment baseline", - timestamp: "2026-04-01T10:00:00Z", + timestamp: AT.baseline1, }); const experimentOptimized = makeExperiment({ @@ -302,7 +327,7 @@ const experimentOptimized = makeExperiment({ projectId: ID.projects.training, name: "Optimized model", description: "Quantized variant", - timestamp: "2026-04-15T10:00:00Z", + timestamp: AT.optimized1, onCloud: true, cloudProvider: "aws", cloudRegion: "eu-west-3", @@ -313,7 +338,7 @@ const experimentProduction = makeExperiment({ projectId: ID.projects.inference, name: "Production rollout", description: "Live inference", - timestamp: "2026-04-20T10:00:00Z", + timestamp: AT.production, onCloud: true, cloudProvider: "gcp", cloudRegion: "europe-west1", @@ -340,7 +365,7 @@ const optimizedReport = makeExperimentReport({ const runBaseline1 = makeRunRow({ runId: ID.runs.baseline1, experimentId: ID.experiments.baseline, - timestamp: "2026-04-01T10:00:00Z", + timestamp: AT.baseline1, emissions: 0.617, energyConsumed: 2.839, durationSeconds: 1800, @@ -349,7 +374,7 @@ const runBaseline1 = makeRunRow({ const runBaseline2 = makeRunRow({ runId: ID.runs.baseline2, experimentId: ID.experiments.baseline, - timestamp: "2026-04-01T11:00:00Z", + timestamp: AT.baseline2, emissions: 0.617, energyConsumed: 2.839, durationSeconds: 1800, @@ -358,7 +383,7 @@ const runBaseline2 = makeRunRow({ const runOptimized1 = makeRunRow({ runId: ID.runs.optimized1, experimentId: ID.experiments.optimized, - timestamp: "2026-04-15T10:00:00Z", + timestamp: AT.optimized1, emissions: 0.567, energyConsumed: 2.345, durationSeconds: 1800, @@ -368,11 +393,63 @@ const ciToken = makeProjectToken({ id: ID.tokens.ci, projectId: ID.projects.training, name: "Local dev token", - lastUsed: "2026-04-30T08:00:00Z", + lastUsed: AT.tokenLastUsed, }); // ─── Exported aggregate (consumed by handlers.ts) ────────────────────────── +/* + * The organization's emission rows, flattened across every run. The backend's + * `/organizations/{id}/sums` filters this table by `emissions.timestamp` and + * aggregates the matches, so the mock does the same rather than returning a + * constant — otherwise a date filter cannot be exercised locally at all. + */ +function organizationEmissionRows(): Emission[] { + return [ + ...makeEmissionSeries({ + runId: ID.runs.baseline1, + samples: 12, + startedAt: new Date(AT.baseline1), + }), + ...makeEmissionSeries({ + runId: ID.runs.baseline2, + samples: 12, + startedAt: new Date(AT.baseline2), + }), + ...makeEmissionSeries({ + runId: ID.runs.optimized1, + samples: 6, + startedAt: new Date(AT.optimized1), + }), + ]; +} + +const round = (n: number, dp = 3) => Number(n.toFixed(dp)); + +/** + * Aggregate the organization's emissions over a date range, the way + * `read_organization_detailed_sums` does. Bounds are inclusive; either may be + * omitted, matching the endpoint's optional query parameters. + */ +export function organizationReportBetween( + start?: Date | null, + end?: Date | null, +): OrganizationReport { + const rows = organizationEmissionRows().filter((e) => { + const t = new Date(e.timestamp).getTime(); + if (start && t < start.getTime()) return false; + if (end && t > end.getTime()) return false; + return true; + }); + return { + name: organization.name, + emissions: round(rows.reduce((a, e) => a + e.emissions_sum, 0)), + energy_consumed: round(rows.reduce((a, e) => a + e.energy_consumed, 0)), + // Each row covers one sampling interval. + duration: rows.length * EMISSION_INTERVAL_SECONDS, + }; +} + export const MOCK = { user: adminUser, @@ -382,9 +459,26 @@ export const MOCK = { [organization.id]: organization, } as Record, report: organizationReport, + /* + * `GET /organizations/{id}/users` returns the backend's + * `OrganizationUser`: a user plus their membership of that organization, + * including `is_admin`. The admin/member split mirrors the two fixture + * users. + */ usersByOrgId: { - [organization.id]: [adminUser, memberUser], - } as Record, + [organization.id]: [ + { + ...adminUser, + organization_id: organization.id, + is_admin: true, + }, + { + ...memberUser, + organization_id: organization.id, + is_admin: false, + }, + ], + } as Record, }, project: { diff --git a/webapp/src/api/mock/handlers.ts b/webapp/src/api/mock/handlers.ts index fb9a83315..1ff876802 100644 --- a/webapp/src/api/mock/handlers.ts +++ b/webapp/src/api/mock/handlers.ts @@ -1,4 +1,4 @@ -import { ID, MOCK, MockProjectWire } from "./data"; +import { ID, MOCK, MockProjectWire, organizationReportBetween } from "./data"; export type MockResponse = { status: number; body?: unknown }; @@ -27,7 +27,7 @@ const handlers: Handler[] = [ }, // ─── Organizations ───────────────────────────────────────────────────── - ({ pathname, method, body }) => { + ({ pathname, method, searchParams, body }) => { if (method === "GET" && pathname === "/organizations") { return ok(MOCK.organization.list); } @@ -49,7 +49,20 @@ const handlers: Handler[] = [ } const sums = pathname.match(/^\/organizations\/([^/]+)\/sums$/); if (method === "GET" && sums) { - return ok(MOCK.organization.report); + // Honour the same query parameters as the real endpoint, so the + // dashboard's date picker actually changes the figures locally. + const parse = (key: string) => { + const raw = searchParams.get(key); + if (!raw) return null; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? null : d; + }; + return ok( + organizationReportBetween( + parse("start_date"), + parse("end_date"), + ), + ); } const users = pathname.match(/^\/organizations\/([^/]+)\/users$/); if (method === "GET" && users) { diff --git a/webapp/src/api/organizations.ts b/webapp/src/api/organizations.ts index 414ed375f..cfa8b33a0 100644 --- a/webapp/src/api/organizations.ts +++ b/webapp/src/api/organizations.ts @@ -43,6 +43,31 @@ export async function createOrganization(organization: { }); } +/* + * Add a member to an organization by email address. + * + * The endpoint looks the address up among existing accounts and subscribes it + * to the organization; it does not send an invitation, and it answers with a + * bare status object rather than the member it added, so there is nothing to + * validate and the caller refetches the list. + */ +export async function addOrganizationUser( + organizationId: string, + email: string, +): Promise { + await fetchApiVoid(`/organizations/${organizationId}/add-user`, { + method: "POST", + body: JSON.stringify({ email }), + }); +} + +/* + * Remove a member from an organization. + * + * The endpoint is admin-only and refuses to remove the organization's last + * administrator, so an administrator can be removed while another remains. It + * answers with a bare status object, so the caller refetches the list. + */ export async function removeUserFromOrganization( organizationId: string, userId: string, diff --git a/webapp/src/api/schemas.ts b/webapp/src/api/schemas.ts index f7fe8772d..e70c6c9a7 100644 --- a/webapp/src/api/schemas.ts +++ b/webapp/src/api/schemas.ts @@ -16,6 +16,20 @@ export const UserSchema = z.object({ }); export type User = z.infer; +/* + * `GET /organizations/{id}/users` returns the backend's `OrganizationUser`: a + * user plus their membership of that organization. `is_admin` is the only place + * the API exposes admin rights, and it is per-organization. + */ +export const OrganizationUserSchema = z.object({ + id: z.string(), + email: z.string(), + name: z.string(), + organization_id: z.string(), + is_admin: z.boolean(), +}); +export type OrganizationUser = z.infer; + // Backend returns snake_case keys (`organization_id`); the rest of the // codebase consumes camelCase (`organizationId`). Zod's `.transform` lets // us validate the wire shape and expose the camelCase shape to the app. @@ -190,7 +204,6 @@ export interface ProjectDashboardProps { selectedRunId: string; onExperimentClick: (experimentId: string) => void; onRunClick: (runId: string) => void; - onSettingsClick: () => void; onRefresh: () => void; isLoading?: boolean; } diff --git a/webapp/src/components/account-menu.tsx b/webapp/src/components/account-menu.tsx new file mode 100644 index 000000000..13904e755 --- /dev/null +++ b/webapp/src/components/account-menu.tsx @@ -0,0 +1,46 @@ +import { redirectToAccount, redirectToLogout } from "@/api/auth"; +import { LogoutIcon } from "./icons/logout-icon"; +import { SettingsIcon } from "./icons/settings-icon"; +import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { MenuItem, MenuPanel } from "./ui/menu"; + +export default function AccountMenu({ + children, +}: Readonly<{ + /** The rail's "Account" item, used as the trigger. */ + children: React.ReactNode; +}>) { + return ( + + {children} + {/* Opens beside the rail, bottom edges level with the trigger. The offset + crosses the rail's 16px padding and 2px border, plus an 8px gap. */} + + {/* Email and password are owned by the identity provider, + so this leaves the app for its account console. The API + holds the issuer URL and redirects, the same way login + does. */} + } + > + Settings + + + + } + > + Log out + + + + ); +} diff --git a/webapp/src/components/breadcrumb.tsx b/webapp/src/components/breadcrumb.tsx deleted file mode 100644 index c823c90c8..000000000 --- a/webapp/src/components/breadcrumb.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; -import React from "react"; - -export default function BreadcrumbHeader({ - pathSegments, -}: { - pathSegments: { - title: string; - href: string | null; - }[]; -}) { - return ( - - - {pathSegments.map((segment, index) => { - const isLast = index === pathSegments.length - 1; - const title = segment.title; - const href = segment.href; - return ( - - - {href ? ( - - {title} - - ) : ( - {title} - )} - - {!isLast && } - - ); - })} - - - ); -} diff --git a/webapp/src/components/chart-row.tsx b/webapp/src/components/chart-row.tsx new file mode 100644 index 000000000..f986b9f81 --- /dev/null +++ b/webapp/src/components/chart-row.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/helpers/utils"; + +/* + * Two charts side by side, separated by a rule. + * + * The rules read as one cross across the four charts, so nothing here is spaced + * with margins: the gap around a rule is padding *inside* the cells, which leaves + * the rule running the full height of the row. A margin would end the line early + * and break the cross at its centre. + * + * The inset is a prop rather than something a caller passes through `className`: + * these are arbitrary variants, so a `[&>*:first-child]` rule outranks a `[&>*]` + * one whatever the class order, and an override would reach one cell only. + * + * The cells arrive wrapped in `Suspense` and fragments, which render no DOM node, + * so the inset reaches them through child selectors rather than by the caller + * putting it on each chart. + * + * Below `md` the columns stack and the rule turns horizontal with them. + */ +export default function ChartRow({ + insetTop = false, + insetBottom = false, + className, + children, +}: Readonly<{ + /** Space above the charts, for a row sitting under a rule. */ + insetTop?: boolean; + /** Space below them, for a row sitting above one. */ + insetBottom?: boolean; + className?: string; + children: React.ReactNode; +}>) { + return ( +
*:first-child]` reset outranks the `[&>*]` inset below and + // would silently apply it to one cell only. + "max-md:[&>*:first-child]:pb-10 max-md:[&>*:last-child]:pt-10", + // Side by side: equal space either side of the vertical rule. + "md:[&>*:first-child]:pr-10 md:[&>*:last-child]:pl-10", + "lg:[&>*:first-child]:pr-16 lg:[&>*:last-child]:pl-16", + // Applied to both cells, so the row's own edges stay level. + insetTop && "md:[&>*]:pt-10 lg:[&>*]:pt-16", + insetBottom && "md:[&>*]:pb-10 lg:[&>*]:pb-16", + className, + )} + > + {children} +
+ ); +} diff --git a/webapp/src/components/chart-section.tsx b/webapp/src/components/chart-section.tsx new file mode 100644 index 000000000..68d4b49dc --- /dev/null +++ b/webapp/src/components/chart-section.tsx @@ -0,0 +1,47 @@ +import { cn } from "@/helpers/utils"; +import { useChartViewTime } from "@/hooks/useChartViewTime"; + +/* + * A titled block of the dashboard: heading, a line saying what it is for, and + * whatever action belongs to it, above the thing itself. + * + * The charts each drew this by hand inside a `Card`. They share it now, so the + * page reads as one surface with sections on it rather than a wall of boxes, and + * the three headings cannot drift apart. It carries no border or fill of its own: + * the panels behind the redesign are the page, not the card. + */ +export default function ChartSection({ + title, + description, + action, + className, + children, +}: Readonly<{ + title: string; + description?: string; + /** Sits on the title's row, at its end. */ + action?: React.ReactNode; + className?: string; + children: React.ReactNode; +}>) { + const ref = useChartViewTime(title); + + return ( +
+
+
+

+ {title} +

+ {action} +
+ {description && ( +

+ {description} +

+ )} +
+ {children} +
+ ); +} diff --git a/webapp/src/components/consumed-energy-gauge.tsx b/webapp/src/components/consumed-energy-gauge.tsx new file mode 100644 index 000000000..0420f299e --- /dev/null +++ b/webapp/src/components/consumed-energy-gauge.tsx @@ -0,0 +1,105 @@ +import { cn } from "@/helpers/utils"; + +/* + * The "Consumed energy" gauge: one SVG whose ring, value and caption are placed + * in the design's own coordinate space, so the geometry lives in the viewBox + * and the gauge scales to whatever box its parent gives it. + * + * The arc is a fixed decorative sweep, not a proportion of the value: these + * metrics are unbounded running totals with no maximum anywhere in the API, so + * there is nothing to take a fraction of. The number in the middle is the data. + * It is drawn only when there is a value — a zero gauge shows bare track, so an + * empty range reads as empty rather than as some amount. + */ + +const VIEWBOX = 199.23; +const CENTER = 99.6152; +const RADIUS = 87.0511; +const STROKE = 25.1281; + +/** 12 o'clock, in the screen degrees used below (0 = 3 o'clock, clockwise). */ +const START_ANGLE = 270; +/** The sweep the previous gauges drew, kept so the rings look unchanged. */ +const ARC_SWEEP = 100; + +const point = (angleDeg: number) => { + const a = (angleDeg * Math.PI) / 180; + return [CENTER + RADIUS * Math.cos(a), CENTER + RADIUS * Math.sin(a)]; +}; + +/** Arc sweeping anti-clockwise from 12 o'clock by `sweep` degrees. */ +function arcPath(sweep: number) { + const [x0, y0] = point(START_ANGLE); + const [x1, y1] = point(START_ANGLE - sweep); + const largeArc = sweep > 180 ? 1 : 0; + // sweep-flag 0 draws anti-clockwise in SVG's y-down coordinate system. + return `M ${x0} ${y0} A ${RADIUS} ${RADIUS} 0 ${largeArc} 0 ${x1} ${y1}`; +} + +export default function ConsumedEnergyGauge({ + value, + label, + className, +}: Readonly<{ + /** The metric's value, shown in the middle of the ring. */ + value: number; + /** Unit caption, e.g. "kWh". */ + label: string; + className?: string; +}>) { + return ( + + + {/* + * The arc is decorative, so it stands for "there is something here" + * rather than for a proportion: at zero there is nothing to mark and + * the ring is left as bare track. + */} + {value > 0 && ( + + )} + {/* + * Figma left-aligns both labels from their own x offsets, with the + * value's baseline box starting at y 75.384 and the caption's at + * 115.769. `dominant-baseline: text-before-edge` makes those the top + * edges, matching how Figma positions the text frames. + */} + + {value} + + + {label} + + + ); +} diff --git a/webapp/src/components/consumed-energy-gauges.tsx b/webapp/src/components/consumed-energy-gauges.tsx new file mode 100644 index 000000000..de4c9a1c9 --- /dev/null +++ b/webapp/src/components/consumed-energy-gauges.tsx @@ -0,0 +1,37 @@ +import ConsumedEnergyGauge from "./consumed-energy-gauge"; +import { cn } from "@/helpers/utils"; + +/* + * The "Consumed energy" gauges: energy, emissions and duration, each a ring with + * its figure and unit. + * + * One component for both dashboards, as with the equivalences beside them — the + * design draws the same three rings on each. The gauges keep their own size and + * wrap when the row runs out of width, which is how the design lays them out (a + * gap between them, not a distribution across the width). + */ +export type Gauge = { + label: string; + value: number; +}; + +export default function ConsumedEnergyGauges({ + gauges, + className, +}: Readonly<{ + gauges: Gauge[]; + className?: string; +}>) { + return ( +
    + {gauges.map((gauge) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/webapp/src/components/create-experiment-modal.tsx b/webapp/src/components/create-experiment-modal.tsx new file mode 100644 index 000000000..90e2e217e --- /dev/null +++ b/webapp/src/components/create-experiment-modal.tsx @@ -0,0 +1,223 @@ +import { trackEvent } from "@/utils/matomo"; +import { useEffect, useRef, useState } from "react"; +import { ClipboardCheck, ClipboardCopy, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { createExperiment } from "@/api/experiments"; +import { Experiment, ExperimentInput } from "@/api/schemas"; +import { Dialog, DialogContent } from "./ui/dialog"; +import { FormField } from "./ui/form-field"; +import { IconButton } from "./ui/icon-button"; +import ModalHeader from "./ui/modal-header"; +import { PrimaryButton } from "./ui/primary-button"; + +/* + * Create an experiment, in the same panel as the Create-project dialog: the two + * are the same object in the design, so they share the shell, the fields and the + * button rather than each describing them. + * + * It has a second state the other does not: once the experiment exists, the + * dialog stays open to hand over its id, since that is what the tracker needs and + * the page never shows it again. + */ +export default function CreateExperimentModal({ + projectId, + isOpen, + onClose, + onExperimentCreated, +}: { + projectId: string; + isOpen: boolean; + onClose: () => void; + onExperimentCreated?: () => void | Promise; +}) { + const [isCopied, setIsCopied] = useState(false); + const copyTimerRef = useRef | null>(null); + const [isSaving, setIsSaving] = useState(false); + const [isCreated, setIsCreated] = useState(false); + const [experimentData, setExperimentData] = useState({ + name: "", + description: "", + on_cloud: false, + project_id: "", + }); + const [createdExperiment, setCreatedExperiment] = + useState(null); + + useEffect(() => { + if (projectId && !experimentData.project_id) { + setExperimentData({ + ...experimentData, + project_id: projectId, + }); + } + }, [projectId, experimentData]); + + useEffect(() => { + return () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }; + }, []); + + const resetForm = () => { + setExperimentData({ + name: "", + description: "", + on_cloud: false, + project_id: projectId, + }); + setIsCreated(false); + setCreatedExperiment(null); + }; + + const handleClose = () => { + resetForm(); + onClose(); + }; + + const handleSave = async () => { + if (!experimentData.name.trim()) { + toast.error("Experiment name is required"); + return; + } + + setIsSaving(true); + + try { + const newExperiment = await createExperiment(experimentData); + trackEvent("Activation", "experiment_created"); + setCreatedExperiment(newExperiment); + setIsCreated(true); + await onExperimentCreated?.(); + toast.success( + `Experiment ${experimentData.name} created successfully`, + ); + } catch (error) { + console.error("Failed to create experiment:", error); + toast.error("Failed to create experiment"); + } finally { + setIsSaving(false); + } + }; + + const handleCopy = (token: string | undefined) => { + if (!token) return; + navigator.clipboard + .writeText(token) + .then(() => { + setIsCopied(true); + toast.success("Experiment ID copied to clipboard"); + copyTimerRef.current = setTimeout( + () => setIsCopied(false), + 2000, + ); + }) + .catch((err) => { + console.error("Failed to copy experiment id:", err); + toast.error("Failed to copy experiment ID"); + }); + }; + + return ( + + + {!isCreated ? ( + <> + + + { + event.preventDefault(); + handleSave(); + }} + > + + setExperimentData({ + ...experimentData, + name: e.target.value, + }) + } + /> + + + setExperimentData({ + ...experimentData, + description: e.target.value, + }) + } + /> + +
+ + {isSaving && ( + + )} + {isSaving + ? "Creating..." + : "Create experiment"} + +
+ + + ) : ( + <> + + +
+
+

+ Id of this experiment +

+
+ + {createdExperiment?.id} + + + handleCopy(createdExperiment?.id) + } + > + {isCopied ? ( + + ) : ( + + )} + +
+
+ +
+ + Done + +
+
+ + )} +
+
+ ); +} diff --git a/webapp/src/components/create-organization-modal.tsx b/webapp/src/components/create-organization-modal.tsx new file mode 100644 index 000000000..058c5922b --- /dev/null +++ b/webapp/src/components/create-organization-modal.tsx @@ -0,0 +1,131 @@ +import { trackEvent } from "@/utils/matomo"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useNavigate } from "react-router-dom"; + +import { createOrganization } from "@/api/organizations"; +import { Dialog, DialogContent } from "./ui/dialog"; +import { FormField } from "./ui/form-field"; +import ModalHeader from "./ui/modal-header"; +import { PrimaryButton } from "./ui/primary-button"; + +/* + * Create an organization, in the same panel as the Create-project dialog: the + * two are the same object in the design, so they share the shell, the fields + * and the button rather than each describing them. + */ + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + onOrganizationCreated: () => Promise; +} + +interface CreateOrganizationInput { + name: string; + description: string; +} + +const CreateOrganizationModal: React.FC = ({ + isOpen, + onClose, + onOrganizationCreated, +}) => { + const [formData, setFormData] = useState({ + name: "", + description: "", + }); + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const handleClose = () => { + // Reset state when closing + setFormData({ name: "", description: "" }); + onClose(); + }; + + const handleSave = async () => { + toast.promise( + async () => { + setIsLoading(true); + try { + const newOrganization = await createOrganization(formData); + if (!newOrganization) { + throw new Error("Failed to create organization"); + } + trackEvent("Activation", "organization_created"); + await onOrganizationCreated(); + handleClose(); + navigate(`/${newOrganization.id}`); + return newOrganization; + } catch (error) { + console.error("Failed to create organization:", error); + throw error; + } finally { + setIsLoading(false); + } + }, + { + loading: "Creating organization...", + success: "Organization created successfully!", + error: "Failed to create organization", + }, + ); + }; + + return ( + + {/* The design's own close control lives in the header, so the shared + corner button is omitted. */} + + + +
{ + event.preventDefault(); + handleSave(); + }} + > + + setFormData({ ...formData, name: e.target.value }) + } + /> + + + setFormData({ + ...formData, + description: e.target.value, + }) + } + /> + +
+ + {isLoading ? "Creating..." : "Create organization"} + +
+ +
+
+ ); +}; + +export default CreateOrganizationModal; diff --git a/webapp/src/components/create-project-modal.tsx b/webapp/src/components/create-project-modal.tsx new file mode 100644 index 000000000..da73ad84a --- /dev/null +++ b/webapp/src/components/create-project-modal.tsx @@ -0,0 +1,137 @@ +import { trackEvent } from "@/utils/matomo"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { createProject } from "@/api/projects"; +import { Dialog, DialogContent } from "./ui/dialog"; +import ModalHeader from "./ui/modal-header"; +import { FormField } from "./ui/form-field"; +import { PrimaryButton } from "./ui/primary-button"; + +/* + * Create a project: a dialog holding a name and a description. + * + * The design's fixed 664x524 panel is not reproduced as a size — its own + * numbers do not add up, with the form starting below where the panel ends — so + * the panel keeps its proportion instead, a little wider than tall, and narrows + * below its maximum. + */ + +interface ModalProps { + organizationId: string; + isOpen: boolean; + onClose: () => void; + onProjectCreated: () => Promise; +} + +interface CreateProjectInput { + name: string; + description: string; +} + +const CreateProjectModal: React.FC = ({ + organizationId, + isOpen, + onClose, + onProjectCreated, +}) => { + const [formData, setFormData] = useState({ + name: "", + description: "", + }); + const [isLoading, setIsLoading] = useState(false); + + const handleClose = () => { + // Reset state when closing + setFormData({ name: "", description: "" }); + onClose(); + }; + + const handleSave = async () => { + toast.promise( + async () => { + setIsLoading(true); + try { + const newProject = await createProject( + organizationId, + formData, + ); + trackEvent("Activation", "project_created"); + await onProjectCreated(); // Call the callback to refresh the project list + handleClose(); // Automatically close the modal after successful creation + return newProject; // Return for the success message + } catch (error) { + console.error("Failed to create project:", error); + throw error; // Rethrow for the error message + } finally { + setIsLoading(false); + } + }, + { + loading: "Creating project...", + success: "Project created successfully!", + error: "Failed to create project", + }, + ); + }; + + return ( + + {/* The design's own close control lives in the header, so the shared + corner button is omitted. */} + + + + {/* The form shares the header's gutter. Its vertical padding is + the panel's main source of air. */} +
{ + event.preventDefault(); + handleSave(); + }} + > + + setFormData({ ...formData, name: e.target.value }) + } + /> + + + setFormData({ + ...formData, + description: e.target.value, + }) + } + /> + + {/* Separated by more than the gap between the fields, so it + reads as the end of the form rather than a third row. */} +
+ + {isLoading ? "Creating..." : "Create project"} + +
+ +
+
+ ); +}; + +export default CreateProjectModal; diff --git a/webapp/src/components/createExperimentModal.tsx b/webapp/src/components/createExperimentModal.tsx deleted file mode 100644 index e189a43d1..000000000 --- a/webapp/src/components/createExperimentModal.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { createExperiment } from "@/api/experiments"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Experiment, ExperimentInput } from "@/api/schemas"; -import { Separator } from "./ui/separator"; -import { ClipboardCheck, ClipboardCopy, Loader2 } from "lucide-react"; -import { toast } from "sonner"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; - -export default function CreateExperimentModal({ - projectId, - isOpen, - onClose, - onExperimentCreated, -}: { - projectId: string; - isOpen: boolean; - onClose: () => void; - onExperimentCreated?: () => void | Promise; -}) { - const [isCopied, setIsCopied] = useState(false); - const copyTimerRef = useRef | null>(null); - const [isSaving, setIsSaving] = useState(false); - const [isCreated, setIsCreated] = useState(false); - const [experimentData, setExperimentData] = useState({ - name: "", - description: "", - on_cloud: false, - project_id: "", - }); - const [createdExperiment, setCreatedExperiment] = - useState(null); - - useEffect(() => { - if (projectId && !experimentData.project_id) { - setExperimentData({ - ...experimentData, - project_id: projectId, - }); - } - }, [projectId, experimentData]); - - useEffect(() => { - return () => { - if (copyTimerRef.current) clearTimeout(copyTimerRef.current); - }; - }, []); - - const resetForm = () => { - setExperimentData({ - name: "", - description: "", - on_cloud: false, - project_id: projectId, - }); - setIsCreated(false); - setCreatedExperiment(null); - }; - - const handleClose = () => { - resetForm(); - onClose(); - }; - - const handleSave = async () => { - if (!experimentData.name.trim()) { - toast.error("Experiment name is required"); - return; - } - - setIsSaving(true); - - try { - const newExperiment = await createExperiment(experimentData); - setCreatedExperiment(newExperiment); - setIsCreated(true); - await onExperimentCreated?.(); - toast.success( - `Experiment ${experimentData.name} created successfully`, - ); - } catch (error) { - console.error("Failed to create experiment:", error); - toast.error("Failed to create experiment"); - } finally { - setIsSaving(false); - } - }; - - const handleCopy = (token: string | undefined) => { - if (!token) return; - navigator.clipboard - .writeText(token) - .then(() => { - setIsCopied(true); - toast.success("Experiment ID copied to clipboard"); - copyTimerRef.current = setTimeout( - () => setIsCopied(false), - 2000, - ); - }) - .catch((err) => { - console.error("Failed to copy experiment id:", err); - toast.error("Failed to copy experiment ID"); - }); - }; - - return ( - - - {!isCreated ? ( - <> - - Create new experiment - - -
-
- - - setExperimentData({ - ...experimentData, - name: e.target.value, - }) - } - placeholder="Experiment Name" - /> -
-
- - - setExperimentData({ - ...experimentData, - description: e.target.value, - }) - } - placeholder="Experiment Description" - /> -
-
- - - - - ) : ( - <> - - - Experiment {createdExperiment?.name} Created - - - -
-

Id of this experiment:

-
-
-                                    {createdExperiment?.id}
-                                
- -
-
- - - - - )} -
-
- ); -} diff --git a/webapp/src/components/createOrganizationModal.tsx b/webapp/src/components/createOrganizationModal.tsx deleted file mode 100644 index f72d756a5..000000000 --- a/webapp/src/components/createOrganizationModal.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useState } from "react"; -import { createOrganization } from "@/api/organizations"; -import { Separator } from "./ui/separator"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; -import { Button } from "./ui/button"; -import { toast } from "sonner"; -import { useNavigate } from "react-router-dom"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "./ui/dialog"; - -interface ModalProps { - isOpen: boolean; - onClose: () => void; - onOrganizationCreated: () => Promise; -} - -interface CreateOrganizationInput { - name: string; - description: string; -} - -const CreateOrganizationModal: React.FC = ({ - isOpen, - onClose, - onOrganizationCreated, -}) => { - const [formData, setFormData] = useState({ - name: "", - description: "", - }); - const [isLoading, setIsLoading] = useState(false); - const navigate = useNavigate(); - - const handleSave = async () => { - if (!formData.name.trim()) { - toast.error("Organization name is required"); - return; - } - - setIsLoading(true); - - toast - .promise( - (async () => { - const newOrganization = await createOrganization(formData); - await onOrganizationCreated(); - setFormData({ name: "", description: "" }); - onClose(); - if (newOrganization) { - navigate(`/${newOrganization.id}`); - return newOrganization; - } else { - throw new Error("Failed to create organization"); - } - })(), - { - loading: "Creating organization...", - success: "Organization created", - error: "Failed to create organization", - }, - ) - .unwrap(); - setIsLoading(false); - }; - - const handleClose = () => { - setFormData({ name: "", description: "" }); - onClose(); - }; - - return ( - - - - Create new organization - - Fill in the details to create your organization - - - -
-
- - - setFormData({ - ...formData, - name: e.target.value, - }) - } - placeholder="Organization Name" - /> -
-
- -
-
- - - setFormData({ - ...formData, - description: e.target.value, - }) - } - placeholder="Organization Description" - /> -
-
- - - - -
-
- ); -}; - -export default CreateOrganizationModal; diff --git a/webapp/src/components/createProjectModal.tsx b/webapp/src/components/createProjectModal.tsx deleted file mode 100644 index f4ccce028..000000000 --- a/webapp/src/components/createProjectModal.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { useState } from "react"; -import { createProject } from "@/api/projects"; -import { Separator } from "./ui/separator"; -import { Input } from "./ui/input"; -import { Label } from "./ui/label"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "./ui/dialog"; -import { toast } from "sonner"; - -interface ModalProps { - organizationId: string; - isOpen: boolean; - onClose: () => void; - onProjectCreated: () => Promise; -} - -interface CreateProjectInput { - name: string; - description: string; -} - -const CreateProjectModal: React.FC = ({ - organizationId, - isOpen, - onClose, - onProjectCreated, -}) => { - const [formData, setFormData] = useState({ - name: "", - description: "", - }); - const [isLoading, setIsLoading] = useState(false); - - const handleSave = async () => { - toast.promise( - async () => { - setIsLoading(true); - try { - const newProject = await createProject( - organizationId, - formData, - ); - await onProjectCreated(); // Call the callback to refresh the project list - handleClose(); // Automatically close the modal after successful creation - return newProject; // Return for the success message - } catch (error) { - console.error("Failed to create project:", error); - throw error; // Rethrow for the error message - } finally { - setIsLoading(false); - } - }, - { - loading: "Creating project...", - success: "Project created successfully!", - error: "Failed to create project", - }, - ); - }; - - const handleClose = () => { - // Reset state when closing - setFormData({ name: "", description: "" }); - onClose(); - }; - - return ( - - - - Create new project - - Fill in the details to create your project - - - -
-
- - - setFormData({ - ...formData, - name: e.target.value, - }) - } - placeholder="Project Name" - /> -
-
- -
-
- - - setFormData({ - ...formData, - description: e.target.value, - }) - } - placeholder="Project Description" - /> -
-
- - - - -
-
- ); -}; - -export default CreateProjectModal; diff --git a/webapp/src/components/date-range-picker.tsx b/webapp/src/components/date-range-picker.tsx index 8f39839d6..2f5834760 100644 --- a/webapp/src/components/date-range-picker.tsx +++ b/webapp/src/components/date-range-picker.tsx @@ -1,3 +1,4 @@ +import { trackEvent } from "@/utils/matomo"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { @@ -14,9 +15,24 @@ import { format } from "date-fns"; interface DateRangePickerProps { date: DateRange; onDateChange: (newDate: DateRange | undefined) => void; + /** + * `default` keeps the outline button used elsewhere in the app. + * `dashboard` matches the design's "Input" component: a 16px + * IBM Plex Mono Regular "Dates" label above a 46px field with a + * rgba(255,255,255,0.05) fill, 2px radius, 16px horizontal padding and a + * #666666 numeric value. The design shows no calendar glyph in the field. + */ + variant?: "default" | "dashboard"; + /** Field label, used by the `dashboard` variant. */ + label?: string; } -export function DateRangePicker({ date, onDateChange }: DateRangePickerProps) { +export function DateRangePicker({ + date, + onDateChange, + variant = "default", + label = "Dates", +}: DateRangePickerProps) { const [open, setOpen] = useState(false); const [tempDateRange, setTempDateRange] = useState( date, @@ -31,12 +47,51 @@ export function DateRangePicker({ date, onDateChange }: DateRangePickerProps) { }; const handleApply = () => { + if (tempDateRange?.from && tempDateRange.to) { + const days = Math.round( + (tempDateRange.to.getTime() - tempDateRange.from.getTime()) / + 86_400_000, + ); + trackEvent("Dashboard", "date_range_applied", undefined, days); + } onDateChange(tempDateRange); setOpen(false); }; - return ( - + /* + * The design renders the range as "01/01/2021 - 01/02/2021". A one-month + * span is dd/MM/yyyy (1 Jan to 1 Feb), which also matches this dashboard's + * default 30-day range; read as MM/dd/yyyy it would be a single day. + */ + const formatted = (pattern: string) => + date?.from + ? date.to + ? `${format(date.from, pattern)} - ${format(date.to, pattern)}` + : format(date.from, pattern) + : null; + + const trigger = + variant === "dashboard" ? ( +
+ + + + +
+ ) : ( + ); + + return ( + + {trigger}
- - - Run Metadata - - Hardware and environment details - - - -
-
- - CPU: - - {emissionTimeSeries.metadata.cpu_model} ( - {emissionTimeSeries.metadata.cpu_count} cores) - -
- {emissionTimeSeries.metadata.gpu_model && ( -
- - GPU: - - {emissionTimeSeries.metadata.gpu_model} ( - {emissionTimeSeries.metadata.gpu_count}) - -
- )} + + { + if (!emissionTimeSeries) return; + exportEmissionsTimeSeriesCsv( + emissionTimeSeries, + projectName, + experimentName, + ); + }} + loadingMessage="Exporting time series..." + successMessage="Time series exported successfully" + errorMessage="Failed to export time series" + /> + ) + } + > +
+ {Object.keys(chartConfig).map((key) => { + const chart = key as keyof typeof chartConfig; + return ( + + ); + })} +
+ + + + + format(new Date(value), tickFmt) + } + /> + + { + const tooltipPayload = payload as + | TimeSeriesTooltipPayload + | undefined; + const point = + tooltipPayload?.[0]?.payload; + + if (!point) { + return ""; + } + + return format( + new Date(point.ts), + "MMM d, yyyy HH:mm:ss", + ); + }} + /> + } + /> + + + +
+ +
+
+ + CPU: + + {emissionTimeSeries.metadata.cpu_model} ( + {emissionTimeSeries.metadata.cpu_count} cores) + +
+ {emissionTimeSeries.metadata.gpu_model && (
- - RAM: + + GPU: - {emissionTimeSeries.metadata.ram_total_size} GB + {emissionTimeSeries.metadata.gpu_model} ( + {emissionTimeSeries.metadata.gpu_count})
-
- OS:{" "} - {emissionTimeSeries.metadata.os} -
-
- Python:{" "} - {emissionTimeSeries.metadata.python_version} -
-
- Region:{" "} - {emissionTimeSeries.metadata.region} -
+ )} +
+ + RAM: + + {emissionTimeSeries.metadata.ram_total_size} GB +
- - - - -
-
-
- Emissions Time Series - - Showing emissions rate and energy consumed - over time - -
- {!isPublicView && ( - { - if (!emissionTimeSeries) return; - exportEmissionsTimeSeriesCsv( - emissionTimeSeries, - projectName, - experimentName, - ); - }} - loadingMessage="Exporting time series..." - successMessage="Time series exported successfully" - errorMessage="Failed to export time series" - /> - )} -
+
+ OS:{" "} + {emissionTimeSeries.metadata.os}
-
- {Object.keys(chartConfig).map((key) => { - const chart = key as keyof typeof chartConfig; - return ( - - ); - })} +
+ Python:{" "} + {emissionTimeSeries.metadata.python_version}
- - - - - - - format(new Date(value), tickFmt) - } - /> - - { - const tooltipPayload = payload as - | TimeSeriesTooltipPayload - | undefined; - const point = - tooltipPayload?.[0]?.payload; - - if (!point) { - return ""; - } - - return format( - new Date(point.ts), - "MMM d, yyyy HH:mm:ss", - ); - }} - /> - } - /> - - - - - -
+
+ Region:{" "} + {emissionTimeSeries.metadata.region} +
+
+ + ); } diff --git a/webapp/src/components/equivalence-list.tsx b/webapp/src/components/equivalence-list.tsx new file mode 100644 index 000000000..d6ceda295 --- /dev/null +++ b/webapp/src/components/equivalence-list.tsx @@ -0,0 +1,113 @@ +import { cn } from "@/helpers/utils"; + +/* + * The "Equal to" list: an icon, the figure it stands for, and a caption saying + * what the figure means. + * + * One component for both dashboards, since the design draws the same item in + * each. Only the direction differs: the global dashboard spreads them across the + * width of its section, and the project dashboard stacks them in a column beside + * its gauges. That is the `direction` prop, and it is the only thing a caller + * decides — an item always looks the same. + * + * The captions wrap to two lines at the measure the design gives them, so the + * column keeps its shape rather than stretching to the longest caption. + */ +export type Equivalence = { + icon: string; + alt: string; + value: string; + caption: string; +}; + +/* + * The icon, wording and unit for each equivalence, declared once. + * + * Both dashboards compute these from the same helpers, so they must read the + * same. They did not: the two pages had drifted to different captions for the + * same number, and one of them rendered kilometres with no unit at all. + * + * The caption for the first is per-capita emissions, not household energy — that + * is what `getEquivalentCitizenPercentage` divides by (a US citizen's yearly + * CO2e, over 52 weeks). The design's own copy says "an american household weekly + * energy consumption", which describes neither half of that. + */ +export function equivalences({ + citizen, + transportation, + tvTime, +}: { + /** Percentage of a citizen's weekly emissions, already rounded. */ + citizen: string; + /** Kilometres, already rounded. */ + transportation: string; + /** Days, already rounded. */ + tvTime: string; +}): Equivalence[] { + return [ + { + icon: "/icons/household_consumption.svg", + alt: "Household consumption icon", + value: `${citizen}%`, + caption: "Of a U.S. citizen's weekly emissions", + }, + { + icon: "/icons/transportation.svg", + alt: "Transportation icon", + value: `${transportation} km`, + caption: "Kilometers ridden", + }, + { + icon: "/icons/tv.svg", + alt: "TV icon", + value: `${tvTime} days`, + caption: "Of watching TV", + }, + ]; +} + +export default function EquivalenceList({ + items, + direction = "row", + className, +}: Readonly<{ + items: Equivalence[]; + direction?: "row" | "column"; + className?: string; +}>) { + return ( +
    + {items.map((item) => ( +
  • + {item.alt} +
    +

    + {item.value} +

    +

    + {item.caption} +

    +
    +
  • + ))} +
+ ); +} diff --git a/webapp/src/components/experiment-bar-chart.tsx b/webapp/src/components/experiment-bar-chart.tsx index e8a6d0920..f1db23e36 100644 --- a/webapp/src/components/experiment-bar-chart.tsx +++ b/webapp/src/components/experiment-bar-chart.tsx @@ -1,13 +1,6 @@ import { ExperimentReport } from "@/api/schemas"; import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { ChartConfig, ChartContainer, @@ -16,6 +9,7 @@ import { } from "@/components/ui/chart"; import { exportExperimentsToCsv } from "@/utils/export"; import { useMemo, useState } from "react"; +import ChartSection from "./chart-section"; import ChartSkeleton from "./chart-skeleton"; import { ExportCsvButton } from "./export-csv-button"; @@ -35,7 +29,7 @@ const chartConfig = { }, mobile: { label: "Energy consumed", - color: "hsl(var(--secondary))", + color: "var(--cc-gray)", }, } satisfies ChartConfig; @@ -84,16 +78,11 @@ export default function ExperimentsBarChart({ } return ( - - -
- Project experiment runs - - Click an experiment to see the runs on the chart on the - right - -
- {!isPublicView && ( + + ) + } + > + + {experimentsReportData.length > 0 ? ( + + + value.slice(0, 3)} + /> + } + /> + } + radius={4} + /> + + ) : ( +
+

+ No data available +

+
)} -
- - - {experimentsReportData.length > 0 ? ( - - - value.slice(0, 3)} - /> - - } - /> - } - radius={4} - /> - - ) : ( -
-

- No data available -

-
- )} -
-
-
+ + ); } diff --git a/webapp/src/components/export-csv-button.tsx b/webapp/src/components/export-csv-button.tsx index d480114b4..e413e259a 100644 --- a/webapp/src/components/export-csv-button.tsx +++ b/webapp/src/components/export-csv-button.tsx @@ -1,5 +1,6 @@ -import { Button } from "@/components/ui/button"; -import { Download, Loader2 } from "lucide-react"; +import { trackEvent } from "@/utils/matomo"; +import { Loader2 } from "lucide-react"; +import { DownloadIcon } from "@/components/icons/download-icon"; import { useState } from "react"; import { toast } from "sonner"; import { @@ -27,6 +28,7 @@ export function ExportCsvButton({ const [isExporting, setIsExporting] = useState(false); const handleDownload = () => { + trackEvent("Dashboard", "csv_exported"); setIsExporting(true); toast.promise( (async () => { @@ -45,19 +47,19 @@ export function ExportCsvButton({ - +

Download .csv export

diff --git a/webapp/src/components/icons/account-circle-icon.tsx b/webapp/src/components/icons/account-circle-icon.tsx new file mode 100644 index 000000000..2f18adee2 --- /dev/null +++ b/webapp/src/components/icons/account-circle-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Account in circle icon — a head and shoulders inside a circle. + */ + +export function AccountCircleIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/account-icon.tsx b/webapp/src/components/icons/account-icon.tsx new file mode 100644 index 000000000..4bd035532 --- /dev/null +++ b/webapp/src/components/icons/account-icon.tsx @@ -0,0 +1,37 @@ +import { FigmaIconProps } from "./types"; + +/* + * Account icon — a head and shoulders in a circle, drawn in the same pixel-art + * style as the rail's other icons. + */ + +export function AccountIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/arrow-back-ios-icon.tsx b/webapp/src/components/icons/arrow-back-ios-icon.tsx new file mode 100644 index 000000000..aa4f30d2d --- /dev/null +++ b/webapp/src/components/icons/arrow-back-ios-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Back icon — a chevron pointing left. + */ + +export function ArrowBackIosIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/download-icon.tsx b/webapp/src/components/icons/download-icon.tsx new file mode 100644 index 000000000..aee4d1183 --- /dev/null +++ b/webapp/src/components/icons/download-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Download icon — an arrow pointing down onto a line, drawn in the same + * pixel-art style as the rail's icons. + */ + +export function DownloadIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/global-icon.tsx b/webapp/src/components/icons/global-icon.tsx new file mode 100644 index 000000000..648369142 --- /dev/null +++ b/webapp/src/components/icons/global-icon.tsx @@ -0,0 +1,33 @@ +import { FigmaIconProps } from "./types"; + +/* + * Global icon — a globe, drawn in the same pixel-art style as the rail's other + * icons. + */ + +export function GlobalIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/glossary-icon.tsx b/webapp/src/components/icons/glossary-icon.tsx new file mode 100644 index 000000000..5efda6241 --- /dev/null +++ b/webapp/src/components/icons/glossary-icon.tsx @@ -0,0 +1,39 @@ +import { FigmaIconProps } from "./types"; + +/* + * Glossary icon — an org chart with a smiling robot at the top branching down + * to three nodes, drawn in the same pixel-art style as the rail's other icons. + */ + +export function GlossaryIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/lock-icon.tsx b/webapp/src/components/icons/lock-icon.tsx new file mode 100644 index 000000000..04ab6c302 --- /dev/null +++ b/webapp/src/components/icons/lock-icon.tsx @@ -0,0 +1,23 @@ +import { FigmaIconProps } from "./types"; + +/* + * Lock icon — a closed padlock, drawn in the same pixel-art style as the rail's + * icons. + */ + +export function LockIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/logout-icon.tsx b/webapp/src/components/icons/logout-icon.tsx new file mode 100644 index 000000000..b8d2b1cd4 --- /dev/null +++ b/webapp/src/components/icons/logout-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Log-out icon — an arrow leaving through a doorway, drawn in the same pixel-art + * style as the rail's icons. + */ + +export function LogoutIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/members-icon.tsx b/webapp/src/components/icons/members-icon.tsx new file mode 100644 index 000000000..837ef4eba --- /dev/null +++ b/webapp/src/components/icons/members-icon.tsx @@ -0,0 +1,73 @@ +import { FigmaIconProps } from "./types"; + +/* + * Members icon — three figures side by side, middle one popping out, + * drawn in the same pixel-art style as the rail's other icons. + */ + +export function MembersIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/more-vert-icon.tsx b/webapp/src/components/icons/more-vert-icon.tsx new file mode 100644 index 000000000..cf3b0ecb9 --- /dev/null +++ b/webapp/src/components/icons/more-vert-icon.tsx @@ -0,0 +1,22 @@ +import { FigmaIconProps } from "./types"; + +/* + * Vertical more icon — three dots in a vertical line, pixel-art style. + */ + +export function MoreVertIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/organization-icon.tsx b/webapp/src/components/icons/organization-icon.tsx new file mode 100644 index 000000000..1f9dfc74d --- /dev/null +++ b/webapp/src/components/icons/organization-icon.tsx @@ -0,0 +1,72 @@ +import { FigmaIconProps } from "./types"; + +/* + * Organization icon — an office building. + */ + +export function OrganizationIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/organizations-icon.tsx b/webapp/src/components/icons/organizations-icon.tsx new file mode 100644 index 000000000..1fa0aa2bb --- /dev/null +++ b/webapp/src/components/icons/organizations-icon.tsx @@ -0,0 +1,53 @@ +import { FigmaIconProps } from "./types"; + +/* + * Organizations icon — a building with two wings, drawn in the same pixel-art + * style as the rail's other icons. Distinct from `OrganizationIcon`, the thin + * glyph the menu rows use. + * + * The artwork only fills the middle of its original canvas, so the viewBox is + * cropped to a square around it to fill the 32px frame like its siblings. + */ + +export function OrganizationsIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/plus-icon.tsx b/webapp/src/components/icons/plus-icon.tsx new file mode 100644 index 000000000..f82fe3574 --- /dev/null +++ b/webapp/src/components/icons/plus-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Plus icon — a plus sign. + */ + +export function PlusIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/projects-icon.tsx b/webapp/src/components/icons/projects-icon.tsx new file mode 100644 index 000000000..23877c57e --- /dev/null +++ b/webapp/src/components/icons/projects-icon.tsx @@ -0,0 +1,35 @@ +import { FigmaIconProps } from "./types"; + +/* + * Projects icon — two magazine files with documents standing in them, a smiley face + * on the front one, drawn in the same pixel-art style as the rail's other icons. + */ + +export function ProjectsIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/refresh-icon.tsx b/webapp/src/components/icons/refresh-icon.tsx new file mode 100644 index 000000000..425f362da --- /dev/null +++ b/webapp/src/components/icons/refresh-icon.tsx @@ -0,0 +1,24 @@ +import { FigmaIconProps } from "./types"; + +/* + * Refresh icon — two arrows chasing each other in a circle, drawn in the same + * pixel-art style as the rail's icons. + */ + +export function RefreshIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/settings-icon.tsx b/webapp/src/components/icons/settings-icon.tsx new file mode 100644 index 000000000..b5c6663f8 --- /dev/null +++ b/webapp/src/components/icons/settings-icon.tsx @@ -0,0 +1,23 @@ +import { FigmaIconProps } from "./types"; + +/* + * Settings icon — a cog, drawn in the same pixel-art style as the rail's icons. + */ + +export function SettingsIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/share-icon.tsx b/webapp/src/components/icons/share-icon.tsx new file mode 100644 index 000000000..8a4487b67 --- /dev/null +++ b/webapp/src/components/icons/share-icon.tsx @@ -0,0 +1,23 @@ +import { FigmaIconProps } from "./types"; + +/* + * Share icon — three nodes joined by lines, drawn in the same pixel-art style as + * the rail's icons. + */ + +export function ShareIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/icons/types.ts b/webapp/src/components/icons/types.ts new file mode 100644 index 000000000..124b7fad1 --- /dev/null +++ b/webapp/src/components/icons/types.ts @@ -0,0 +1,16 @@ +/* + * Single declaration of the contract for every icon in this directory. + * + * An icon takes nothing but a class: it draws with `currentColor` and at the + * size its caller gives it, so state colour and dimensions belong to the + * control around it rather than to the glyph. + * + * Named for where the set comes from: nearly all of these are exported verbatim + * from the Code Carbon Figma file. A few were not in the Figma and were + * designed apart from the rest, but in the same style, and they share this same + * contract. Seeing `FigmaIconProps` in a module is the quiet signal that the + * icon was added during the dashboard redesign. + */ +export type FigmaIconProps = { + className?: string; +}; diff --git a/webapp/src/components/icons/user-single-aim-icon.tsx b/webapp/src/components/icons/user-single-aim-icon.tsx new file mode 100644 index 000000000..89ccf1a04 --- /dev/null +++ b/webapp/src/components/icons/user-single-aim-icon.tsx @@ -0,0 +1,52 @@ +import { FigmaIconProps } from "./types"; + +/* + * User single aim icon — one figure inside a targeting reticle, drawn in + * pixel-art style. + */ + +export function UserSingleAimIcon({ className }: FigmaIconProps) { + return ( + + ); +} diff --git a/webapp/src/components/member-row.tsx b/webapp/src/components/member-row.tsx new file mode 100644 index 000000000..6b3aeb44c --- /dev/null +++ b/webapp/src/components/member-row.tsx @@ -0,0 +1,123 @@ +import { OrganizationUser } from "@/api/schemas"; +import { cn } from "@/helpers/utils"; +import { MoreVertIcon } from "./icons/more-vert-icon"; +import { UserSingleAimIcon } from "./icons/user-single-aim-icon"; +import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { MenuItem, MenuPanel } from "./ui/menu"; +import { TableCell, TableRow } from "./ui/table"; + +/* + * One member of the organization: an avatar, their name and email, their + * standing in it, and a menu of the actions that apply to them. + * + * Unlike a project row this is not a link — there is no member page to open — + * so nothing lights on hover and the overflow menu is its only control. + * + * The avatar is decorative: no API field can fill the design's photo circle, so + * it renders the design's member glyph and the name beside it is the identity. + */ + +/* + * The trigger's padding, and the gap its menu keeps from the glyph. Radix + * anchors to the trigger's whole box, so both are cancelled to bring the panel + * back to the dots. Same values as a project row's, because it is the same + * control. + */ +const TRIGGER_INSET = 20; +const MENU_GAP = 4; + +export default function MemberRow({ + member, + onDelete, +}: Readonly<{ + member: OrganizationUser; + /** Omitted for anyone who may not remove this member; the menu then has + nothing in it and the trigger is not rendered at all. */ + onDelete?: () => void; +}>) { + /* The API's name can come back empty; the email is the only field always + present, so it becomes the row's title when there is nothing above it. */ + const name = member.name?.trim(); + + return ( + + +
+ + {/* Wraps rather than overflows: an email address is long + and a narrow screen has to hold it. */} +
+ {name && ( + + {name} + + )} + + {member.email} + +
+
+
+ + {/* + * The design's status note. It reads "(Invited unnaccepted yet)", + * which nothing in the API can tell us — adding a member subscribes + * an existing account immediately, so there is no pending state. The + * slot instead carries the one thing the membership does record, + * which is whether they administer the organization. + */} + + {member.is_admin && ( + + (Admin) + + )} + + + {/* Only as wide as its trigger, and empty when there is nothing + to offer — a disabled control would only advertise an action + the viewer cannot take. */} + + {onDelete && ( + + + + + {/* + * The design fills this menu with "Resend invite" and a + * "Settings" entry, neither of which has an endpoint + * behind it — there is no pending invitation to resend + * and no membership field to edit. Removal is the one + * action the API supports, so it is the one row here. + */} + + Delete + + + )} + +
+ ); +} diff --git a/webapp/src/components/mobile-header.tsx b/webapp/src/components/mobile-header.tsx deleted file mode 100644 index ba7d3c91a..000000000 --- a/webapp/src/components/mobile-header.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; -import { Organization } from "@/api/schemas"; -import { Menu } from "lucide-react"; -import { Link } from "react-router-dom"; -import { useState } from "react"; -import NavBar from "./navbar"; - -export default function MobileHeader({ - orgs, -}: { - orgs: Organization[] | undefined; -}) { - const [isSheetOpen, setSheetOpened] = useState(false); - - return ( -
- {/* Drawer that shows only on small screens */} - - setSheetOpened(true)}> - - - -
- setSheetOpened(false)} - className="flex flex-1 justify-center items-center gap-2 pt-6 font-semibold" - > - Logo - -
- -
-
-
- ); -} diff --git a/webapp/src/components/nav-item.tsx b/webapp/src/components/nav-item.tsx deleted file mode 100644 index 8f9f4f103..000000000 --- a/webapp/src/components/nav-item.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from "./ui/button"; - -export default function NavItem({ - isSelected, - onClick, - icon, - children, - paddingY = 3, -}: { - isSelected: boolean; - onClick?: () => void; - icon?: React.ReactNode; - children: React.ReactNode; - paddingY?: number; -}) { - return ( - - ); -} diff --git a/webapp/src/components/navbar.tsx b/webapp/src/components/navbar.tsx deleted file mode 100644 index a1431db8f..000000000 --- a/webapp/src/components/navbar.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { Organization } from "@/api/schemas"; -import { SelectGroup } from "@radix-ui/react-select"; -import { - AreaChart, - Building, - Home, - LogOutIcon, - UserIcon, - Users, -} from "lucide-react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useEffect, useState } from "react"; -import NavItem from "./nav-item"; -import { - Select, - SelectContent, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "./ui/select"; -import CreateOrganizationModal from "./createOrganizationModal"; -import { getOrganizations } from "@/api/organizations"; -import { Button } from "./ui/button"; -import { useModal } from "@/hooks/useModal"; - -const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; -export default function NavBar({ - orgs, - setSheetOpened, -}: Readonly<{ - orgs: Organization[] | undefined; - setSheetOpened?: (value: boolean) => void; -}>) { - const [selected, setSelected] = useState(null); - const navigate = useNavigate(); - const [selectedOrg, setSelectedOrg] = useState(() => { - try { - return localStorage.getItem("organizationId"); - } catch { - return null; - } - }); - const iconStyles = "h-4 w-4 flex-shrink-0 text-muted-foreground"; - const { pathname } = useLocation(); - const newOrgModal = useModal(); - const [organizationList, setOrganizationList] = useState< - Organization[] | undefined - >([]); - const [isDropdownOpen, setDropdownOpen] = useState(false); - - useEffect(() => { - if (pathname.includes("/members")) { - setSelected("members"); - } else if (pathname.includes("/profile")) { - setSelected("profile"); - } else if (pathname.includes("/projects")) { - setSelected("projects"); - return; - } else { - setSelected("home"); - } - }, [pathname, orgs]); - - useEffect(() => { - if (orgs) { - setOrganizationList(orgs); - } - }, [orgs]); - - useEffect(() => { - if (!organizationList?.length) { - setSelectedOrg(null); - return; - } - const routeOrgId = pathname.split("/")[1]; - if (organizationList.some((org) => org.id === routeOrgId)) { - setSelectedOrg(routeOrgId); - } else if (!organizationList.some((org) => org.id === selectedOrg)) { - setSelectedOrg(organizationList[0].id); - } - }, [pathname, organizationList, selectedOrg]); - - useEffect(() => { - if (!selectedOrg) return; - try { - localStorage.setItem("organizationId", selectedOrg); - const organizationName = organizationList?.find( - (organization) => organization.id === selectedOrg, - )?.name; - if (organizationName) { - localStorage.setItem("organizationName", organizationName); - } - } catch (error) { - console.error("Error writing to localStorage:", error); - } - }, [selectedOrg, organizationList]); - - const handleNewOrgClick = async () => { - newOrgModal.open(); - setDropdownOpen(false); // Close the dropdown menu - }; - - const refreshOrgList = async () => { - // Fetch the updated list of organizations from the server - const orgs = await getOrganizations(); - setOrganizationList(orgs); - }; - - return ( -
- -
- ); -} diff --git a/webapp/src/components/organizations-menu.tsx b/webapp/src/components/organizations-menu.tsx new file mode 100644 index 000000000..cf2f44390 --- /dev/null +++ b/webapp/src/components/organizations-menu.tsx @@ -0,0 +1,149 @@ +import { useState } from "react"; +import useSWR from "swr"; + +import { getOrganizations } from "@/api/organizations"; +import { Organization, OrganizationUser, User } from "@/api/schemas"; +import { fetcher } from "@/api/swr"; +import { useModal } from "@/hooks/useModal"; +import CreateOrganizationModal from "./create-organization-modal"; +import { OrganizationIcon } from "./icons/organization-icon"; +import { PlusIcon } from "./icons/plus-icon"; +import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { MenuItem, MenuPanel } from "./ui/menu"; + +export default function OrganizationsMenu({ + orgs, + selectedOrg, + onSelectOrg, + children, +}: Readonly<{ + orgs: Organization[] | undefined; + selectedOrg: string | null; + onSelectOrg: (organizationId: string) => void; + /** The rail's "Organizations" item, used as the trigger. */ + children: React.ReactNode; +}>) { + const [open, setOpen] = useState(false); + // Keyed on instead of `open`, so closing the menu does not drop the admin + // lookup below and regroup the organizations as it animates out. + const [hasOpened, setHasOpened] = useState(false); + const newOrgModal = useModal(); + const [organizationList, setOrganizationList] = useState< + Organization[] | undefined + >(undefined); + + const { data: auth } = useSWR<{ user?: User }>("/auth/check", fetcher, { + revalidateOnFocus: false, + }); + + const list = organizationList ?? orgs; + + // Admin rights are exposed only per organization, on its member list, so + // this asks each in turn. Dashboards the user administers go below the rule. + const userId = auth?.user?.id; + const { data: adminOrgIds } = useSWR( + hasOpened && userId && list && list.length > 0 + ? ["organization-admin", userId, list.map((o) => o.id).join(",")] + : null, + async () => { + const ids = await Promise.all( + (list ?? []).map(async (org) => { + try { + const members: OrganizationUser[] = await fetcher( + `/organizations/${org.id}/users`, + ); + return members.some( + (m) => m.id === userId && m.is_admin, + ) + ? org.id + : null; + } catch { + // A membership that cannot be read counts as non-admin + // rather than failing the whole menu. + return null; + } + }), + ); + return new Set(ids.filter((id): id is string => id !== null)); + }, + { revalidateOnFocus: false }, + ); + + const owned = list?.filter((org) => adminOrgIds?.has(org.id)) ?? []; + const invited = list?.filter((org) => !adminOrgIds?.has(org.id)) ?? []; + + const refreshOrgList = async () => { + setOrganizationList(await getOrganizations()); + }; + + return ( + <> + { + setOpen(next); + if (next) setHasOpened(true); + }} + > + {children} + {/* Opens beside the rail, bottom edges level with the trigger. The offset + crosses the rail's 16px padding and 2px border, plus an 8px gap. */} + + {invited.length > 0 && ( +

+ Dashboards you've been invited to +

+ )} + + {invited.map((org) => ( + onSelectOrg(org.id)} + icon={ + + } + > + {org.name} + + ))} + + newOrgModal.open()} + icon={} + > + Add new organization + + + {owned.length > 0 && ( +
+ )} + + {owned.map((org) => ( + onSelectOrg(org.id)} + icon={ + + } + > + {org.name} + + ))} + + + + + + ); +} diff --git a/webapp/src/components/project-actions.tsx b/webapp/src/components/project-actions.tsx new file mode 100644 index 000000000..427eb30d3 --- /dev/null +++ b/webapp/src/components/project-actions.tsx @@ -0,0 +1,216 @@ +import { useState } from "react"; +import { toast } from "sonner"; + +import { + getEmissionsTimeSeries, + getRunEmissionsByExperiment, +} from "@/api/runs"; +import { ExperimentReport, Project } from "@/api/schemas"; +import { useModal } from "@/hooks/useModal"; +import { cn } from "@/helpers/utils"; +import { exportToJson } from "@/utils/export"; +import ProjectSettingsModal from "./project-settings-modal"; +import { DownloadIcon } from "./icons/download-icon"; +import { RefreshIcon } from "./icons/refresh-icon"; +import { SettingsIcon } from "./icons/settings-icon"; +import ShareProjectButton from "./share-project-button"; +import { IconButton } from "./ui/icon-button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; + +/* + * The actions that apply to a project as a whole: refresh, share, export, and + * settings. + * + * Its own component so it can sit in the page's heading, beside the project's + * name, rather than inside the panels below — the dashboard's data flows down, + * but these controls belong to the project, not to any panel. It owns the state + * only it uses (the refresh and export spinners, the settings dialog). + * + * The controls are square outlined icon buttons, so they carry the same radius + * and the same hover as every other control in the app. + */ +export default function ProjectActions({ + project, + experimentsReportData, + runData, + onRefresh, + onProjectUpdated, + className, +}: Readonly<{ + project: Project; + experimentsReportData: ExperimentReport[]; + runData: { experimentId: string; startDate: string; endDate: string }; + /** Refetches the dashboard, behind the refresh control. */ + onRefresh: () => void | Promise; + /** Runs after the settings dialog saves; defaults to a full refresh. */ + onProjectUpdated?: () => void | Promise; + className?: string; +}>) { + const settingsModal = useModal(); + const [isExporting, setIsExporting] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async () => { + setIsRefreshing(true); + try { + await onRefresh(); + } finally { + setIsRefreshing(false); + } + }; + + const handleJsonExport = () => { + if (isExporting) return; + + setIsExporting(true); + + toast.promise( + (async () => { + // Prepare the experiments data with runs for each experiment + const experimentsWithRuns = await Promise.all( + experimentsReportData.map(async (exp) => { + // Fetch runs for each experiment + const runs = await getRunEmissionsByExperiment( + exp.experiment_id, + runData.startDate, + runData.endDate, + ); + + // Fetch metadata and emissions for each run + const runsWithDetails = await Promise.all( + runs.map(async (run) => { + // Get emissions time series data (includes metadata) + const emissionsData = + await getEmissionsTimeSeries(run.runId); + + // Return run with metadata and emissions + return { + ...run, + emissions_value: run.emissions, + emissions: + emissionsData?.emissions || undefined, + metadata: + emissionsData.metadata || undefined, + }; + }), + ); + + // Return experiment data with its enhanced runs + return { + experiment_id: exp.experiment_id, + name: exp.name, + emissions: exp.emissions, + energy_consumed: exp.energy_consumed, + duration: exp.duration, + runs: runsWithDetails, + }; + }), + ); + + // Format the project data according to the requested structure + const formattedData = { + projects: [ + { + // Include all project properties + id: project.id, + name: project.name, + description: project.description, + public: project.public, + organizationId: project.organizationId, + experiments: experimentsWithRuns, + + // Add extra metadata + date_range: { + startDate: runData.startDate, + endDate: runData.endDate, + }, + }, + ], + }; + + exportToJson(formattedData); + // Small delay to make the loading state visible + await new Promise((resolve) => setTimeout(resolve, 500)); + setIsExporting(false); + })(), + { + loading: "Exporting JSON data...", + success: "JSON data exported successfully", + error: "Failed to export JSON data", + }, + ); + }; + + return ( +
+
+ + + + + + + + +

Refresh data

+
+
+
+ + + + + + + + + +

Download JSON export

+
+
+
+ + + +
+ + +
+ ); +} diff --git a/webapp/src/components/project-dashboard-base.tsx b/webapp/src/components/project-dashboard-base.tsx index d26e19272..0a35b269b 100644 --- a/webapp/src/components/project-dashboard-base.tsx +++ b/webapp/src/components/project-dashboard-base.tsx @@ -1,5 +1,4 @@ import { DateRangePicker } from "@/components/date-range-picker"; -import { Separator } from "@/components/ui/separator"; import { getDefaultDateRange } from "@/helpers/date-utils"; import { ExperimentReport, @@ -10,10 +9,13 @@ import { } from "@/api/schemas"; import { lazy, ReactNode, Suspense, useState } from "react"; import { DateRange } from "react-day-picker"; +import ChartRow from "./chart-row"; import ChartSkeleton from "./chart-skeleton"; -import CreateExperimentModal from "./createExperimentModal"; -import { Button } from "./ui/button"; -import { Card, CardContent } from "./ui/card"; +import ConsumedEnergyGauges from "./consumed-energy-gauges"; +import { PlusIcon } from "./icons/plus-icon"; +import { PrimaryButton } from "./ui/primary-button"; +import EquivalenceList, { equivalences } from "./equivalence-list"; +import CreateExperimentModal from "./create-experiment-modal"; import { Select, SelectContent, @@ -29,7 +31,6 @@ import { toast } from "sonner"; // experiment dropdown. Radix Select forbids an empty string as an item value. const ALL_EXPERIMENTS = "__all__"; -const RadialChart = lazy(() => import("@/components/radial-chart")); const ExperimentsBarChart = lazy( () => import("@/components/experiment-bar-chart"), ); @@ -107,20 +108,17 @@ export default function ProjectDashboardBase({ }; return ( -
-
- {headerContent ? ( - headerContent - ) : ( -
-

{project.name}

-

- {project.description} -

-
- )} -
+ /* + * No gap on this column: the charts below are separated by rules that have + * to meet, and a gap here would push the horizontal one away from the + * vertical ones. Each block carries its own space instead. + */ +
+
+ {headerContent} +
onDateChange(newDate || getDefaultDateRange()) @@ -128,234 +126,145 @@ export default function ProjectDashboardBase({ />
-
-
- {isLoading ? ( - <> - - - - - ) : ( - <> -
-
- Household consumption icon -
-
-

- {convertedValues.citizen} % -

-

- Of a U.S citizen weekly energy emissions -

-
-
-
-
- Transportation icon -
-
-

- {convertedValues.transportation} -

-

- Kilometers ridden -

-
-
-
-
- TV icon -
-
-

- {convertedValues.tvTime} days -

-

- Of watching TV -

-
-
- - )} -
-
- {isLoading ? ( - - - - - - ) : ( - - - - - - } - > - - - )} -
-
- {isLoading ? ( - - - - - - ) : ( - - - - - - } - > - - - )} -
-
+
+ {isLoading ? ( +
+ + + +
+ ) : ( + + )} + +
+

+ Consumed energy +

{isLoading ? ( - - - - - +
+ + + +
) : ( - - - - - - } - > - - + )} -
+
- - -
-

- {projectExperiments.length === 0 - ? isPublicView - ? "No experiment data in the selected date range" // This is because for public projects we show only the experiments that have runs, but for private projects we show in this list as well the projects created but without runs yet - : "No experiments have been created yet." - : "Set of experiments included in this project"} +

+

+ Experiments +

+ + {projectExperiments.length === 0 && ( +

+ {isPublicView + ? // Public projects list only experiments that have + // runs; private ones also list those created without + // any runs yet. + "No experiment data in the selected date range" + : "No experiments have been created yet."}

+ )} + +
+ {projectExperiments.length !== 0 && ( +
+ + {selectedExperiment && ( +
+ {selectedExperiment.description && ( +

+ {selectedExperiment.description} +

+ )} + {!isPublicView && ( +
+ + Experiment id + + + {selectedExperiment.id} + + +
+ )} +
+ )} +
+ )} + {!isPublicView && ( -
- + + Add an experiment + setIsExperimentModalOpen(false)} onExperimentCreated={onExperimentCreated} /> -
+ )}
- {projectExperiments.length !== 0 && ( -
- - {selectedExperiment && ( - - {selectedExperiment.description && ( -

- {selectedExperiment.description} -

- )} - {!isPublicView && ( -
- - Experiment id - - - {selectedExperiment.id} - - -
- )} -
- )} -
- )} - -
+
+ {isLoading ? ( <> @@ -386,25 +295,22 @@ export default function ProjectDashboardBase({ )} -
+ {selectedRunId && selectedRunId != "" && ( - <> - -
- {isLoading ? ( - - ) : ( - }> - - - )} -
- +
+ {isLoading ? ( + + ) : ( + }> + + + )} +
)}
); diff --git a/webapp/src/components/project-dashboard.tsx b/webapp/src/components/project-dashboard.tsx index c5714f3b7..1efee919e 100644 --- a/webapp/src/components/project-dashboard.tsx +++ b/webapp/src/components/project-dashboard.tsx @@ -1,31 +1,15 @@ -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - getEmissionsTimeSeries, - getRunEmissionsByExperiment, -} from "@/api/runs"; import { ProjectDashboardProps } from "@/api/schemas"; -import { exportToJson } from "@/utils/export"; -import { - Download, - LockIcon, - RefreshCw, - SettingsIcon, - Share2Icon, -} from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; import ProjectDashboardBase from "./project-dashboard-base"; -import ProjectSettingsModal from "./project-settings-modal"; -import ShareProjectButton from "./share-project-button"; -import { useModal } from "@/hooks/useModal"; +/* + * The private project dashboard: the shared panels, wired to the authenticated + * data. + * + * The project's own controls — refresh, share, export, settings — used to live + * here as a header row passed down to the base. They now sit in the page's + * heading beside the project's name, as `ProjectActions`, so nothing about the + * project's identity is rendered from inside its panels. + */ export default function ProjectDashboard({ project, date, @@ -39,229 +23,26 @@ export default function ProjectDashboard({ selectedRunId, onExperimentClick, onRunClick, - onSettingsClick, onRefresh, isLoading, }: ProjectDashboardProps) { - const settingsModal = useModal(); - const [isExporting, setIsExporting] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); - - const handleRefresh = async () => { - setIsRefreshing(true); - try { - await onRefresh(); - } finally { - setIsRefreshing(false); - } - }; - - const handleJsonExport = () => { - if (isExporting) return; - - setIsExporting(true); - - toast.promise( - (async () => { - // Prepare the experiments data with runs for each experiment - const experimentsWithRuns = await Promise.all( - experimentsReportData.map(async (exp) => { - // Fetch runs for each experiment - const runs = await getRunEmissionsByExperiment( - exp.experiment_id, - runData.startDate, - runData.endDate, - ); - - // Fetch metadata and emissions for each run - const runsWithDetails = await Promise.all( - runs.map(async (run) => { - // Get emissions time series data (includes metadata) - const emissionsData = - await getEmissionsTimeSeries(run.runId); - - // Return run with metadata and emissions - return { - ...run, - emissions_value: run.emissions, - emissions: - emissionsData?.emissions || undefined, - metadata: - emissionsData.metadata || undefined, - }; - }), - ); - - // Return experiment data with its enhanced runs - return { - experiment_id: exp.experiment_id, - name: exp.name, - emissions: exp.emissions, - energy_consumed: exp.energy_consumed, - duration: exp.duration, - runs: runsWithDetails, - }; - }), - ); - - // Format the project data according to the requested structure - const formattedData = { - projects: [ - { - // Include all project properties - id: project.id, - name: project.name, - description: project.description, - public: project.public, - organizationId: project.organizationId, - experiments: experimentsWithRuns, - - // Add extra metadata - date_range: { - startDate: runData.startDate, - endDate: runData.endDate, - }, - }, - ], - }; - - exportToJson(formattedData); - // Small delay to make the loading state visible - await new Promise((resolve) => setTimeout(resolve, 500)); - setIsExporting(false); - })(), - { - loading: "Exporting JSON data...", - success: "JSON data exported successfully", - error: "Failed to export JSON data", - }, - ); - }; - - const headerContent = ( -
-
-

- Project {project.name} -

- {project.public !== undefined && ( -
- -
- )} -
-
- - - - - - -

Refresh data

-
-
-
- - - - - - - -

Download JSON export

-
-
-
- -
-
- ); - return ( -
- - - { - // Call the original onSettingsClick to refresh the data - onSettingsClick(); - }} - /> -
- ); -} - -export function ProjectVisibilityBadge({ isPublic }: { isPublic: boolean }) { - return isPublic ? ( - - - Public - - ) : ( - - - Private - + ); } diff --git a/webapp/src/components/project-row.tsx b/webapp/src/components/project-row.tsx new file mode 100644 index 000000000..0d025e923 --- /dev/null +++ b/webapp/src/components/project-row.tsx @@ -0,0 +1,99 @@ +import { Link } from "react-router-dom"; + +import { Project } from "@/api/schemas"; +import { cn } from "@/helpers/utils"; +import { MoreVertIcon } from "./icons/more-vert-icon"; +import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { MenuItem, MenuPanel } from "./ui/menu"; +import { TableCell, TableRow } from "./ui/table"; + +/* + * One project in the list: its name, its secondary text, and a menu of the + * actions that apply to it. + * + * Both texts are links filling their cells, so the whole band is a click + * target, and they light together on hover as one item. The actions cell is + * excluded from that: its trigger is a small glyph, and if the cell lit with it + * there would be no way to tell the button from merely being near it. + */ + +/** Lets the row's hover exclude the actions cell. */ +const ACTIONS_CELL = "project-row-actions"; + +/* + * The trigger's padding, and the gap its menu keeps from the glyph. Radix anchors + * a menu to the trigger's box — the whole hit area — so cancelling that padding on + * both axes anchors the panel to the dots themselves, and enlarging the hit area + * no longer pushes the menu away from what opened it. + */ +const TRIGGER_INSET = 20; +const MENU_GAP = 4; + +const CELL_LINK = + "type-mono-medium block break-words py-5 text-cc-white outline-none transition-colors " + + "group-[:hover:not(:has(.project-row-actions:hover))]:text-cc-button-hover " + + "focus-visible:ring-2 focus-visible:ring-cc-lime lg:py-6 motion-reduce:transition-none"; + +export default function ProjectRow({ + project, + href, + onSettings, + onDelete, +}: Readonly<{ + project: Project; + href: string; + onSettings: () => void; + onDelete: () => void; +}>) { + return ( + + {/* The name takes half the table, which is what starts the secondary + text at its own column. */} + + + {project.name} + + + + + {project.description && ( + + {project.description} + + )} + + + {/* Only as wide as its trigger. */} + + + + + + + Settings + Delete + + + + + ); +} diff --git a/webapp/src/components/project-settings-modal.tsx b/webapp/src/components/project-settings-modal.tsx index 0befd0ba9..a85088275 100644 --- a/webapp/src/components/project-settings-modal.tsx +++ b/webapp/src/components/project-settings-modal.tsx @@ -1,22 +1,28 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { updateProject } from "@/api/projects"; import { Project } from "@/api/schemas"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ProjectTokensTable } from "./projectTokens/projectTokenTable"; -import { updateProject } from "@/api/projects"; -import { toast } from "sonner"; -import { Loader2 } from "lucide-react"; +import ShareProjectButton from "./share-project-button"; +import { Dialog, DialogContent } from "./ui/dialog"; +import ModalHeader from "./ui/modal-header"; +import { FormField } from "./ui/form-field"; +import { PrimaryButton } from "./ui/primary-button"; +import { Switch } from "./ui/switch"; +import { TabNavList, TabNavTrigger } from "./ui/tab-nav"; +import { Tabs, TabsContent } from "./ui/tabs"; + +/* + * Project settings: the Create-project modal's panel, fields and button, wider + * because it also holds the API-tokens table. The design has no frame for this + * dialog, so it is the redesign's vocabulary applied to the controls it had. + * + * Everything here, the public toggle included, saves on submit; the dialog then + * stays open, so the sharing link the toggle enables appears next to the save + * button rather than behind a second trip into settings. + */ interface ProjectSettingsModalProps { open: boolean; @@ -34,15 +40,36 @@ export default function ProjectSettingsModal({ const [name, setName] = useState(project.name || ""); const [description, setDescription] = useState(project.description || ""); const [isPublic, setIsPublic] = useState(project.public || false); + /* + * What visibility is actually stored, which is what the sharing link below + * follows. Tracked here rather than read from the prop because a parent may + * hold the project it opened the dialog with and not re-pass it after a + * save. + */ + const [savedIsPublic, setSavedIsPublic] = useState(project.public || false); const [isSaving, setIsSaving] = useState(false); const [activeTab, setActiveTab] = useState("general"); - // Update form when project changes + /* + * The dialog stays mounted between openings, so the tab it was left on would + * otherwise still be showing the next time it opens. Settings starts on + * General; the tokens tab is somewhere you go, not somewhere you resume. + */ + useEffect(() => { + if (open) setActiveTab("general"); + }, [open]); + + /* + * Reset the form when the project this dialog is editing changes, including + * the refresh that follows a save. Keyed on the fields rather than the + * object, which is a new one on every refresh. + */ useEffect(() => { setName(project.name || ""); setDescription(project.description || ""); setIsPublic(project.public || false); - }, [project]); + setSavedIsPublic(project.public || false); + }, [project.id, project.name, project.description, project.public]); const handleSave = async () => { setIsSaving(true); @@ -52,97 +79,110 @@ export default function ProjectSettingsModal({ description, public: isPublic, }); + setSavedIsPublic(isPublic); toast.success("Project settings updated successfully"); + // Kept open: the sharing link a just-enabled project gains appears + // beside the button that was clicked, ready to copy. onProjectUpdated(); } catch (error) { console.error("Error updating project:", error); toast.error("Failed to update project settings"); } finally { setIsSaving(false); - onOpenChange(false); } }; return ( - - - Project Settings - - Manage your project settings and API tokens - - + + - - - General - - - API Tokens - - + + General + API Tokens + - -
-
- - setName(e.target.value)} - placeholder="Enter project name" - className="mt-1" - /> -
-
- - - setDescription(e.target.value) - } - placeholder="Enter project description" - className="mt-1" - /> -
-
+ +
{ + event.preventDefault(); + handleSave(); + }} + > + setName(e.target.value)} + /> + + setDescription(e.target.value)} + /> + +
-
-
- - - +
+ + {isSaving && ( + + )} + {isSaving ? "Saving..." : "Save changes"} + + + {/* Follows the *saved* visibility, not the + toggle: there is no link to copy until the + project is actually public. */} + +
+ - + {/* Not redesigned yet — the table keeps its current look. */} + diff --git a/webapp/src/components/project-visibility-badge.tsx b/webapp/src/components/project-visibility-badge.tsx new file mode 100644 index 000000000..9eaf33520 --- /dev/null +++ b/webapp/src/components/project-visibility-badge.tsx @@ -0,0 +1,35 @@ +import { LockIcon } from "./icons/lock-icon"; +import { ShareIcon } from "./icons/share-icon"; +import { Badge } from "./ui/badge"; + +/* + * Whether a project is visible to anyone with its link, shown beside its name. + * + * Its own module rather than an export of the project dashboard, so the page + * heading can show it without importing from the panels below it. + * + * The glyphs are the redesign's own; the pill around them is still the badge as + * it was. They are drawn a little larger than the 12px the old icons used — + * pixel-art strokes need the room to stay legible at this size. + */ +export default function ProjectVisibilityBadge({ + isPublic, +}: Readonly<{ isPublic: boolean }>) { + return isPublic ? ( + + + Public + + ) : ( + + + Private + + ); +} diff --git a/webapp/src/components/projectTokens/projectTokenTable.tsx b/webapp/src/components/projectTokens/projectTokenTable.tsx index 87b03ddcf..a23f3ed75 100644 --- a/webapp/src/components/projectTokens/projectTokenTable.tsx +++ b/webapp/src/components/projectTokens/projectTokenTable.tsx @@ -1,3 +1,4 @@ +import { trackEvent } from "@/utils/matomo"; import { Card } from "@/components/ui/card"; import { Table, TableBody } from "@/components/ui/table"; import { AccessLevel, IProjectToken } from "@/api/schemas"; @@ -6,7 +7,10 @@ import CustomRowToken from "@/components/projectTokens/custom-row-token"; import { useMemo, useState, useEffect, useRef } from "react"; import { Loader2, ClipboardCopy, ClipboardCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { PlusIcon } from "@/components/icons/plus-icon"; +import { FormField } from "@/components/ui/form-field"; +import { PrimaryButton } from "@/components/ui/primary-button"; +import { SecondaryButton } from "@/components/ui/secondary-button"; import { toast } from "sonner"; import copy from "copy-to-clipboard"; @@ -49,6 +53,7 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => { tokenName, AccessLevel.WRITE, ); + trackEvent("Activation", "project_token_created"); toast.success(`Token ${tokenName} created successfully`); setCreatedToken(newToken.token ?? null); setTokenName(""); @@ -67,6 +72,7 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => { try { const success = copy(token); if (success) { + trackEvent("Activation", "project_token_copied"); setIsCopied(true); toast.success("Token copied to clipboard"); copyTimerRef.current = setTimeout( @@ -103,14 +109,17 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => { return (
-
+
{!isCreatingToken && !createdToken ? ( - + + Create a new token + ) : createdToken ? (

@@ -152,36 +161,44 @@ export const ProjectTokensTable = ({ projectId }: { projectId: string }) => {

Create new token

-
- { + event.preventDefault(); + handleCreateToken(); + }} + > + setTokenName(e.target.value)} - placeholder="Token Name" - className="flex-grow" disabled={isSubmitting} + containerClassName="min-w-0 flex-1" /> - - -
+
+ + {isSubmitting && ( + + )} + {isSubmitting ? "Creating..." : "Create"} + + setIsCreatingToken(false)} + disabled={isSubmitting} + > + Cancel + +
+
)}
diff --git a/webapp/src/components/radial-chart.tsx b/webapp/src/components/radial-chart.tsx deleted file mode 100644 index efe7fa02b..000000000 --- a/webapp/src/components/radial-chart.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { - Label, - PolarGrid, - PolarRadiusAxis, - RadialBar, - RadialBarChart, -} from "recharts"; - -import { Card, CardContent } from "@/components/ui/card"; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@/components/ui/chart"; - -type chartDataType = { - label: string; - value: number; -}; - -export default function RadialChart({ - data, -}: Readonly<{ data: chartDataType }>) { - const chartConfig = { - value: { - label: data?.label || "", - color: "hsl(var(--primary))", - }, - } satisfies ChartConfig; - - // Check if data is missing or empty - if (!data || data.value === undefined) { - return ( - - -
- No data available -
-
-
- ); - } - - return ( - - - - - - } - /> - - - - - - - - ); -} diff --git a/webapp/src/components/remove-member-modal.tsx b/webapp/src/components/remove-member-modal.tsx index 83ae8e438..8c40221bc 100644 --- a/webapp/src/components/remove-member-modal.tsx +++ b/webapp/src/components/remove-member-modal.tsx @@ -1,85 +1,72 @@ -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { AlertTriangle, Loader2 } from "lucide-react"; - -interface RemoveMemberModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - memberName: string; - memberId: string; - organizationName: string; - onRemove: (memberId: string) => Promise; -} +import { Dialog, DialogContent, DialogDescription } from "./ui/dialog"; +import ModalHeader from "./ui/modal-header"; +import { PrimaryButton } from "./ui/primary-button"; +import { SecondaryButton } from "./ui/secondary-button"; +/* + * Confirm removing a member from the organization. + * + * The same shell as the create dialogs, with the form replaced by a sentence and + * a pair of buttons: removal takes no input, so the only thing left to + * establish is that the right person was picked, and from which organization. + * There is no type-the-name gate as on project deletion — this destroys + * nothing, and the page's own invite field puts the member back. + */ export default function RemoveMemberModal({ - open, - onOpenChange, + isOpen, + onClose, + onConfirm, memberName, - memberId, organizationName, - onRemove, -}: RemoveMemberModalProps) { - const [isRemoving, setIsRemoving] = useState(false); - - const handleRemove = async () => { - setIsRemoving(true); - try { - await onRemove(memberId); - onOpenChange(false); - } catch (error) { - console.error("Error removing member:", error); - } finally { - setIsRemoving(false); - } - }; - + isRemoving, +}: Readonly<{ + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + /** Their name, or their email address when the API has no name. */ + memberName: string; + organizationName: string; + isRemoving: boolean; +}>) { return ( - - - - - - Remove member - - - {memberName} will - lose access to {organizationName} and all of its - projects. Their emission data is kept, and they can be - added back later. + { + if (!open && !isRemoving) onClose(); + }} + > + {/* The design's own close control lives in the header, so the shared + corner button is omitted. */} + + + +
+ + Remove {memberName} from {organizationName}? They lose + access to the organization and all of its projects. + Their emission data is kept, and they can be added back + later by email. - - - - - +
+ + {isRemoving ? "Removing..." : "Remove member"} + + + Cancel + +
+
); diff --git a/webapp/src/components/runs-scatter-chart.tsx b/webapp/src/components/runs-scatter-chart.tsx index ad2a0594a..50b127a0a 100644 --- a/webapp/src/components/runs-scatter-chart.tsx +++ b/webapp/src/components/runs-scatter-chart.tsx @@ -2,17 +2,11 @@ import { RunReport } from "@/api/schemas"; import { format } from "date-fns"; import { Label, Scatter, ScatterChart, Tooltip, XAxis, YAxis } from "recharts"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { getRunEmissionsByExperiment } from "@/api/runs"; import { exportRunsToCsv } from "@/utils/export"; import { pickTimeFormat } from "@/helpers/time-axis"; import { useEffect, useMemo, useState } from "react"; +import ChartSection from "./chart-section"; import ChartSkeleton from "./chart-skeleton"; import { ExportCsvButton } from "./export-csv-button"; import { ChartConfig, ChartContainer } from "./ui/chart"; @@ -123,15 +117,11 @@ export default function RunsScatterChart({ } return ( - - -
- Scatter Chart - Emissions by Run Id - - Click a run to see time series - -
- {!isPublicView && ( + - )} -
- - - {runsReportsData.length > 0 ? ( - + + {runsReportsData.length > 0 ? ( + + + format(new Date(value), tickFmt) + } > - - format(new Date(value), tickFmt) - } - > - - - - } /> - onRunClick(data.runId)} - cursor="pointer" + - ) : ( -
-

- No data available -

-
- )} -
-
-
+ + + {/* + * `insideLeft` anchors at the top of the axis and the + * rotation pivots on that anchor, so the label hangs + * from the top unless it is told to anchor on its own + * middle. + */} + + } /> + onRunClick(data.runId)} + cursor="pointer" + /> + + ) : ( +
+

+ No data available +

+
+ )} + + ); } diff --git a/webapp/src/components/settings-nav-item.tsx b/webapp/src/components/settings-nav-item.tsx new file mode 100644 index 000000000..152d96797 --- /dev/null +++ b/webapp/src/components/settings-nav-item.tsx @@ -0,0 +1,64 @@ +import { cn } from "@/helpers/utils"; + +/* + * A row in the Settings page's sub-navigation. + * + * The design draws unselected rows at 50% opacity and gives no hover or pressed + * state, so those are built from what it does define: hover brings the row to + * full opacity over a #2b2b2b wash — the same fill the selected row uses — and + * pressing settles on that fill outright. Disabled rows stay flat. + * + * The selected row is not a button. It is the panel you are already on, so there + * is nothing to press: it renders as a `span` marked `aria-current`, which is + * also what keeps it out of the tab order. + */ + +const ROW = + "flex items-center gap-0 rounded-menu px-5 py-2.5 text-left outline-none " + + "focus-visible:ring-2 focus-visible:ring-cc-lime"; + +const LABEL = "type-display type-settings-nav whitespace-nowrap p-2.5"; + +export default function SettingsNavItem({ + icon: Icon, + label, + isCurrent, + onClick, + disabled, +}: Readonly<{ + icon: (props: { className?: string }) => React.JSX.Element; + label: string; + isCurrent?: boolean; + onClick?: () => void; + disabled?: boolean; +}>) { + const tone = isCurrent ? "text-cc-lime" : "text-cc-white"; + + if (isCurrent) { + return ( + + + {label} + + ); + } + + return ( + + ); +} diff --git a/webapp/src/components/share-project-button.tsx b/webapp/src/components/share-project-button.tsx index 32ef913dd..2f39a6522 100644 --- a/webapp/src/components/share-project-button.tsx +++ b/webapp/src/components/share-project-button.tsx @@ -1,23 +1,33 @@ -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { trackEvent } from "@/utils/matomo"; +import { ShareIcon } from "@/components/icons/share-icon"; +import { IconButton } from "@/components/ui/icon-button"; +import { SecondaryButton } from "@/components/ui/secondary-button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import copy from "copy-to-clipboard"; -import { CheckIcon, CopyIcon, Share2Icon } from "lucide-react"; +import { CheckIcon, CopyIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; interface ShareProjectButtonProps { projectId: string; isPublic: boolean; + /* + * How the control presents itself. `icon` is the round icon button the + * project dashboard has always shown; `labelled` is the redesign's outlined + * "Copy link" button, used where the control sits in a form beside the + * setting that produces the link. Both open the same panel. + */ + trigger?: "icon" | "labelled"; } export default function ShareProjectButton({ projectId, isPublic, + trigger = "icon", }: ShareProjectButtonProps) { const [copied, setCopied] = useState(false); const copyTimerRef = useRef | null>(null); @@ -34,6 +44,7 @@ export default function ShareProjectButton({ const copyToClipboard = () => { try { copy(publicUrl); + trackEvent("Sharing", "public_link_copied"); setCopied(true); toast.success("Public link copied to clipboard"); copyTimerRef.current = setTimeout(() => setCopied(false), 2000); @@ -51,41 +62,45 @@ export default function ShareProjectButton({
- + {trigger === "labelled" ? ( + + + Copy link + + ) : ( + + + + )} - -
-

- Share this project -

-

- Anyone with this link can view this project's - emissions data without authentication. -

-
- - +
diff --git a/webapp/src/components/sidebar-rail.tsx b/webapp/src/components/sidebar-rail.tsx new file mode 100644 index 000000000..bbdd3279f --- /dev/null +++ b/webapp/src/components/sidebar-rail.tsx @@ -0,0 +1,233 @@ +import * as React from "react"; +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { Organization } from "@/api/schemas"; +import { cn } from "@/helpers/utils"; +import AccountMenu from "./account-menu"; +import { GlobalIcon } from "./icons/global-icon"; +import { ProjectsIcon } from "./icons/projects-icon"; +import { AccountIcon } from "./icons/account-icon"; +import { MembersIcon } from "./icons/members-icon"; +import { OrganizationsIcon } from "./icons/organizations-icon"; +import OrganizationsMenu from "./organizations-menu"; + +/* + * The app's primary navigation: the destinations stacked at the top of a + * vertical rail with the organizations and account items pinned to the bottom, and the same items + * as a bottom bar below `md`. The rail's width lives in the `rail` token. + * + * Icons take their fill from `currentColor`, so colouring a control moves its + * icon and label together — green for the page you are on, white otherwise. + */ + +/* + * A control in the rail: an icon above its label, green when it is the page you + * are on and white otherwise. + * + * A plain button that forwards its props, so the destinations use it for + * navigation and the organizations and account controls use it as a menu + * trigger. + */ +const RailButton = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef<"button"> & { + icon: (props: { className?: string }) => React.JSX.Element; + label: string; + isSelected?: boolean; + iconClassName?: string; + } +>( + ( + { icon: Icon, label, isSelected, className, iconClassName, ...props }, + ref, + ) => ( + + ), +); +RailButton.displayName = "RailButton"; + +type RailItem = { + key: string; + label: string; + Icon: (props: { className?: string }) => React.JSX.Element; + path: (orgId: string) => string; +}; + +/* + * The third item is Members, matching the destination it has always pointed at + * (`/:organizationId/members`). Figma labels it "Glossary" and draws a different + * glyph; `GlossaryIcon` is still exported from its own module for whenever a + * glossary page exists. + */ +const ITEMS: RailItem[] = [ + { key: "global", label: "Global", Icon: GlobalIcon, path: (o) => `/${o}` }, + { + key: "projects", + label: "Projects", + Icon: ProjectsIcon, + path: (o) => `/${o}/projects`, + }, + { + key: "members", + label: "Members", + Icon: MembersIcon, + path: (o) => `/${o}/members`, + }, +]; + +export default function SidebarRail({ + orgs, + className, +}: Readonly<{ + orgs: Organization[] | undefined; + className?: string; +}>) { + const { pathname } = useLocation(); + const navigate = useNavigate(); + + // Same selected-organization resolution the previous nav used, so the + // localStorage contract and URL-derived org are unchanged. + const [selectedOrg, setSelectedOrg] = useState(() => { + try { + return localStorage.getItem("organizationId"); + } catch { + return null; + } + }); + + useEffect(() => { + if (selectedOrg) return; + try { + const localOrg = localStorage.getItem("organizationId"); + const found = orgs?.find((org) => org.id === localOrg); + if (localOrg && found) { + setSelectedOrg(localOrg); + } else if (orgs && orgs.length > 0) { + setSelectedOrg(orgs[0].id); + } + } catch (error) { + console.error("Error reading from localStorage:", error); + } + }, [selectedOrg, orgs]); + + useEffect(() => { + if (!selectedOrg) return; + try { + if (localStorage.getItem("organizationId") !== selectedOrg) { + localStorage.setItem("organizationId", selectedOrg); + } + const orgName = orgs?.find((org) => org.id === selectedOrg)?.name; + if (orgName) { + localStorage.setItem("organizationName", orgName); + } + } catch (error) { + console.error("Error writing to localStorage:", error); + } + }, [selectedOrg, orgs]); + + // Keep the rail in step with the org in the URL. + useEffect(() => { + const orgId = pathname.split("/")[1]; + if (orgId && orgs?.some((org) => org.id === orgId)) { + setSelectedOrg(orgId); + } + }, [pathname, orgs]); + + const selectedKey = pathname.includes("/projects") + ? "projects" + : pathname.includes("/members") + ? "members" + : "global"; + + return ( + + ); +} diff --git a/webapp/src/components/ui/breadcrumb.tsx b/webapp/src/components/ui/breadcrumb.tsx index 198c31d07..659c4d79e 100644 --- a/webapp/src/components/ui/breadcrumb.tsx +++ b/webapp/src/components/ui/breadcrumb.tsx @@ -1,115 +1,84 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { Fragment } from "react"; +import { Link } from "react-router-dom"; import { cn } from "@/helpers/utils"; -const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode; - } ->(({ ...props }, ref) =>
- - -
-

API tokens

-
- -
-
-
-
- ); -} diff --git a/webapp/src/pages/ProjectsPage.tsx b/webapp/src/pages/ProjectsPage.tsx index 1a7d1d66a..9db883cba 100644 --- a/webapp/src/pages/ProjectsPage.tsx +++ b/webapp/src/pages/ProjectsPage.tsx @@ -1,43 +1,86 @@ -import BreadcrumbHeader from "@/components/breadcrumb"; -import CreateProjectModal from "@/components/createProjectModal"; -import CustomRow from "@/components/custom-row"; +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import useSWR from "swr"; +import { toast } from "sonner"; + +import CreateProjectModal from "@/components/create-project-modal"; import DeleteProjectModal from "@/components/delete-project-modal"; import ErrorMessage from "@/components/error-message"; import Loader from "@/components/loader"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; +import ProjectRow from "@/components/project-row"; +import ProjectSettingsModal from "@/components/project-settings-modal"; +import { PlusIcon } from "@/components/icons/plus-icon"; +import { PrimaryButton } from "@/components/ui/primary-button"; import { Table, TableBody } from "@/components/ui/table"; + import { fetcher } from "@/api/swr"; +import { deleteProject, getProjects } from "@/api/projects"; +import { Organization, Project } from "@/api/schemas"; import { useModal } from "@/hooks/useModal"; -import { getProjects, deleteProject } from "@/api/projects"; -import { Project } from "@/api/schemas"; -import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; -import useSWR from "swr"; -import { toast } from "sonner"; +import { Breadcrumb } from "@/components/ui/breadcrumb"; +/* + * The Projects page, in its empty and populated states, which differ only in + * where the primary action sits: centred in the empty area, or on the heading + * row beside the title. + * + * The design's frames still draw the older in-page navigation; that lives in + * `SidebarRail` and `AccountMenu` now and is not rebuilt here. + * + * A row's secondary text is the project's description — the design labels it + * "Last updated on 02/02/24", and the API carries no timestamp. + */ export default function ProjectsPage() { const { organizationId } = useParams<{ organizationId: string }>(); - let organizationName: string | null = null; - try { - organizationName = localStorage.getItem("organizationName"); - } catch { - organizationName = null; - } + const createModal = useModal(); + const settingsModal = useModal(); const deleteModal = useModal(); const [projectList, setProjectList] = useState([]); + const [projectToEdit, setProjectToEdit] = useState(null); const [projectToDelete, setProjectToDelete] = useState( null, ); - const handleClick = async () => { - createModal.open(); - }; + /* + * The breadcrumb's organization name. Fetched like the Global dashboard does, + * with the name the navigation already cached as the fallback while the request + * is in flight, so the crumb never flashes a bare id. + */ + const { data: organization } = useSWR( + organizationId ? `/organizations/${organizationId}` : null, + fetcher, + { revalidateOnFocus: false }, + ); + let cachedOrganizationName: string | null = null; + try { + cachedOrganizationName = localStorage.getItem("organizationName"); + } catch { + cachedOrganizationName = null; + } + const organizationName = + organization?.name || cachedOrganizationName || organizationId!; + + const { + data: projects, + error, + isLoading, + } = useSWR(`/projects?organization=${organizationId}`, fetcher); + + useEffect(() => { + if (projects) { + setProjectList(projects); + } + }, [projects]); const refreshProjectList = async () => { - const projects = await getProjects(organizationId!); - setProjectList(projects || []); + const refreshed = await getProjects(organizationId!); + setProjectList(refreshed || []); + }; + + const handleSettingsClick = (project: Project) => { + setProjectToEdit(project); + settingsModal.open(); }; const handleDeleteClick = (project: Project) => { @@ -56,22 +99,6 @@ export default function ProjectsPage() { } }; - const { - data: projects, - error, - isLoading, - } = useSWR( - `/projects?organization=${organizationId}`, - fetcher, - {}, - ); - - useEffect(() => { - if (projects) { - setProjectList(projects); - } - }, [projects]); - if (isLoading) { return ; } @@ -80,73 +107,110 @@ export default function ProjectsPage() { return ; } + const sortedProjects = [...projectList].sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()), + ); + const hasProjects = sortedProjects.length > 0; + + /* The same button in the design's two placements: on the heading row, and + centred in the empty state. */ + const addProjectButton = ( + + + Add a project + + ); + return ( -
- + {/* The parent crumb hovers to white rather than to the design's + button-hover green, which is the colour of the current crumb beside + it — hovering should not make a link look like the page you are + already on. */} + -
-
-

Projects

- - + + {/* The action joins the heading only when there are projects; the + empty state centres it instead. */} +
+

+ Projects +

+ {hasProjects && addProjectButton} +
+ + {hasProjects ? ( + + + {sortedProjects.map((project) => ( + handleSettingsClick(project)} + onDelete={() => handleDeleteClick(project)} + /> + ))} + +
+ ) : ( + /* + * The design gives the empty region a fixed 525px height purely to + * centre its contents in the frame; here it takes the space the + * column has left over and centres within that, with a minimum so + * it still reads as an empty area when the viewport is short. + */ +
+ {addProjectButton} +

+ You have no projects added yet... +

- - - - {projectList && - projectList - .sort((a, b) => - a.name - .toLowerCase() - .localeCompare( - b.name.toLowerCase(), - ), - ) - .map((project) => ( - - handleDeleteClick(project) - } - /> - ))} - -
-
- {projectToDelete && ( - - )} -
+ )} + + + + {/* + * Settings opens in place rather than navigating away. The project + * dashboard's own settings control already opens this same modal, so + * the row menu now behaves the way the rest of the app does, and acting + * on a row no longer costs you the list you were working in. The row + * already holds the whole project, so nothing is refetched. + */} + {projectToEdit && ( + + )} + + {projectToDelete && ( + + )}
); } diff --git a/webapp/src/pages/SettingsPage.tsx b/webapp/src/pages/SettingsPage.tsx new file mode 100644 index 000000000..d6c600bda --- /dev/null +++ b/webapp/src/pages/SettingsPage.tsx @@ -0,0 +1,118 @@ +import { useNavigate } from "react-router-dom"; +import useSWR from "swr"; + +import { User } from "@/api/schemas"; +import { fetcher } from "@/api/swr"; +import SettingsNavItem from "@/components/settings-nav-item"; +import { FormField } from "@/components/ui/form-field"; +import { PrimaryButton } from "@/components/ui/primary-button"; +import { AccountCircleIcon } from "@/components/icons/account-circle-icon"; +import { ArrowBackIosIcon } from "@/components/icons/arrow-back-ios-icon"; +import { LockIcon } from "@/components/icons/lock-icon"; + +const USER_PROFILE_URL = import.meta.env.VITE_OIDC_PROFILE_URL; + +/* + * The Settings page: a hugging sub-nav beside a bounded content column, both + * starting at the same top edge. + * + * The frame has no sidebar rail and carries its own "Go back" control, so this + * is a standalone page routed under AuthGuard rather than a child of + * DashboardLayout. + */ + +export default function SettingsPage() { + const navigate = useNavigate(); + + // The signed-in user's real email, from the endpoint AuthGuard already uses. + // Figma shows the field in its "Disabled" state with placeholder text; the + // value here is the actual address rather than that placeholder. + const { data: auth, isLoading } = useSWR<{ user?: User }>( + "/auth/check", + fetcher, + { revalidateOnFocus: false }, + ); + const email = auth?.user?.email; + + /* + * Email address and password are both owned by the OIDC provider — the API + * exposes no endpoint for either, and the previous navigation's "Profile" + * item already sent users there. So "Change" and "Password" hand off to it, + * and are disabled when no provider URL is configured. + */ + const goToProvider = () => { + if (USER_PROFILE_URL) window.location.href = USER_PROFILE_URL; + }; + + return ( +
+
+ {/* Sub-nav */} + + + {/* Content */} +
+

+ Profile +

+ +
+

+ Email +

+

+ Manage your email address to receive important + updates and notifications +

+ +
+ + + Change + +
+
+
+
+
+ ); +} diff --git a/webapp/src/router.tsx b/webapp/src/router.tsx index fcd852371..83a8b706e 100644 --- a/webapp/src/router.tsx +++ b/webapp/src/router.tsx @@ -11,7 +11,6 @@ const HomePage = lazy(() => import("./pages/HomePage")); const OrgDashboardPage = lazy(() => import("./pages/OrgDashboardPage")); const ProjectsPage = lazy(() => import("./pages/ProjectsPage")); const ProjectDashboardPage = lazy(() => import("./pages/ProjectDashboardPage")); -const ProjectSettingsPage = lazy(() => import("./pages/ProjectSettingsPage")); const MembersPage = lazy(() => import("./pages/MembersPage")); function SuspenseWrapper({ children }: { children: React.ReactNode }) { @@ -82,14 +81,6 @@ export const router = createBrowserRouter([ ), }, - { - path: "/:organizationId/projects/:projectId/settings", - element: ( - - - - ), - }, { path: "/:organizationId/members", element: ( diff --git a/webapp/src/utils/matomo.ts b/webapp/src/utils/matomo.ts new file mode 100644 index 000000000..9a784db98 --- /dev/null +++ b/webapp/src/utils/matomo.ts @@ -0,0 +1,82 @@ +// Matomo page-view tracking for the webapp. +// +// The webapp is a single-page app, so Matomo's stock snippet would only see +// the first load. We send the first page view ourselves, then one per +// pathname change by listening to the router. Query strings are left out of +// the tracked URL on purpose: they carry filter state, not distinct pages. +// +// Tracking stays off unless both env vars are set, so local dev and the mock +// build never pollute the production site. + +type MatomoCommand = (string | number)[]; + +interface TrackedRouter { + state: { location: { pathname: string } }; + subscribe( + listener: (state: { location: { pathname: string } }) => void, + ): () => void; +} + +declare global { + interface Window { + _paq?: MatomoCommand[]; + } +} + +let enabled = false; + +function push(command: MatomoCommand) { + (window._paq = window._paq || []).push(command); +} + +function trackPageView(pathname: string) { + push(["setCustomUrl", window.location.origin + pathname]); + push(["trackPageView"]); +} + +/** + * Record a custom event. A no-op until `initMatomo` has enabled tracking, so + * call sites never need to check the environment. Pass only categories and + * counts: nothing that identifies a person. + */ +export function trackEvent( + category: string, + action: string, + name?: string, + value?: number, +): void { + if (!enabled) return; + const command: MatomoCommand = ["trackEvent", category, action]; + if (name !== undefined || value !== undefined) command.push(name ?? ""); + if (value !== undefined) command.push(value); + push(command); +} + +export function initMatomo(router: TrackedRouter): void { + const baseUrl = import.meta.env.VITE_MATOMO_URL; + const siteId = import.meta.env.VITE_MATOMO_SITE_ID; + if (!baseUrl || !siteId) return; + enabled = true; + + const url = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + // Cookieless, so the dashboard needs no consent banner for analytics. + push(["disableCookies"]); + push(["enableLinkTracking"]); + push(["setTrackerUrl", `${url}matomo.php`]); + push(["setSiteId", siteId]); + + const script = document.createElement("script"); + script.async = true; + script.src = `${url}matomo.js`; + document.head.appendChild(script); + + let lastPathname = router.state.location.pathname; + trackPageView(lastPathname); + + router.subscribe((state) => { + const { pathname } = state.location; + if (pathname === lastPathname) return; + lastPathname = pathname; + trackPageView(pathname); + }); +} diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts index 8b1dd7979..d03395242 100644 --- a/webapp/src/vite-env.d.ts +++ b/webapp/src/vite-env.d.ts @@ -5,6 +5,8 @@ interface ImportMetaEnv { readonly VITE_BASE_URL: string; readonly VITE_USE_MOCK_DATA?: string; readonly VITE_OIDC_PROFILE_URL?: string; + readonly VITE_MATOMO_URL?: string; + readonly VITE_MATOMO_SITE_ID?: string; readonly VITE_PROJECT_ENCRYPTION_KEY?: string; // Legacy public-link decoder only. } diff --git a/webapp/tailwind.config.ts b/webapp/tailwind.config.ts index 31cf1a474..cb6410833 100644 --- a/webapp/tailwind.config.ts +++ b/webapp/tailwind.config.ts @@ -13,7 +13,60 @@ const config = { }, }, extend: { + spacing: { + /* + * Structural dimensions from the redesign, each defined once + * and only where it belongs. + * + * `rail` is the compact sidebar's own width, widened from the design's + * 101px so the "Organizations" label fits. `gauge` is the + * consumed-energy ring's diameter (Figma 199.23, normalised — + * the ring's proportions live in the SVG viewBox, so the + * rendered size is free). `heading` is the gap between a + * section heading and its content (Figma 46px, which is off + * the default scale but consistent across both sections). + */ + rail: "101px", + gauge: "200px", + heading: "46px", + /* Account menu panel width and row/field control height. */ + control: "46px", + }, + boxShadow: { + /* Figma 218:14541 — account menu panel. */ + menu: "0 4px 10px rgba(0, 0, 0, 0.25)", + /* Figma 218:11927 — modal panel. */ + dialog: "0 4px 20px rgba(0, 0, 0, 0.25)", + }, + fontFamily: { + // IBM Plex Mono. `font-mono` is already applied on . + mono: ["var(--font-mono)"], + // Disket Mono Bold — the design's display face, self-hosted + // from public/fonts. See the note in globals.css. + display: ["var(--font-display)"], + }, colors: { + /* + * Figma variables from frame 218:7838, added additively. The + * existing shadcn tokens below are untouched: `--primary` + * already resolves to #BFFB4F and `--background` to #181818, + * so those two are reused rather than duplicated in markup. + */ + cc: { + background: "var(--cc-background)", + "page-background": "var(--cc-page-background)", + lime: "var(--cc-lime)", + white: "var(--cc-white)", + "dark-gray": "var(--cc-dark-gray)", + "darkest-gray": "var(--cc-darkest-gray)", + gray: "var(--cc-gray)", + "button-hover": "var(--cc-button-hover)", + "text-input-gray": "var(--cc-text-input-gray)", + "breadcrumb-gray": "var(--cc-breadcrumb-gray)", + rule: "var(--cc-rule)", + "gauge-track": "var(--cc-gauge-track)", + "gauge-label": "var(--cc-gauge-label)", + }, border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", @@ -49,6 +102,9 @@ const config = { }, }, borderRadius: { + /* Design-specific radii from the Figma frame. */ + menu: "4px", + field: "2px", lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", diff --git a/webapp/tests/api/auth.test.ts b/webapp/tests/api/auth.test.ts index cd4f1bca8..f27d3fdbc 100644 --- a/webapp/tests/api/auth.test.ts +++ b/webapp/tests/api/auth.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { buildLoginUrl, redirectToLogin } from "@/api/auth"; +import { + buildAccountUrl, + buildLoginUrl, + buildLogoutUrl, + redirectToAccount, + redirectToLogin, + redirectToLogout, +} from "@/api/auth"; afterEach(() => { vi.unstubAllEnvs(); @@ -39,3 +46,40 @@ describe("redirectToLogin", () => { expect(assign).not.toHaveBeenCalled(); }); }); + +describe.each([ + ["buildLogoutUrl", buildLogoutUrl, "/auth/logout", redirectToLogout], + ["buildAccountUrl", buildAccountUrl, "/auth/account", redirectToAccount], +])("%s", (_name, build, path, redirect) => { + it("constructs the URL from VITE_API_URL", () => { + expect(build()).toBe(`http://api.test/api${path}`); + }); + + it("tolerates a trailing slash on VITE_API_URL", () => { + vi.stubEnv("VITE_API_URL", "http://api.test/api/"); + expect(build()).toBe(`http://api.test/api${path}`); + }); + + it("returns null and warns when VITE_API_URL is missing", () => { + vi.stubEnv("VITE_API_URL", ""); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(build()).toBeNull(); + expect(warn).toHaveBeenCalled(); + }); + + it("navigates to the URL, and is a no-op without API config", () => { + const assign = vi.fn(); + Object.defineProperty(window, "location", { + writable: true, + value: { ...window.location, assign }, + }); + redirect(); + expect(assign).toHaveBeenCalledWith(`http://api.test/api${path}`); + + assign.mockClear(); + vi.stubEnv("VITE_API_URL", ""); + vi.spyOn(console, "warn").mockImplementation(() => {}); + redirect(); + expect(assign).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/tests/api/mock/handlers.test.ts b/webapp/tests/api/mock/handlers.test.ts index 6b46e1171..00031861b 100644 --- a/webapp/tests/api/mock/handlers.test.ts +++ b/webapp/tests/api/mock/handlers.test.ts @@ -48,6 +48,66 @@ describe("resolveMock — organizations", () => { expect((r.body as { name: string }).name).toBe("Mock Organization"); }); + /* + * The real endpoint filters the emissions table by timestamp; these pin the + * mock to the same behaviour so the dashboard's date picker can be exercised + * — and regressions in date handling caught — without a live backend. + */ + describe("org sums honour the date range", () => { + type Report = { + emissions: number; + energy_consumed: number; + duration: number; + }; + const sums = (qs = ""): Report => + resolveMock(url(`/organizations/${ID.org}/sums${qs}`), "GET") + .body as Report; + const iso = (daysAgo: number) => + new Date(Date.now() - daysAgo * 86_400_000).toISOString(); + + it("aggregates every emission when no range is given", () => { + const all = sums(); + // 12 + 12 + 6 sampled rows across the fixture's three runs. + expect(all.duration).toBe(30 * 5 * 60); + expect(all.emissions).toBeGreaterThan(0); + expect(all.energy_consumed).toBeGreaterThan(0); + }); + + it("excludes runs outside the range", () => { + const all = sums(); + // Fixtures sit at now-20d (x2) and now-5d; a 7-day window keeps only + // the most recent one, which has 6 of the 30 rows. + const week = sums(`?start_date=${iso(7)}&end_date=${iso(0)}`); + expect(week.duration).toBe(6 * 5 * 60); + expect(week.emissions).toBeLessThan(all.emissions); + expect(week.emissions).toBeGreaterThan(0); + }); + + it("returns zeros for a range containing nothing", () => { + const empty = sums(`?start_date=${iso(400)}&end_date=${iso(390)}`); + expect(empty).toMatchObject({ + emissions: 0, + energy_consumed: 0, + duration: 0, + }); + }); + + it("widening the range can only add emissions", () => { + const week = sums(`?start_date=${iso(7)}&end_date=${iso(0)}`); + const month = sums(`?start_date=${iso(30)}&end_date=${iso(0)}`); + expect(month.emissions).toBeGreaterThan(week.emissions); + expect(month.duration).toBeGreaterThan(week.duration); + }); + + it("ignores an unparseable date rather than failing", () => { + const r = resolveMock( + url(`/organizations/${ID.org}/sums?start_date=not-a-date`), + "GET", + ); + expect(r.status).toBe(200); + }); + }); + it("synthesizes an added user on POST /add-user", () => { const r = resolveMock( url(`/organizations/${ID.org}/add-user`), diff --git a/webapp/tests/components/account-menu.test.tsx b/webapp/tests/components/account-menu.test.tsx new file mode 100644 index 000000000..d45ceb24d --- /dev/null +++ b/webapp/tests/components/account-menu.test.tsx @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const redirectToAccountMock = vi.hoisted(() => vi.fn()); +const redirectToLogoutMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/auth", () => ({ + redirectToAccount: redirectToAccountMock, + redirectToLogout: redirectToLogoutMock, +})); + +import AccountMenu from "@/components/account-menu"; +import { renderWithRouter } from "../test-utils"; + +function renderMenu() { + renderWithRouter( + + + , + ); +} + +beforeEach(() => { + redirectToAccountMock.mockReset(); + redirectToLogoutMock.mockReset(); +}); + +describe("AccountMenu", () => { + it("offers only the account actions once opened", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + + expect( + await screen.findByRole("menuitem", { name: "Settings" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Log out" }), + ).toBeInTheDocument(); + expect(screen.getAllByRole("menuitem")).toHaveLength(2); + }); + + it("leaves for the provider's account console from its own row", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Settings" }), + ); + await waitFor(() => expect(redirectToAccountMock).toHaveBeenCalled()); + }); + + it("logs out through the API from its own row", async () => { + renderMenu(); + await userEvent.click(screen.getByRole("button", { name: "Account" })); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Log out" }), + ); + await waitFor(() => expect(redirectToLogoutMock).toHaveBeenCalled()); + }); +}); diff --git a/webapp/tests/components/breadcrumb.test.tsx b/webapp/tests/components/breadcrumb.test.tsx index 6eac8ae3e..be9839c1a 100644 --- a/webapp/tests/components/breadcrumb.test.tsx +++ b/webapp/tests/components/breadcrumb.test.tsx @@ -1,27 +1,57 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import BreadcrumbHeader from "@/components/breadcrumb"; +import { describe, expect, it } from "vitest"; +import { screen, within } from "@testing-library/react"; + +import { Breadcrumb } from "@/components/ui/breadcrumb"; import { renderWithRouter } from "../test-utils"; -describe("BreadcrumbHeader", () => { - it("renders all segments and links the ones with hrefs", () => { +describe("Breadcrumb", () => { + it("links every crumb that has a destination", () => { renderWithRouter( - , ); - const orgLink = screen.getByRole("link", { name: /org/i }); - expect(orgLink).toHaveAttribute("href", "/org-1"); - expect(screen.getByRole("link", { name: /projects/i })).toHaveAttribute( - "href", - "/org-1/projects", + const nav = screen.getByRole("navigation", { name: "Breadcrumb" }); + expect( + within(nav).getByRole("link", { name: "Mozilla" }), + ).toHaveAttribute("href", "/o1"); + expect( + within(nav).getByRole("link", { name: "Projects" }), + ).toHaveAttribute("href", "/o1/projects"); + expect(within(nav).queryByRole("link", { name: "Bench" })).toBeNull(); + }); + + /* + * The organisation dashboard's first crumb has nowhere to go either, so + * "not a link" and "the page you are on" have to stay separate: only the + * last crumb is current, and only it is green. + */ + it("marks the last crumb as the current page, not every unlinked one", () => { + renderWithRouter( + , + ); + + const current = screen.getAllByText( + (_, element) => element?.getAttribute("aria-current") === "page", ); - // Last segment (no href) is plain text, not a link. - expect(screen.getByText("Project A").tagName).toBe("SPAN"); + expect(current).toHaveLength(1); + expect(current[0]).toHaveTextContent("Global"); + }); + + it("separates crumbs with a spaced slash", () => { + renderWithRouter( + , + ); + + expect( + screen.getByRole("navigation", { name: "Breadcrumb" }), + ).toHaveTextContent("Mozilla / Members"); }); }); diff --git a/webapp/tests/components/consumed-energy-gauge.test.tsx b/webapp/tests/components/consumed-energy-gauge.test.tsx new file mode 100644 index 000000000..91ba6e940 --- /dev/null +++ b/webapp/tests/components/consumed-energy-gauge.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import ConsumedEnergyGauge from "@/components/consumed-energy-gauge"; + +/* + * The arc stands for "there is something here", so an empty range must not draw + * it — otherwise a zero gauge looks like some amount. + */ +describe("ConsumedEnergyGauge", () => { + it("draws the arc when there is a value", () => { + const { container } = render( + , + ); + expect(container.querySelector("path")).not.toBeNull(); + }); + + it("draws no arc at zero", () => { + const { container } = render( + , + ); + expect(container.querySelector("path")).toBeNull(); + // The track and the figures stay. + expect(container.querySelector("circle")).not.toBeNull(); + expect(screen.getByText("0")).toBeInTheDocument(); + }); + + it("names itself with its value and unit", () => { + render(); + expect(screen.getByRole("img", { name: "3 days" })).toBeInTheDocument(); + }); +}); diff --git a/webapp/tests/components/create-organization-modal.test.tsx b/webapp/tests/components/create-organization-modal.test.tsx new file mode 100644 index 000000000..345d72502 --- /dev/null +++ b/webapp/tests/components/create-organization-modal.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const createOrganizationMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/organizations", () => ({ + createOrganization: createOrganizationMock, +})); + +import CreateOrganizationModal from "@/components/create-organization-modal"; +import { renderWithRouter } from "../test-utils"; + +beforeEach(() => { + navigateMock.mockReset(); + createOrganizationMock.mockReset(); + createOrganizationMock.mockResolvedValue({ + id: "new-org", + name: "Acme", + description: "", + }); +}); + +describe("CreateOrganizationModal", () => { + it("keeps the submit action disabled until a name is entered", async () => { + renderWithRouter( + , + ); + + const submit = screen.getByRole("button", { + name: /^create organization$/i, + }); + expect(submit).toBeDisabled(); + + await userEvent.type(screen.getByLabelText(/^name$/i), "Acme"); + expect(submit).toBeEnabled(); + }); + + it("submits name + description and navigates to the new organization", async () => { + const onOrganizationCreated = vi.fn().mockResolvedValue(undefined); + + renderWithRouter( + , + ); + + // The redesign labels the fields "Name" and "Description", and the + // action "Create organization". + await userEvent.type(screen.getByLabelText(/^name$/i), "Acme"); + await userEvent.type( + screen.getByLabelText(/^description$/i), + "Some desc", + ); + + await userEvent.click( + screen.getByRole("button", { name: /^create organization$/i }), + ); + + // toast.promise resolves the inner thunk asynchronously. + await vi.waitFor(() => + expect(createOrganizationMock).toHaveBeenCalledWith({ + name: "Acme", + description: "Some desc", + }), + ); + await vi.waitFor(() => + expect(navigateMock).toHaveBeenCalledWith("/new-org"), + ); + expect(onOrganizationCreated).toHaveBeenCalled(); + }); +}); diff --git a/webapp/tests/components/createProjectModal.test.tsx b/webapp/tests/components/create-project-modal.test.tsx similarity index 59% rename from webapp/tests/components/createProjectModal.test.tsx rename to webapp/tests/components/create-project-modal.test.tsx index f6601a64a..7e5a56f51 100644 --- a/webapp/tests/components/createProjectModal.test.tsx +++ b/webapp/tests/components/create-project-modal.test.tsx @@ -7,7 +7,7 @@ vi.mock("@/api/projects", () => ({ createProject: createProjectMock, })); -import CreateProjectModal from "@/components/createProjectModal"; +import CreateProjectModal from "@/components/create-project-modal"; beforeEach(() => { createProjectMock.mockReset(); @@ -22,6 +22,25 @@ beforeEach(() => { }); describe("CreateProjectModal", () => { + it("keeps the submit action disabled until a name is entered", async () => { + render( + , + ); + + const submit = screen.getByRole("button", { + name: /^create project$/i, + }); + expect(submit).toBeDisabled(); + + await userEvent.type(screen.getByLabelText(/^name$/i), "New project"); + expect(submit).toBeEnabled(); + }); + it("submits the form with name + description and the parent org id", async () => { const onProjectCreated = vi.fn().mockResolvedValue(undefined); const onClose = vi.fn(); @@ -35,17 +54,16 @@ describe("CreateProjectModal", () => { />, ); + // The redesign labels the fields "Name" and "Description", and the + // action "Create project". + await userEvent.type(screen.getByLabelText(/^name$/i), "New project"); await userEvent.type( - screen.getByPlaceholderText(/project name/i), - "New project", - ); - await userEvent.type( - screen.getByPlaceholderText(/project description/i), + screen.getByLabelText(/^description$/i), "Some desc", ); await userEvent.click( - screen.getByRole("button", { name: /^create$/i }), + screen.getByRole("button", { name: /^create project$/i }), ); // toast.promise resolves the inner thunk asynchronously. diff --git a/webapp/tests/components/createOrganizationModal.test.tsx b/webapp/tests/components/createOrganizationModal.test.tsx deleted file mode 100644 index 8da71ea8b..000000000 --- a/webapp/tests/components/createOrganizationModal.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -const navigateMock = vi.hoisted(() => vi.fn()); -vi.mock("react-router-dom", async () => { - const actual = - await vi.importActual( - "react-router-dom", - ); - return { ...actual, useNavigate: () => navigateMock }; -}); - -const createOrganizationMock = vi.hoisted(() => vi.fn()); -vi.mock("@/api/organizations", () => ({ - createOrganization: createOrganizationMock, -})); - -const toastErrorMock = vi.hoisted(() => vi.fn()); -vi.mock("sonner", () => ({ - toast: { - promise: vi.fn((p) => ({ unwrap: () => p })), - error: toastErrorMock, - success: vi.fn(), - }, -})); - -import CreateOrganizationModal from "@/components/createOrganizationModal"; -import { renderWithRouter } from "../test-utils"; - -beforeEach(() => { - navigateMock.mockReset(); - createOrganizationMock.mockReset(); - toastErrorMock.mockReset(); -}); - -describe("CreateOrganizationModal", () => { - it("rejects an empty name with a toast error", async () => { - const onClose = vi.fn(); - const onOrganizationCreated = vi.fn().mockResolvedValue(undefined); - - renderWithRouter( - , - ); - - await userEvent.click( - screen.getByRole("button", { name: /^create$/i }), - ); - - expect(toastErrorMock).toHaveBeenCalledWith( - "Organization name is required", - ); - expect(createOrganizationMock).not.toHaveBeenCalled(); - }); - - it("creates an organization and navigates to its dashboard", async () => { - createOrganizationMock.mockResolvedValue({ - id: "new-org", - name: "Acme", - description: "", - }); - const onOrganizationCreated = vi.fn().mockResolvedValue(undefined); - - renderWithRouter( - {}} - onOrganizationCreated={onOrganizationCreated} - />, - ); - - await userEvent.type( - screen.getByPlaceholderText(/organization name/i), - "Acme", - ); - await userEvent.click( - screen.getByRole("button", { name: /^create$/i }), - ); - - await vi.waitFor(() => - expect(navigateMock).toHaveBeenCalledWith("/new-org"), - ); - }); -}); diff --git a/webapp/tests/components/equivalence-list.test.tsx b/webapp/tests/components/equivalence-list.test.tsx new file mode 100644 index 000000000..c7d06d63e --- /dev/null +++ b/webapp/tests/components/equivalence-list.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import EquivalenceList, { equivalences } from "@/components/equivalence-list"; + +/* + * Both dashboards render these from one builder, so the captions and units + * cannot drift apart again — they did once, and one page showed kilometres with + * no unit at all. + */ +describe("equivalences", () => { + it("carries the unit on every figure", () => { + const [citizen, transport, tv] = equivalences({ + citizen: "1.50", + transportation: "42.00", + tvTime: "7.00", + }); + + expect(citizen.value).toBe("1.50%"); + expect(transport.value).toBe("42.00 km"); + expect(tv.value).toBe("7.00 days"); + }); + + it("describes the first figure as a citizen's emissions, which is what it is", () => { + const [citizen] = equivalences({ + citizen: "1", + transportation: "1", + tvTime: "1", + }); + expect(citizen.caption).toMatch(/citizen/i); + expect(citizen.caption).toMatch(/emissions/i); + }); +}); + +describe("EquivalenceList", () => { + it("renders a figure and caption per item", () => { + render( + , + ); + + expect(screen.getByText("1.50%")).toBeInTheDocument(); + expect(screen.getByText("42.00 km")).toBeInTheDocument(); + expect(screen.getByText("7.00 days")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + }); +}); diff --git a/webapp/tests/components/nav-item.test.tsx b/webapp/tests/components/nav-item.test.tsx deleted file mode 100644 index f3d57ddfb..000000000 --- a/webapp/tests/components/nav-item.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import NavItem from "@/components/nav-item"; - -describe("NavItem", () => { - it("renders children and fires onClick", async () => { - const onClick = vi.fn(); - render( - - Dashboard - , - ); - - const button = screen.getByRole("button", { name: /dashboard/i }); - await userEvent.click(button); - expect(onClick).toHaveBeenCalledOnce(); - }); - - it("applies the selected styling when isSelected is true", () => { - render(Home); - const button = screen.getByRole("button", { name: /home/i }); - expect(button.className).toContain("text-primary"); - }); -}); diff --git a/webapp/tests/components/navbar.test.tsx b/webapp/tests/components/navbar.test.tsx deleted file mode 100644 index e44c66ef8..000000000 --- a/webapp/tests/components/navbar.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -const navigateMock = vi.hoisted(() => vi.fn()); -vi.mock("react-router-dom", async () => { - const actual = - await vi.importActual( - "react-router-dom", - ); - return { - ...actual, - useNavigate: () => navigateMock, - useLocation: () => ({ pathname: "/o1" }), - }; -}); - -vi.mock("@/api/organizations", () => ({ - getOrganizations: vi.fn().mockResolvedValue([]), -})); - -import NavBar from "@/components/navbar"; -import { renderWithRouter } from "../test-utils"; - -beforeEach(() => { - navigateMock.mockReset(); -}); - -const orgs = [ - { id: "o1", name: "Acme", description: "" }, - { id: "o2", name: "Beta", description: "" }, -]; - -describe("NavBar", () => { - it("renders the primary nav items", () => { - renderWithRouter(); - expect( - screen.getByRole("button", { name: /home/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /projects/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /members/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /log out/i }), - ).toBeInTheDocument(); - }); - - it("navigates to the organization root when Home is clicked", async () => { - renderWithRouter(); - await userEvent.click(screen.getByRole("button", { name: /home/i })); - expect(navigateMock).toHaveBeenCalledWith("/o1"); - }); - - it("navigates to //projects when Projects is clicked", async () => { - renderWithRouter(); - await userEvent.click( - screen.getByRole("button", { name: /projects/i }), - ); - expect(navigateMock).toHaveBeenCalledWith("/o1/projects"); - }); -}); diff --git a/webapp/tests/components/organizations-menu.test.tsx b/webapp/tests/components/organizations-menu.test.tsx new file mode 100644 index 000000000..7110b9bb5 --- /dev/null +++ b/webapp/tests/components/organizations-menu.test.tsx @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const fetcherMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/swr", () => ({ fetcher: fetcherMock, swrConfig: {} })); +vi.mock("@/api/organizations", () => ({ getOrganizations: vi.fn() })); + +import OrganizationsMenu from "@/components/organizations-menu"; +import { renderWithRouter } from "../test-utils"; +import { SWRConfig } from "swr"; + +const orgs = [ + { id: "o1", name: "Mozilla", description: "" }, + { id: "o2", name: "Wecasa", description: "" }, +]; + +/* + * `o1` is administered by the signed-in user and `o2` is not, which is the split + * the menu draws: administered dashboards below the rule, invited ones above it. + */ +function mockApi() { + fetcherMock.mockImplementation((key: string) => { + if (key === "/auth/check") + return Promise.resolve({ user: { id: "u1" } }); + if (key === "/organizations/o1/users") + return Promise.resolve([{ id: "u1", is_admin: true }]); + if (key === "/organizations/o2/users") + return Promise.resolve([{ id: "u1", is_admin: false }]); + return Promise.resolve([]); + }); +} + +function renderMenu() { + const onSelectOrg = vi.fn(); + renderWithRouter( + new Map(), dedupingInterval: 0 }}> + + + + , + ); + return { onSelectOrg }; +} + +beforeEach(() => { + fetcherMock.mockReset(); + navigateMock.mockReset(); + mockApi(); +}); + +describe("OrganizationsMenu", () => { + it("lists the dashboards once opened", async () => { + renderMenu(); + await userEvent.click( + screen.getByRole("button", { name: "Organizations" }), + ); + + expect( + await screen.findByRole("menuitem", { name: "Mozilla" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Wecasa" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /add new organization/i }), + ).toBeInTheDocument(); + }); + + it("marks the dashboard being viewed", async () => { + renderMenu(); + await userEvent.click( + screen.getByRole("button", { name: "Organizations" }), + ); + + expect( + await screen.findByRole("menuitem", { name: "Mozilla" }), + ).toHaveAttribute("aria-current", "true"); + expect( + screen.getByRole("menuitem", { name: "Wecasa" }), + ).not.toHaveAttribute("aria-current"); + }); + + it("separates administered dashboards from invited ones", async () => { + renderMenu(); + await userEvent.click( + screen.getByRole("button", { name: "Organizations" }), + ); + + // The heading only appears when there is something invited to list. + expect( + await screen.findByText(/dashboards you've been invited to/i), + ).toBeInTheDocument(); + + const items = screen + .getAllByRole("menuitem") + .map((item) => item.textContent); + // Invited first, administered after the rule. + expect(items.indexOf("Wecasa")).toBeLessThan(items.indexOf("Mozilla")); + }); + + it("keeps that split after the menu is closed and reopened", async () => { + renderMenu(); + const trigger = screen.getByRole("button", { name: "Organizations" }); + + await userEvent.click(trigger); + await screen.findByText(/dashboards you've been invited to/i); + await userEvent.keyboard("{Escape}"); + await userEvent.click(trigger); + + // Regression: keying the admin lookup on `open` dropped the result here, + // and every dashboard fell back into the invited group. + const items = screen + .getAllByRole("menuitem") + .map((item) => item.textContent); + expect(items.indexOf("Wecasa")).toBeLessThan(items.indexOf("Mozilla")); + }); + + it("switches dashboard when one is picked", async () => { + const { onSelectOrg } = renderMenu(); + await userEvent.click( + screen.getByRole("button", { name: "Organizations" }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Wecasa" }), + ); + expect(onSelectOrg).toHaveBeenCalledWith("o2"); + }); +}); diff --git a/webapp/tests/components/project-actions.test.tsx b/webapp/tests/components/project-actions.test.tsx new file mode 100644 index 000000000..9ecd15941 --- /dev/null +++ b/webapp/tests/components/project-actions.test.tsx @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +vi.mock("@/api/projects", () => ({ updateProject: vi.fn() })); +vi.mock("@/api/runs", () => ({ + getEmissionsTimeSeries: vi.fn(), + getRunEmissionsByExperiment: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/components/projectTokens/projectTokenTable", () => ({ + ProjectTokensTable: () =>
, +})); + +const exportToJsonMock = vi.hoisted(() => vi.fn()); +vi.mock("@/utils/export", () => ({ exportToJson: exportToJsonMock })); + +import ProjectActions from "@/components/project-actions"; + +const project = { + id: "p1", + name: "Pipeline A", + description: "Nightly run", + public: false, + organizationId: "o1", + experiments: [], +}; + +const runData = { + experimentId: "e1", + startDate: "2024-01-01", + endDate: "2024-02-01", +}; + +beforeEach(() => { + exportToJsonMock.mockReset(); +}); + +function renderActions() { + const onRefresh = vi.fn(); + render( + , + ); + return { onRefresh }; +} + +describe("ProjectActions", () => { + it("offers the project's actions, each named", () => { + renderActions(); + expect( + screen.getByRole("button", { name: /refresh data/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /download json export/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /project settings/i }), + ).toBeInTheDocument(); + }); + + it("hides the share control while the project is private", () => { + renderActions(); + expect( + screen.queryByRole("button", { name: /share project/i }), + ).not.toBeInTheDocument(); + }); + + it("refreshes when asked", async () => { + const { onRefresh } = renderActions(); + await userEvent.click( + screen.getByRole("button", { name: /refresh data/i }), + ); + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()); + }); + + it("exports the project as JSON", async () => { + renderActions(); + await userEvent.click( + screen.getByRole("button", { name: /download json export/i }), + ); + await waitFor(() => expect(exportToJsonMock).toHaveBeenCalledOnce()); + + const [payload] = exportToJsonMock.mock.calls[0]; + expect(payload.projects[0].id).toBe("p1"); + expect(payload.projects[0].date_range).toEqual({ + startDate: runData.startDate, + endDate: runData.endDate, + }); + }); + + it("opens project settings in place", async () => { + renderActions(); + await userEvent.click( + screen.getByRole("button", { name: /project settings/i }), + ); + expect( + await screen.findByRole("heading", { name: /project settings/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/webapp/tests/components/project-row.test.tsx b/webapp/tests/components/project-row.test.tsx new file mode 100644 index 000000000..98134d59d --- /dev/null +++ b/webapp/tests/components/project-row.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import ProjectRow from "@/components/project-row"; +import { Table, TableBody } from "@/components/ui/table"; +import { renderWithRouter } from "../test-utils"; + +const project = { + id: "p1", + name: "Pipeline A", + description: "Nightly training run", + public: false, + organizationId: "o1", + experiments: [], +}; + +function renderRow(overrides: Partial[0]> = {}) { + const onSettings = vi.fn(); + const onDelete = vi.fn(); + renderWithRouter( + + + + +
, + ); + return { onSettings, onDelete }; +} + +describe("ProjectRow", () => { + it("links both the name and the secondary text to the project", () => { + renderRow(); + const links = screen.getAllByRole("link"); + expect(links).toHaveLength(2); + links.forEach((link) => + expect(link).toHaveAttribute("href", "/o1/projects/p1"), + ); + }); + + it("omits the secondary link when there is no description", () => { + renderRow({ project: { ...project, description: "" } }); + expect(screen.getAllByRole("link")).toHaveLength(1); + }); + + it("names its overflow trigger after the project", () => { + renderRow(); + expect( + screen.getByRole("button", { name: /actions for pipeline a/i }), + ).toBeInTheDocument(); + }); + + it("calls back when a menu action is picked", async () => { + const { onSettings, onDelete } = renderRow(); + + await userEvent.click( + screen.getByRole("button", { name: /actions for pipeline a/i }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: /settings/i }), + ); + expect(onSettings).toHaveBeenCalledOnce(); + expect(onDelete).not.toHaveBeenCalled(); + + await userEvent.click( + screen.getByRole("button", { name: /actions for pipeline a/i }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: /delete/i }), + ); + expect(onDelete).toHaveBeenCalledOnce(); + }); +}); diff --git a/webapp/tests/components/project-settings-modal.test.tsx b/webapp/tests/components/project-settings-modal.test.tsx new file mode 100644 index 000000000..e22c131ac --- /dev/null +++ b/webapp/tests/components/project-settings-modal.test.tsx @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const updateProjectMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/projects", () => ({ updateProject: updateProjectMock })); + +// The token table fetches on mount and is not what these tests are about. +vi.mock("@/components/projectTokens/projectTokenTable", () => ({ + ProjectTokensTable: () =>
, +})); + +import ProjectSettingsModal from "@/components/project-settings-modal"; + +const project = { + id: "p1", + name: "Pipeline A", + description: "Nightly training run", + public: false, + organizationId: "o1", + experiments: [], +}; + +beforeEach(() => { + updateProjectMock.mockReset(); + updateProjectMock.mockResolvedValue(project); +}); + +function renderModal(overrides: Record = {}) { + const onOpenChange = vi.fn(); + const onProjectUpdated = vi.fn(); + const { rerender } = render( + , + ); + return { onOpenChange, onProjectUpdated, rerender }; +} + +describe("ProjectSettingsModal", () => { + it("does not save the visibility toggle until the form is submitted", async () => { + renderModal(); + + expect( + screen.queryByRole("button", { name: /copy link/i }), + ).not.toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("switch", { name: /make project public/i }), + ); + expect(updateProjectMock).not.toHaveBeenCalled(); + expect( + screen.queryByRole("button", { name: /copy link/i }), + ).not.toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => + expect(updateProjectMock).toHaveBeenCalledWith("p1", { + name: project.name, + description: project.description, + public: true, + }), + ); + }); + + it("shows and hides the sharing link with each save, not with the toggle", async () => { + renderModal(); + + await userEvent.click( + screen.getByRole("switch", { name: /make project public/i }), + ); + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + expect( + await screen.findByRole("button", { name: /copy link/i }), + ).toBeInTheDocument(); + + // The parent may still be holding the project it opened the dialog + // with, so the link has to follow what was saved here, not the prop. + await userEvent.click( + screen.getByRole("switch", { name: /make project public/i }), + ); + expect( + screen.getByRole("button", { name: /copy link/i }), + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + await waitFor(() => + expect( + screen.queryByRole("button", { name: /copy link/i }), + ).not.toBeInTheDocument(), + ); + }); + + it("stays open after a successful save", async () => { + const { onOpenChange, onProjectUpdated } = renderModal(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => expect(onProjectUpdated).toHaveBeenCalled()); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + + it("stays open when the save fails, so the edits survive", async () => { + updateProjectMock.mockRejectedValue(new Error("nope")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const { onOpenChange } = renderModal(); + + await userEvent.click( + screen.getByRole("button", { name: /save changes/i }), + ); + + await waitFor(() => expect(updateProjectMock).toHaveBeenCalled()); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + + it("opens on General, whichever tab it was left on", async () => { + const { rerender } = render( + , + ); + + await userEvent.click(screen.getByRole("tab", { name: /api tokens/i })); + expect( + screen.getByRole("tab", { name: /api tokens/i }), + ).toHaveAttribute("aria-selected", "true"); + + // Closed and opened again: the dialog stays mounted between openings. + rerender( + , + ); + rerender( + , + ); + + expect(screen.getByRole("tab", { name: /general/i })).toHaveAttribute( + "aria-selected", + "true", + ); + }); + + it("disables the save action until the project has a name", async () => { + renderModal(); + + const save = screen.getByRole("button", { name: /save changes/i }); + expect(save).toBeEnabled(); + + await userEvent.clear(screen.getByLabelText(/^name$/i)); + expect(save).toBeDisabled(); + }); +}); diff --git a/webapp/tests/components/project-visibility-badge.test.tsx b/webapp/tests/components/project-visibility-badge.test.tsx new file mode 100644 index 000000000..b757373b3 --- /dev/null +++ b/webapp/tests/components/project-visibility-badge.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import ProjectVisibilityBadge from "@/components/project-visibility-badge"; + +describe("ProjectVisibilityBadge", () => { + it("says Public when the project is public", () => { + render(); + expect(screen.getByText("Public")).toBeInTheDocument(); + }); + + it("says Private otherwise", () => { + render(); + expect(screen.getByText("Private")).toBeInTheDocument(); + }); +}); diff --git a/webapp/tests/components/projectTokens/projectTokenTable.test.tsx b/webapp/tests/components/projectTokens/projectTokenTable.test.tsx index 5732e69f0..ba71b1fff 100644 --- a/webapp/tests/components/projectTokens/projectTokenTable.test.tsx +++ b/webapp/tests/components/projectTokens/projectTokenTable.test.tsx @@ -32,7 +32,7 @@ describe("ProjectTokensTable", () => { renderWithRouter(); await userEvent.click( await screen.findByRole("button", { - name: /\+ create a new token/i, + name: /create a new token/i, }), ); expect(screen.getByPlaceholderText(/token name/i)).toBeInTheDocument(); diff --git a/webapp/tests/components/radial-chart.test.tsx b/webapp/tests/components/radial-chart.test.tsx deleted file mode 100644 index 357b645d5..000000000 --- a/webapp/tests/components/radial-chart.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; - -class NoopResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -} -// @ts-expect-error -- jsdom doesn't have it -globalThis.ResizeObserver = NoopResizeObserver; - -import RadialChart from "@/components/radial-chart"; - -describe("RadialChart", () => { - it("falls back to 'No data available' when value is undefined", () => { - render( - // @ts-expect-error -- testing the missing-value branch - , - ); - expect(screen.getByText(/no data available/i)).toBeInTheDocument(); - }); - - it("does not show the empty-state when value is defined", () => { - render(); - // The fallback is hidden when a value is provided. We don't assert - // on Recharts SVG output because jsdom doesn't measure layout, so - // the chart never reaches the rendered phase here. - expect( - screen.queryByText(/no data available/i), - ).not.toBeInTheDocument(); - }); -}); diff --git a/webapp/tests/components/remove-member-modal.test.tsx b/webapp/tests/components/remove-member-modal.test.tsx index 260fe7096..8ac957f8c 100644 --- a/webapp/tests/components/remove-member-modal.test.tsx +++ b/webapp/tests/components/remove-member-modal.test.tsx @@ -1,45 +1,59 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import RemoveMemberModal from "@/components/remove-member-modal"; -beforeEach(() => { - vi.restoreAllMocks(); -}); - -function renderModal(overrides: Partial<{ onRemove: () => Promise }>) { - const onRemove = overrides.onRemove ?? vi.fn().mockResolvedValue(undefined); +function renderModal( + overrides: Partial<{ isRemoving: boolean; isOpen: boolean }> = {}, +) { + const onConfirm = vi.fn(); + const onClose = vi.fn(); render( {}} + isOpen={overrides.isOpen ?? true} + onClose={onClose} + onConfirm={onConfirm} memberName="Alice" - memberId="u1" organizationName="Acme" - onRemove={onRemove} + isRemoving={overrides.isRemoving ?? false} />, ); - return onRemove; + return { onConfirm, onClose }; } describe("RemoveMemberModal", () => { it("names the member and the organization being left", () => { - renderModal({}); - expect(screen.getByText("Alice")).toBeInTheDocument(); - expect(screen.getByText(/Acme/)).toBeInTheDocument(); + renderModal(); + expect( + screen.getByText(/Remove Alice from Acme\?/), + ).toBeInTheDocument(); + }); + + it("renders nothing while closed", () => { + renderModal({ isOpen: false }); + expect(screen.queryByText(/Remove Alice/)).not.toBeInTheDocument(); }); - it("calls onRemove with the member id when confirmed", async () => { - const onRemove = renderModal({}); + it("calls onConfirm when confirmed", async () => { + const { onConfirm } = renderModal(); await userEvent.click( screen.getByRole("button", { name: /remove member/i }), ); - expect(onRemove).toHaveBeenCalledWith("u1"); + expect(onConfirm).toHaveBeenCalledTimes(1); }); - it("does not call onRemove when cancelled", async () => { - const onRemove = renderModal({}); + it("does not confirm when cancelled", async () => { + const { onConfirm, onClose } = renderModal(); await userEvent.click(screen.getByRole("button", { name: /cancel/i })); - expect(onRemove).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("disables both buttons while the removal is in flight", () => { + renderModal({ isRemoving: true }); + expect( + screen.getByRole("button", { name: /removing/i }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeDisabled(); }); }); diff --git a/webapp/tests/components/share-project-button.test.tsx b/webapp/tests/components/share-project-button.test.tsx index d955e22aa..aa5d6131b 100644 --- a/webapp/tests/components/share-project-button.test.tsx +++ b/webapp/tests/components/share-project-button.test.tsx @@ -40,9 +40,9 @@ describe("ShareProjectButton", () => { screen.getByRole("button", { name: /share project/i }), ); - const input = await screen.findByDisplayValue( - /\/public\/projects\/p1$/, - ); - expect(input).toBeInTheDocument(); + // The link is shown as text, not in a field: it is read and copied, + // never typed into. + const link = await screen.findByText(/\/public\/projects\/p1$/); + expect(link).toBeInTheDocument(); }); }); diff --git a/webapp/tests/components/sidebar-rail.test.tsx b/webapp/tests/components/sidebar-rail.test.tsx new file mode 100644 index 000000000..d2da74251 --- /dev/null +++ b/webapp/tests/components/sidebar-rail.test.tsx @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +const locationMock = vi.hoisted(() => ({ current: { pathname: "/o1" } })); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useNavigate: () => navigateMock, + useLocation: () => locationMock.current, + }; +}); + +// The account menu fetches on open; the rail's own behaviour is what is tested. +vi.mock("@/components/account-menu", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock("@/components/organizations-menu", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import SidebarRail from "@/components/sidebar-rail"; +import { renderWithRouter } from "../test-utils"; + +const orgs = [ + { id: "o1", name: "Mozilla", description: "" }, + { id: "o2", name: "Wecasa", description: "" }, +]; + +beforeEach(() => { + navigateMock.mockReset(); + localStorage.clear(); + locationMock.current = { pathname: "/o1" }; +}); + +describe("SidebarRail", () => { + it("offers the dashboard's destinations", () => { + renderWithRouter(); + ["Global", "Projects", "Members", "Organizations", "Account"].forEach( + (label) => + expect( + screen.getByRole("button", { name: label }), + ).toBeInTheDocument(), + ); + }); + + it("marks the destination matching the current path", () => { + locationMock.current = { pathname: "/o1/projects" }; + renderWithRouter(); + + expect( + screen.getByRole("button", { name: "Projects" }), + ).toHaveAttribute("aria-current", "page"); + expect( + screen.getByRole("button", { name: "Global" }), + ).not.toHaveAttribute("aria-current"); + }); + + it("navigates within the organization in the URL", async () => { + locationMock.current = { pathname: "/o2/members" }; + renderWithRouter(); + + await userEvent.click(screen.getByRole("button", { name: "Projects" })); + expect(navigateMock).toHaveBeenCalledWith("/o2/projects"); + }); + + it("remembers the organization it resolved, for the pages that read it", () => { + renderWithRouter(); + expect(localStorage.getItem("organizationId")).toBe("o1"); + expect(localStorage.getItem("organizationName")).toBe("Mozilla"); + }); + + it("does not navigate before an organization is known", async () => { + renderWithRouter(); + await userEvent.click(screen.getByRole("button", { name: "Projects" })); + expect(navigateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/tests/components/ui/form-field.test.tsx b/webapp/tests/components/ui/form-field.test.tsx new file mode 100644 index 000000000..edf409b22 --- /dev/null +++ b/webapp/tests/components/ui/form-field.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; + +import { FormField } from "@/components/ui/form-field"; + +describe("FormField", () => { + it("ties its label to its control", () => { + render(); + expect(screen.getByLabelText("Token name")).toBe( + screen.getByRole("textbox"), + ); + }); + + it("keeps the accessible name when the label is hidden", () => { + render(); + // Still reachable by name, but not shown as a visible label. + expect(screen.getByLabelText("Email address")).toBeInTheDocument(); + expect(screen.getByText("Email address")).toHaveClass("sr-only"); + }); +}); diff --git a/webapp/tests/pages/MembersPage.test.tsx b/webapp/tests/pages/MembersPage.test.tsx index 522207e70..b8e3a998d 100644 --- a/webapp/tests/pages/MembersPage.test.tsx +++ b/webapp/tests/pages/MembersPage.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; vi.mock("react-router-dom", async () => { @@ -16,9 +16,11 @@ vi.mock("@/api/swr", () => ({ swrConfig: {}, })); -const removeUserMock = vi.hoisted(() => vi.fn()); +const addOrganizationUserMock = vi.hoisted(() => vi.fn()); +const removeUserFromOrganizationMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/organizations", () => ({ - removeUserFromOrganization: removeUserMock, + addOrganizationUser: addOrganizationUserMock, + removeUserFromOrganization: removeUserFromOrganizationMock, })); import MembersPage from "@/pages/MembersPage"; @@ -27,30 +29,47 @@ import { SWRConfig } from "swr"; beforeEach(() => { fetcherMock.mockReset(); - removeUserMock.mockReset(); - removeUserMock.mockResolvedValue(undefined); + addOrganizationUserMock.mockReset(); + addOrganizationUserMock.mockResolvedValue(undefined); + removeUserFromOrganizationMock.mockReset(); + removeUserFromOrganizationMock.mockResolvedValue(undefined); }); -function mockOrgWithMember() { +/* The viewer, and the members the list comes back with. `/auth/check` decides + who is looking; their own row in the list decides whether they administer + this organization. */ +function mockMembers(members: unknown[], viewerId: string | null = null) { fetcherMock.mockImplementation((url: string) => { - if (url.endsWith("/users")) { - return Promise.resolve([ - { - id: "u1", - name: "Alice", - email: "alice@example.com", - is_active: true, - organizations: ["o1"], - }, - ]); - } - if (url.endsWith("/organizations/o1")) { - return Promise.resolve({ id: "o1", name: "Acme", description: "" }); - } - return Promise.resolve(null); + if (url.endsWith("/users")) return Promise.resolve(members); + if (url === "/auth/check") + return Promise.resolve(viewerId ? { user: { id: viewerId } } : {}); + return mockOrganization(url) ?? Promise.resolve(null); }); } +const ADMIN = { + id: "u1", + name: "Alice", + email: "alice@example.com", + organization_id: "o1", + is_admin: true, +}; + +const MEMBER = { + id: "u2", + name: "Bob", + email: "bob@example.com", + organization_id: "o1", + is_admin: false, +}; + +function mockOrganization(url: string) { + if (url.endsWith("/organizations/o1")) { + return Promise.resolve({ id: "o1", name: "Acme", description: "" }); + } + return undefined; +} + function renderWithSwr(node: React.ReactNode) { return renderWithRouter( new Map(), dedupingInterval: 0 }}> @@ -60,7 +79,7 @@ function renderWithSwr(node: React.ReactNode) { } describe("MembersPage", () => { - it("renders the user list once loaded", async () => { + it("renders the member list once loaded", async () => { // SWR calls the fetcher per key; first matching response wins per key. fetcherMock.mockImplementation((url: string) => { if (url.endsWith("/users")) { @@ -69,74 +88,223 @@ describe("MembersPage", () => { id: "u1", name: "Alice", email: "alice@example.com", - is_active: true, - organizations: ["o1"], + organization_id: "o1", + is_admin: true, }, ]); } - if (url.endsWith("/organizations/o1")) { - return Promise.resolve({ - id: "o1", - name: "Acme", - description: "", - }); - } - return Promise.resolve(null); + return mockOrganization(url) ?? Promise.resolve(null); }); renderWithSwr(); expect(await screen.findByText("Alice")).toBeInTheDocument(); expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + // The status slot carries the only standing the API records. + expect(screen.getByText("(Admin)")).toBeInTheDocument(); }); - it("opens the add-member form when the button is clicked", async () => { + it("shows the empty state when the organization has no members", async () => { fetcherMock.mockImplementation((url: string) => { if (url.endsWith("/users")) return Promise.resolve([]); - if (url.endsWith("/organizations/o1")) - return Promise.resolve({ - id: "o1", - name: "Acme", - description: "", - }); - return Promise.resolve(null); + return mockOrganization(url) ?? Promise.resolve(null); }); renderWithSwr(); - await userEvent.click( - await screen.findByRole("button", { name: /\+ add a member/i }), + + expect( + await screen.findByText(/you have no members invited yet/i), + ).toBeInTheDocument(); + }); + + it("keeps the invite button disabled until an address is typed", async () => { + fetcherMock.mockImplementation((url: string) => { + if (url.endsWith("/users")) return Promise.resolve([]); + return mockOrganization(url) ?? Promise.resolve(null); + }); + + renderWithSwr(); + + const button = await screen.findByRole("button", { name: /invite/i }); + expect(button).toBeDisabled(); + + await userEvent.type( + screen.getByLabelText(/invite via email/i), + "new@example.com", ); - expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument(); + expect(button).toBeEnabled(); }); - it("removes a member through the row menu once confirmed", async () => { - mockOrgWithMember(); + it("invites the typed address", async () => { + fetcherMock.mockImplementation((url: string) => { + if (url.endsWith("/users")) return Promise.resolve([]); + return mockOrganization(url) ?? Promise.resolve(null); + }); + renderWithSwr(); - expect(await screen.findByText("Alice")).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: /toggle/i })); + await userEvent.type( + await screen.findByLabelText(/invite via email/i), + "new@example.com", + ); + await userEvent.click(screen.getByRole("button", { name: /invite/i })); + + expect(addOrganizationUserMock).toHaveBeenCalledWith( + "o1", + "new@example.com", + ); + }); + + it("does not send an invalid address to the API", async () => { + fetcherMock.mockImplementation((url: string) => { + if (url.endsWith("/users")) return Promise.resolve([]); + return mockOrganization(url) ?? Promise.resolve(null); + }); + + renderWithSwr(); + + await userEvent.type( + await screen.findByLabelText(/invite via email/i), + "not-an-email", + ); + await userEvent.click(screen.getByRole("button", { name: /invite/i })); + + // `type="email"` fails the form's own constraint validation, so the + // submit never reaches the handler. The page's schema check behind it + // covers whatever the browser lets through. + expect(addOrganizationUserMock).not.toHaveBeenCalled(); + }); + + it("offers no actions to a member who is not an admin", async () => { + mockMembers([ADMIN, MEMBER], MEMBER.id); + + renderWithSwr(); + + await screen.findByText("Bob"); + expect( + screen.queryByRole("button", { name: /actions for/i }), + ).not.toBeInTheDocument(); + }); + + it("lets an admin remove any member, administrators included", async () => { + mockMembers([ADMIN, MEMBER], ADMIN.id); + + renderWithSwr(); + + await screen.findByText("Bob"); + // The API refuses only the last administrator, so both rows have a menu. + expect( + screen.getByRole("button", { name: /actions for Alice/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /actions for Bob/i }), + ).toBeInTheDocument(); + }); + + it("removes the member only once the dialog is confirmed", async () => { + mockMembers([ADMIN, MEMBER], ADMIN.id); + + renderWithSwr(); + + await screen.findByText("Bob"); await userEvent.click( - await screen.findByRole("menuitem", { name: /delete/i }), + screen.getByRole("button", { name: /actions for Bob/i }), ); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Delete" }), + ); + + // The menu item opens the dialog; nothing has been sent yet. + expect(removeUserFromOrganizationMock).not.toHaveBeenCalled(); await userEvent.click( await screen.findByRole("button", { name: /remove member/i }), ); - expect(removeUserMock).toHaveBeenCalledWith("o1", "u1"); + expect(removeUserFromOrganizationMock).toHaveBeenCalledWith("o1", "u2"); + }); + + it("offers no Settings action, which has no endpoint behind it", async () => { + mockMembers([ADMIN, MEMBER], ADMIN.id); + + renderWithSwr(); + + await screen.findByText("Bob"); + await userEvent.click( + screen.getByRole("button", { name: /actions for Bob/i }), + ); + + await screen.findByRole("menuitem", { name: "Delete" }); + expect( + screen.queryByRole("menuitem", { name: /settings/i }), + ).not.toBeInTheDocument(); + }); + + it("names the member and the organization in the confirmation", async () => { + mockMembers([ADMIN, MEMBER], ADMIN.id); + + renderWithSwr(); + + await screen.findByText("Bob"); + await userEvent.click( + screen.getByRole("button", { name: /actions for Bob/i }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Delete" }), + ); + + expect( + await screen.findByText(/Remove Bob from Acme\?/), + ).toBeInTheDocument(); }); it("does not remove a member when the confirmation is cancelled", async () => { - mockOrgWithMember(); + mockMembers([ADMIN, MEMBER], ADMIN.id); + renderWithSwr(); - expect(await screen.findByText("Alice")).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: /toggle/i })); + await screen.findByText("Bob"); await userEvent.click( - await screen.findByRole("menuitem", { name: /delete/i }), + screen.getByRole("button", { name: /actions for Bob/i }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Delete" }), ); await userEvent.click( await screen.findByRole("button", { name: /cancel/i }), ); - expect(removeUserMock).not.toHaveBeenCalled(); + + expect(removeUserFromOrganizationMock).not.toHaveBeenCalled(); + }); + + it("keeps the dialog open when the API refuses the removal", async () => { + removeUserFromOrganizationMock.mockRejectedValue( + new Error( + "Cannot remove the last administrator of the organization", + ), + ); + mockMembers([ADMIN], ADMIN.id); + + renderWithSwr(); + + await screen.findByText("Alice"); + await userEvent.click( + screen.getByRole("button", { name: /actions for Alice/i }), + ); + await userEvent.click( + await screen.findByRole("menuitem", { name: "Delete" }), + ); + await userEvent.click( + await screen.findByRole("button", { name: /remove member/i }), + ); + + await waitFor(() => + expect(removeUserFromOrganizationMock).toHaveBeenCalledWith( + "o1", + "u1", + ), + ); + expect( + await screen.findByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); }); }); diff --git a/webapp/tests/pages/SettingsPage.test.tsx b/webapp/tests/pages/SettingsPage.test.tsx new file mode 100644 index 000000000..0c9c5a42c --- /dev/null +++ b/webapp/tests/pages/SettingsPage.test.tsx @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const navigateMock = vi.hoisted(() => vi.fn()); +vi.mock("react-router-dom", async () => { + const actual = + await vi.importActual( + "react-router-dom", + ); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const fetcherMock = vi.hoisted(() => vi.fn()); +vi.mock("@/api/swr", () => ({ fetcher: fetcherMock, swrConfig: {} })); + +import SettingsPage from "@/pages/SettingsPage"; +import { renderWithRouter } from "../test-utils"; +import { SWRConfig } from "swr"; + +function renderPage() { + return renderWithRouter( + new Map(), dedupingInterval: 0 }}> + + , + ); +} + +beforeEach(() => { + fetcherMock.mockReset(); + navigateMock.mockReset(); + fetcherMock.mockResolvedValue({ user: { email: "person@example.com" } }); +}); + +describe("SettingsPage", () => { + it("shows the signed-in address rather than a placeholder", async () => { + renderPage(); + expect( + await screen.findByDisplayValue("person@example.com"), + ).toBeDisabled(); + }); + + it("marks Profile as the panel being viewed, and does not make it pressable", () => { + renderPage(); + // The current row is not a button: there is nothing to press. + expect( + screen.queryByRole("button", { name: /profile/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByText("Profile", { selector: "span" }), + ).toBeInTheDocument(); + }); + + it("goes back through history", async () => { + renderPage(); + await userEvent.click(screen.getByRole("button", { name: /go back/i })); + expect(navigateMock).toHaveBeenCalledWith(-1); + }); + + it("disables the provider hand-offs when no provider is configured", () => { + renderPage(); + // VITE_OIDC_PROFILE_URL is unset in tests, so both are inert. + expect( + screen.getByRole("button", { name: /password/i }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: /change/i })).toBeDisabled(); + }); +}); diff --git a/webapp/tests/setup.ts b/webapp/tests/setup.ts index 8ed0c3f4e..1b45f39c4 100644 --- a/webapp/tests/setup.ts +++ b/webapp/tests/setup.ts @@ -2,6 +2,24 @@ import "@testing-library/jest-dom/vitest"; import { afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; +/* + * jsdom implements neither of these, and Radix reaches for both: `useSize` + * observes a control's box (the switch does), and its popper measures scroll + * geometry. Stubbed here rather than in each test, so a component that happens + * to use one does not fail for a reason unrelated to what is being tested. + */ +if (!("ResizeObserver" in globalThis)) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} + afterEach(() => { cleanup(); }); diff --git a/webapp/tests/utils/matomo.test.ts b/webapp/tests/utils/matomo.test.ts new file mode 100644 index 000000000..0b6c19895 --- /dev/null +++ b/webapp/tests/utils/matomo.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { initMatomo, trackEvent } from "@/utils/matomo"; + +function fakeRouter(pathname: string) { + let listener: ((s: { location: { pathname: string } }) => void) | null = + null; + return { + state: { location: { pathname } }, + subscribe(fn: typeof listener) { + listener = fn; + return () => {}; + }, + navigate(next: string) { + listener?.({ location: { pathname: next } }); + }, + }; +} + +beforeEach(() => { + window._paq = undefined; + document.head.innerHTML = ""; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("initMatomo", () => { + it("does nothing when Matomo is not configured", () => { + const router = fakeRouter("/home"); + initMatomo(router); + expect(window._paq).toBeUndefined(); + expect(document.head.querySelector("script")).toBeNull(); + }); + + it("tracks the first page and each new pathname once", () => { + vi.stubEnv("VITE_MATOMO_URL", "https://matomo.example.com"); + vi.stubEnv("VITE_MATOMO_SITE_ID", "3"); + const router = fakeRouter("/home"); + + initMatomo(router); + router.navigate("/home"); + router.navigate("/organizations/1"); + + const views = window._paq!.filter((c) => c[0] === "trackPageView"); + expect(views).toHaveLength(2); + expect(window._paq).toContainEqual(["setSiteId", "3"]); + expect(window._paq).toContainEqual([ + "setTrackerUrl", + "https://matomo.example.com/matomo.php", + ]); + expect(window._paq).toContainEqual([ + "setCustomUrl", + `${window.location.origin}/organizations/1`, + ]); + expect(document.head.querySelector("script")?.src).toBe( + "https://matomo.example.com/matomo.js", + ); + }); + + it("records events with an optional name and value once enabled", () => { + vi.stubEnv("VITE_MATOMO_URL", "https://matomo.example.com"); + vi.stubEnv("VITE_MATOMO_SITE_ID", "3"); + initMatomo(fakeRouter("/home")); + + trackEvent("Activation", "project_created"); + trackEvent("Dashboard", "date_range_applied", undefined, 30); + trackEvent("Dashboard", "chart_viewed", "Emissions", 12); + + expect(window._paq).toContainEqual([ + "trackEvent", + "Activation", + "project_created", + ]); + expect(window._paq).toContainEqual([ + "trackEvent", + "Dashboard", + "date_range_applied", + "", + 30, + ]); + expect(window._paq).toContainEqual([ + "trackEvent", + "Dashboard", + "chart_viewed", + "Emissions", + 12, + ]); + }); +});