From be5310fb9da025cc01f59ecfaa1c9532d69277de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Pardou?= <571533+jrmi@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:17:09 +0200 Subject: [PATCH 1/2] fix: minor demo glitches (#5619) Co-authored-by: peter_baserow --- .../src/baserow/contrib/integrations/core/service_types.py | 5 ++++- web-frontend/modules/integrations/core/serviceTypes.js | 4 ++-- .../modules/integrations/localBaserow/serviceTypes.js | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py index 0b3db414cc..9b68300e59 100644 --- a/backend/src/baserow/contrib/integrations/core/service_types.py +++ b/backend/src/baserow/contrib/integrations/core/service_types.py @@ -2075,7 +2075,7 @@ def _read_csv( return [] if first_line_is_header: - headers = [header.strip() for header in csv_rows[0]] + headers = [self._normalize_csv_header(header) for header in csv_rows[0]] return [ self._row_to_dict(headers, row) for row in csv_rows[1:] @@ -2095,6 +2095,9 @@ def _row_to_dict(self, headers: list[str], row: list[str]) -> dict[str, str]: for index, header in enumerate(headers) } + def _normalize_csv_header(self, header: str) -> str: + return header.removeprefix("\ufeff").strip() + def _row_has_values(self, row: list[str]) -> bool: return any(value != "" for value in row) diff --git a/web-frontend/modules/integrations/core/serviceTypes.js b/web-frontend/modules/integrations/core/serviceTypes.js index ef05bbb537..c243e1e34f 100644 --- a/web-frontend/modules/integrations/core/serviceTypes.js +++ b/web-frontend/modules/integrations/core/serviceTypes.js @@ -329,8 +329,8 @@ export class CoreCSVFileReaderServiceType extends DataSourceServiceTypeMixin( } getErrorMessage({ service }) { - if (service.input_type === undefined && service.file === undefined) { - // we are in preview or published mode + if (service?.input_type === undefined && service?.file === undefined) { + // The service is not loaded yet, or we are in preview or published mode return super.getErrorMessage({ service }) } diff --git a/web-frontend/modules/integrations/localBaserow/serviceTypes.js b/web-frontend/modules/integrations/localBaserow/serviceTypes.js index 6dd443c3cf..22140ef374 100644 --- a/web-frontend/modules/integrations/localBaserow/serviceTypes.js +++ b/web-frontend/modules/integrations/localBaserow/serviceTypes.js @@ -470,7 +470,7 @@ export class LocalBaserowUpsertRowsWorkflowServiceType extends WorkflowActionSer } getErrorMessage({ service }) { - if (service !== undefined && !service.rows?.formula) { + if (service?.rows !== undefined && !service.rows?.formula) { return this.app.$i18n.t('serviceType.errorNoRowsSelected') } From 0b0562ec930a8979a38744c11692841c86695178 Mon Sep 17 00:00:00 2001 From: dimmur-brw Date: Wed, 1 Jul 2026 09:10:01 +0200 Subject: [PATCH 2/2] feat: User presence (part 1) - infrastructure and grid avatars (#5399) --- backend/src/baserow/config/settings/test.py | 4 +- backend/src/baserow/contrib/database/apps.py | 6 +- .../src/baserow/contrib/database/ws/pages.py | 16 + backend/src/baserow/core/async_redis.py | 40 + backend/src/baserow/core/feature_flags.py | 1 + backend/src/baserow/ws/consumers.py | 86 +- backend/src/baserow/ws/presence.py | 356 ++++++++ backend/src/baserow/ws/registries.py | 32 + backend/src/baserow/ws/types.py | 27 +- .../field/test_link_row_field_type.py | 10 +- .../database/ws/test_ws_table_tasks.py | 14 +- backend/tests/baserow/ws/conftest.py | 28 + backend/tests/baserow/ws/test_presence.py | 769 ++++++++++++++++++ backend/tests/baserow/ws/test_ws_consumers.py | 9 +- .../baserow/ws/test_ws_realtime_events.py | 4 + backend/tests/baserow/ws/test_ws_tasks.py | 9 + docs/development/feature-flags.md | 2 + docs/technical/realtime-presence.md | 121 +++ .../src/baserow_enterprise/ws/pages.py | 5 +- .../test_enterprise_data_sync_handler.py | 8 +- .../ws/test_role_signals.py | 46 +- .../baserow_enterprise/viewOwnershipTypes.js | 2 +- .../core/assets/scss/components/all.scss | 1 + .../core/assets/scss/components/header.scss | 8 + .../core/assets/scss/components/presence.scss | 30 + .../components/presence/PresenceBadge.vue | 39 + .../core/components/presence/PresenceBar.vue | 72 ++ .../modules/core/plugins/featureFlags.js | 1 + .../modules/core/plugins/realTimeHandler.js | 27 + web-frontend/modules/core/plugins/store.js | 2 + web-frontend/modules/core/store/presence.js | 83 ++ .../modules/core/utils/presenceColors.js | 27 + .../components/view/grid/GridViewHeader.vue | 48 +- web-frontend/test/fixtures/workspaces.js | 1 + .../test/unit/core/realTimeHandler.spec.js | 93 +++ .../test/unit/core/store/presence.spec.js | 113 +++ .../__snapshots__/publicView.spec.js.snap | 3 +- .../database/__snapshots__/table.spec.js.snap | 6 +- 38 files changed, 2083 insertions(+), 66 deletions(-) create mode 100644 backend/src/baserow/core/async_redis.py create mode 100644 backend/src/baserow/ws/presence.py create mode 100644 backend/tests/baserow/ws/test_presence.py create mode 100644 docs/technical/realtime-presence.md create mode 100644 web-frontend/modules/core/assets/scss/components/presence.scss create mode 100644 web-frontend/modules/core/components/presence/PresenceBadge.vue create mode 100644 web-frontend/modules/core/components/presence/PresenceBar.vue create mode 100644 web-frontend/modules/core/store/presence.js create mode 100644 web-frontend/modules/core/utils/presenceColors.js create mode 100644 web-frontend/test/unit/core/store/presence.spec.js diff --git a/backend/src/baserow/config/settings/test.py b/backend/src/baserow/config/settings/test.py index 0498c10dd6..4d2bd955da 100644 --- a/backend/src/baserow/config/settings/test.py +++ b/backend/src/baserow/config/settings/test.py @@ -8,6 +8,8 @@ from baserow.config.settings.utils import str_to_bool +_fake_redis_server = FakeServer() + # Test environment configuration: # - TEST_ENV_FILE: Optional env file for test settings (default: none) # - DATABASE_*, REDIS_*, and MEDIA_ROOT vars can be passed via real env vars or TEST_ENV_FILE @@ -94,7 +96,7 @@ def getenv_for_tests(key: str, default: str = "") -> str: "OPTIONS": { "CONNECTION_POOL_KWARGS": { "connection_class": FakeConnection, - "server": FakeServer(), + "server": _fake_redis_server, } }, "KEY_PREFIX": "baserow-default-cache", diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index 884866d806..ff65e9cf0e 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -575,7 +575,11 @@ def ready(self): application_type_registry.register(DatabaseApplicationType()) - from .ws.pages import PublicViewPageType, RowPageType, TablePageType + from .ws.pages import ( + PublicViewPageType, + RowPageType, + TablePageType, + ) page_registry.register(TablePageType()) page_registry.register(PublicViewPageType()) diff --git a/backend/src/baserow/contrib/database/ws/pages.py b/backend/src/baserow/contrib/database/ws/pages.py index e2e4e8b9cd..1cbd028ceb 100755 --- a/backend/src/baserow/contrib/database/ws/pages.py +++ b/backend/src/baserow/contrib/database/ws/pages.py @@ -51,6 +51,22 @@ def get_group_name(self, table_id, **kwargs): def get_permission_channel_group_name(self, table_id, **kwargs): return f"permissions-table-{table_id}" + def get_presence_space_name(self, table_id: int | None, **kwargs) -> str | None: + return table_presence_space_name(table_id) + + +def table_presence_space_name(table_id: int) -> str | None: + """ + Return the canonical presence space name for a table. + + :param table_id: The database table id. + :return: The space name string, or None if table_id is falsy. + """ + + if table_id is None or table_id <= 0: + return None + return f"table-{table_id}" + class PublicViewPageType(PageType): type = "view" diff --git a/backend/src/baserow/core/async_redis.py b/backend/src/baserow/core/async_redis.py new file mode 100644 index 0000000000..f7081883ee --- /dev/null +++ b/backend/src/baserow/core/async_redis.py @@ -0,0 +1,40 @@ +import asyncio +from weakref import WeakKeyDictionary + +from django.conf import settings + +from redis.asyncio import Redis, from_url + +_pools: WeakKeyDictionary[asyncio.AbstractEventLoop, Redis] = WeakKeyDictionary() +_test_override: Redis | None = None + + +async def get_async_redis() -> Redis: + """ + Return a shared async Redis client for the current event loop. + + Each loop gets its own client because ``redis.asyncio`` pins + connections to the loop that created them. Dead loops are evicted + automatically via ``WeakKeyDictionary``. + """ + + if _test_override is not None: + return _test_override + + loop = asyncio.get_running_loop() + client = _pools.get(loop) + if client is None: + client = from_url(settings.REDIS_URL, decode_responses=True) + _pools[loop] = client + return client + + +def set_async_redis(client: Redis | None) -> None: + """ + Replace the shared pool — used by tests to inject a fake client. + + The override is loop-agnostic: it bypasses the per-loop map entirely. + """ + + global _test_override + _test_override = client diff --git a/backend/src/baserow/core/feature_flags.py b/backend/src/baserow/core/feature_flags.py index 3650627218..584187ca31 100644 --- a/backend/src/baserow/core/feature_flags.py +++ b/backend/src/baserow/core/feature_flags.py @@ -3,6 +3,7 @@ from baserow.core.exceptions import FeatureDisabledException FF_ENABLE_ALL = "*" +FF_USER_PRESENCE = "user_presence" def feature_flag_is_enabled(feature_flag: str, raise_if_disabled=False) -> bool: diff --git a/backend/src/baserow/ws/consumers.py b/backend/src/baserow/ws/consumers.py index 088efae1cb..7b4ac30397 100644 --- a/backend/src/baserow/ws/consumers.py +++ b/backend/src/baserow/ws/consumers.py @@ -8,6 +8,12 @@ from opentelemetry import metrics from baserow.config.settings.utils import try_int +from baserow.core.feature_flags import FF_USER_PRESENCE, feature_flag_is_enabled +from baserow.ws.presence import ( + NullPresenceHandler, + PresenceHandler, + PresenceHandlerProtocol, +) from baserow.ws.realtime_events import ( FIRST_CONNECT_CURSOR, NO_REPLAY_AVAILABLE, @@ -166,6 +172,8 @@ def __iter__(self): class CoreConsumer(AsyncJsonWebsocketConsumer): + presence: PresenceHandlerProtocol = NullPresenceHandler() + async def connect(self): await self.accept() websocket_connections_counter.add(1) @@ -188,6 +196,15 @@ async def connect(self): return self.scope["pages"] = SubscribedPages() + web_socket_id = self.scope["web_socket_id"] + if feature_flag_is_enabled(FF_USER_PRESENCE): + self.presence = PresenceHandler( + consumer=self, + web_socket_id=web_socket_id, + user_id=user.id, + ) + else: + self.presence = NullPresenceHandler() await self.channel_layer.group_add("users", self.channel_name) async def disconnect(self, code): @@ -196,7 +213,8 @@ async def disconnect(self, code): # eager connect counter would drift ahead during infrastructure failures, # exactly when the metric matters most. try: - await self._remove_all_page_scopes(send_confirmation=False) + await self.presence.leave_all_spaces() + await self._discard_all_channel_groups() await self.channel_layer.group_discard("users", self.channel_name) finally: websocket_disconnects_counter.add( @@ -295,9 +313,15 @@ async def _add_page_scope(self, content: dict): self.scope["pages"].add(page_scope) await self.send_json( - {"type": "page_add", "page": page_type.type, "parameters": parameters} + { + "type": "page_add", + "page": page_type.type, + "parameters": parameters, + } ) + await self.presence.handle_page_subscribed(page_type.type, parameters) + async def _remove_page_scope(self, content: dict, send_confirmation=True): """ Unsubscribes the connection from a page. Based on the provided page @@ -315,17 +339,18 @@ async def _remove_page_scope(self, content: dict, send_confirmation=True): if not context: return - user, page_type, parameters = attrgetter( - "user", "resolved_page_type", "page_scope.page_parameters" + page_type, parameters = attrgetter( + "resolved_page_type", "page_scope.page_parameters" )(context) group_name = page_type.get_group_name(**parameters) await self.channel_layer.group_discard(group_name, self.channel_name) page_scope = PageScope(page_type=page_type.type, page_parameters=parameters) - self.scope["pages"].remove(page_scope) + await self.presence.handle_page_unsubscribed(page_type.type, parameters) + permission_group_name = page_type.get_permission_channel_group_name( **parameters ) @@ -348,18 +373,53 @@ async def _remove_page_scope(self, content: dict, send_confirmation=True): async def _remove_all_page_scopes(self, send_confirmation=True): """ Unsubscribes the connection from all currently subscribed pages. + Used for live connections (e.g. permission revocation that affects + all pages). For disconnect cleanup, use _discard_all_channel_groups. """ if self.scope.get("pages"): for page_scope in self.scope["pages"].copy(): - content = { - "user": self.scope["user"], - "web_socket_id": self.scope["web_socket_id"], - "remove_page": page_scope.page_type, - **page_scope.page_parameters, - } - await self._remove_page_scope( - content, send_confirmation=send_confirmation + try: + content = { + "user": self.scope["user"], + "web_socket_id": self.scope["web_socket_id"], + "remove_page": page_scope.page_type, + **page_scope.page_parameters, + } + await self._remove_page_scope( + content, send_confirmation=send_confirmation + ) + except Exception: + logger.exception( + "Failed to remove page scope {} during cleanup", + page_scope.page_type, + ) + + async def _discard_all_channel_groups(self) -> None: + """ + Tears down all channel group memberships without sending client + messages or touching presence. Used during disconnect — presence + cleanup is handled separately by leave_all_spaces(). + """ + + if not self.scope.get("pages"): + return + + for page_scope in self.scope["pages"].copy(): + try: + page_type = page_registry.get(page_scope.page_type) + group_name = page_type.get_group_name(**page_scope.page_parameters) + await self.channel_layer.group_discard(group_name, self.channel_name) + perm = page_type.get_permission_channel_group_name( + **page_scope.page_parameters + ) + if perm: + await self.channel_layer.group_discard(perm, self.channel_name) + except Exception: + logger.exception( + "Failed to discard channel groups for page scope {} " + "during disconnect", + page_scope.page_type, ) async def _remove_page_scopes_associated_with_perm_group( diff --git a/backend/src/baserow/ws/presence.py b/backend/src/baserow/ws/presence.py new file mode 100644 index 0000000000..8a88f6ebac --- /dev/null +++ b/backend/src/baserow/ws/presence.py @@ -0,0 +1,356 @@ +import json +import uuid +from typing import TYPE_CHECKING, Optional, Protocol, runtime_checkable + +from loguru import logger + +from baserow.core.async_redis import get_async_redis +from baserow.ws.registries import page_registry +from baserow.ws.types import ( + ActivePresenceEntry, + PresenceMembershipMessage, +) + +if TYPE_CHECKING: + from baserow.ws.consumers import CoreConsumer + +PRESENCE_KEY_PREFIX = "presence:" +PRESENCE_SPACE_TTL = 43200 # 12 hours + + +def _is_valid_entry(data) -> bool: + return isinstance(data, dict) and isinstance(data.get("user_id"), int) + + +def make_page_key(page_type: str, parameters: dict) -> str: + """ + Build a deterministic key for a page subscription (type + sorted params). + + :param page_type: The registered page type name. + :param parameters: The page subscription parameters. + :return: A stable string key for this page subscription. + """ + + sorted_params = sorted(parameters.items()) + return f"{page_type}:{','.join(f'{k}={v}' for k, v in sorted_params)}" + + +@runtime_checkable +class PresenceHandlerProtocol(Protocol): + """Per-connection presence lifecycle: page subscribe/unsubscribe and cleanup.""" + + async def handle_page_subscribed( + self, page_type_name: str, parameters: dict + ) -> None: ... + + async def handle_page_unsubscribed( + self, page_type_name: str, parameters: dict + ) -> None: ... + + async def leave_all_spaces(self) -> None: ... + + +class NullPresenceHandler: + """No-op handler used when no authenticated user is present.""" + + async def handle_page_subscribed( + self, page_type_name: str, parameters: dict + ) -> None: + pass + + async def handle_page_unsubscribed( + self, page_type_name: str, parameters: dict + ) -> None: + pass + + async def leave_all_spaces(self) -> None: + pass + + +class PresenceSpace: + """ + Identity and Redis storage for a single presence space. + + Takes a ``name`` (e.g. ``"table-42"``); Redis key and channel group + name are derived deterministically. + """ + + def __init__(self, name: str): + self.name = name + + @property + def redis_key(self) -> str: + return f"{PRESENCE_KEY_PREFIX}{self.name}" + + @property + def channel_group(self) -> str: + return f"presence.{self.name}" + + async def _fetch_and_clean_entries(self) -> dict[str, dict]: + """ + Read all entries from Redis, remove corrupt/invalid ones, return valid. + """ + + redis = await get_async_redis() + raw = await redis.hgetall(self.redis_key) + entries: dict[str, dict] = {} + corrupt: list[str] = [] + for pid, value in raw.items(): + try: + data = json.loads(value) + except (ValueError, TypeError): + corrupt.append(pid) + continue + if _is_valid_entry(data): + entries[pid] = data + else: + corrupt.append(pid) + if corrupt: + await redis.hdel(self.redis_key, *corrupt) + return entries + + async def join(self, presence_id: str, user_id: int) -> None: + """ + Add a presence entry to this space in Redis. + + :param presence_id: Unique identifier for this connection's presence. + :param user_id: The user who owns this connection. + """ + + redis = await get_async_redis() + entry = json.dumps({"user_id": user_id}) + await redis.hset(self.redis_key, presence_id, entry) + await redis.expire(self.redis_key, PRESENCE_SPACE_TTL) + + async def remove_entry(self, presence_id: str) -> None: + """ + Remove a single presence entry from this space in Redis. + + :param presence_id: The presence entry to remove. + """ + + redis = await get_async_redis() + await redis.hdel(self.redis_key, presence_id) + await redis.expire(self.redis_key, PRESENCE_SPACE_TTL) + + async def get_members( + self, exclude_presence_id: Optional[str] = None + ) -> list[ActivePresenceEntry]: + """ + Return all members, optionally excluding presence_id. + + :param exclude_presence_id: If set, omit this entry from the result + (used to exclude self from members response). + :return: List of presence members. + """ + + entries = await self._fetch_and_clean_entries() + return [ + ActivePresenceEntry( + user_id=data["user_id"], + presence_id=pid, + ) + for pid, data in entries.items() + if pid != exclude_presence_id + ] + + +class PresenceHandler: + """Per-connection presence handler owning the full join/leave lifecycle.""" + + def __init__( + self, + consumer: "CoreConsumer", + web_socket_id: str, + user_id: int, + ): + self._consumer = consumer + self._web_socket_id = web_socket_id + self.user_id = user_id + self.presence_id = str(uuid.uuid4()) + self._space_pages: dict[str, set[str]] = {} + self._page_to_space: dict[str, str] = {} + + # -- Page lifecycle (called by consumer) -- + + async def handle_page_subscribed( + self, page_type_name: str, parameters: dict + ) -> None: + """ + Join the presence space for this page, send current members to the + subscriber, and broadcast join to other members. + + :param page_type_name: The registered page type name. + :param parameters: The page subscription parameters. + """ + + try: + space_name = self.resolve_space_name(page_type_name, parameters) + if space_name is None: + return + + page_key = make_page_key(page_type_name, parameters) + if page_key in self._page_to_space: + return + + already_in_space = space_name in self._space_pages + + if already_in_space: + self._page_subscribed(page_key, space_name) + return + + space = PresenceSpace(name=space_name) + await self._consumer.channel_layer.group_add( + space.channel_group, self._consumer.channel_name + ) + members = await self._join(space) + self._page_subscribed(page_key, space_name) + await self._consumer.send_json( + { + "type": "presence.members", + "space": space_name, + "entries": members, + } + ) + await self._broadcast_join(space) + except Exception: + logger.exception("Presence subscribe failed for page {}", page_type_name) + + async def handle_page_unsubscribed( + self, page_type_name: str, parameters: dict + ) -> None: + """ + Leave the presence space if this was the last page mapping to it. + + :param page_type_name: The registered page type name. + :param parameters: The page subscription parameters. + """ + + page_key = make_page_key(page_type_name, parameters) + space_name = self._page_to_space.get(page_key) + if space_name is None: + return + + remaining = self._space_pages.get(space_name, set()) - {page_key} + + if remaining: + self._page_unsubscribed(page_key) + return + + try: + space = PresenceSpace(name=space_name) + await self._leave(space) + await self._broadcast_leave(space) + await self._consumer.send_json( + { + "type": "presence.space_discard", + "space": space_name, + } + ) + await self._consumer.channel_layer.group_discard( + space.channel_group, self._consumer.channel_name + ) + self._page_unsubscribed(page_key) + except Exception: + logger.exception("Presence unsubscribe failed for space {}", space_name) + + async def leave_all_spaces(self) -> None: + """ + Leave every presence space this connection is in. Called during + disconnect to broadcast leave and clean up Redis entries. + """ + + for space_name in list(self._space_pages.keys()): + try: + space = PresenceSpace(name=space_name) + await self._leave(space) + await self._broadcast_leave(space) + await self._consumer.channel_layer.group_discard( + space.channel_group, self._consumer.channel_name + ) + except Exception: + logger.exception("Presence cleanup failed for space {}", space_name) + self._space_pages.clear() + self._page_to_space.clear() + + # -- Page-to-space tracking (internal) -- + + def _page_subscribed(self, page_key: str, space_name: str) -> None: + """ + Record a page→space mapping. Called after external side effects succeed. + """ + + self._page_to_space[page_key] = space_name + self._space_pages.setdefault(space_name, set()).add(page_key) + + def _page_unsubscribed(self, page_key: str) -> None: + """ + Remove a page→space mapping. Called after external side effects succeed. + """ + + space_name = self._page_to_space.pop(page_key, None) + if space_name is None: + return + page_keys = self._space_pages.get(space_name) + if page_keys is not None: + page_keys.discard(page_key) + if not page_keys: + del self._space_pages[space_name] + + # -- Space operations (Redis via PresenceSpace) -- + + async def _join(self, space: PresenceSpace) -> list[ActivePresenceEntry]: + await space.join(self.presence_id, self.user_id) + return await space.get_members(exclude_presence_id=self.presence_id) + + async def _leave(self, space: PresenceSpace) -> None: + await space.remove_entry(self.presence_id) + + # -- Broadcast (ephemeral — no record_realtime_event) -- + + async def _broadcast(self, space: PresenceSpace, payload: dict) -> None: + await self._consumer.channel_layer.group_send( + space.channel_group, + { + "type": "broadcast_to_group", + "payload": payload, + "ignore_web_socket_id": self._web_socket_id, + }, + ) + + async def _broadcast_join(self, space: PresenceSpace) -> None: + payload: PresenceMembershipMessage = { + "type": "presence.join", + "space": space.name, + "user_id": self.user_id, + "presence_id": self.presence_id, + } + await self._broadcast(space, payload) + + async def _broadcast_leave(self, space: PresenceSpace) -> None: + payload: PresenceMembershipMessage = { + "type": "presence.leave", + "space": space.name, + "user_id": self.user_id, + "presence_id": self.presence_id, + } + await self._broadcast(space, payload) + + # -- Helpers -- + + @staticmethod + def resolve_space_name(page_type_name: str, parameters: dict) -> Optional[str]: + """ + Look up the presence space name for a page type via the registry. + + :param page_type_name: The registered page type name. + :param parameters: The page subscription parameters. + :return: The space name, or None if the page type does not exist or + does not participate in presence. + """ + + try: + page_type = page_registry.get(page_type_name) + except page_registry.does_not_exist_exception_class: + return None + return page_type.get_presence_space_name(**parameters) diff --git a/backend/src/baserow/ws/registries.py b/backend/src/baserow/ws/registries.py index 9c15454356..3f61b048d4 100644 --- a/backend/src/baserow/ws/registries.py +++ b/backend/src/baserow/ws/registries.py @@ -78,6 +78,38 @@ def get_permission_channel_group_name(self, **kwargs) -> Optional[str]: return None + def get_presence_space_name(self, **kwargs) -> str | None: + """ + Return the presence space name for this page, or None to opt out of + presence tracking. Override to enable presence for a page type. + + :param kwargs: The additional parameters including their provided values. + :return: A presence space name string, or None if this page type does + not participate in presence. + """ + + return None + + def filter_focus_for_recipient(self, page_parameters, focus, focus_type) -> bool: + """ + Decide whether a recipient on this page should see the given focus + event. Called per-recipient during focus broadcast. + + Must be overridden by every page type that enables presence (returns a + non-None space name). Raises NotImplementedError by default to prevent + data leaks from unimplemented filtering. + + :param page_parameters: The recipient's page subscription parameters. + :param focus: The focus event payload. + :param focus_type: The focus type instance. + :return: True if the recipient should see this focus event. + """ + + raise NotImplementedError( + "Each presence-enabled page type must explicitly declare " + "focus filtering behavior to prevent data leaks." + ) + def broadcast( self, payload, ignore_web_socket_id=None, exclude_user_ids=None, **kwargs ): diff --git a/backend/src/baserow/ws/types.py b/backend/src/baserow/ws/types.py index 2b64045444..ca63e5635d 100644 --- a/backend/src/baserow/ws/types.py +++ b/backend/src/baserow/ws/types.py @@ -1,4 +1,4 @@ -from typing import Any, NamedTuple, TypedDict +from typing import Any, NamedTuple, TypedDict, Union class ChannelGroupMessage(NamedTuple): @@ -40,3 +40,28 @@ class BroadcastToUsersIndividualPayloadsMessage(TypedDict, total=False): type: str payload_map: PayloadMap ignore_web_socket_id: str | None + + +RealtimeEventPayload = Union[ + BroadcastToUsersMessage, + BroadcastToChannelGroupMessage, + BroadcastToUsersIndividualPayloadsMessage, +] + + +class RedisPresenceEntry(TypedDict): + user_id: int + + +class ActivePresenceEntry(TypedDict): + user_id: int + presence_id: str + + +class PresenceMembershipMessage(TypedDict): + """A presence join or leave event broadcast to a space's channel group.""" + + type: str + space: str + user_id: int + presence_id: str diff --git a/backend/tests/baserow/contrib/database/field/test_link_row_field_type.py b/backend/tests/baserow/contrib/database/field/test_link_row_field_type.py index 9f0521bf48..d0d00e65e5 100644 --- a/backend/tests/baserow/contrib/database/field/test_link_row_field_type.py +++ b/backend/tests/baserow/contrib/database/field/test_link_row_field_type.py @@ -1229,11 +1229,11 @@ def test_import_export_link_row_field(data_fixture): imported_workspace, exported_applications, BytesIO(), config, None ) imported_database = imported_applications[0] - imported_tables = imported_database.table_set.all() - imported_table = imported_tables[0] - imported_customers_table = imported_tables[1] - imported_customers_table_views = imported_customers_table.view_set.all() - imported_customers_table_view = imported_customers_table_views[0] + imported_table = imported_database.table_set.get(name="Example") + imported_customers_table = imported_database.table_set.get(name="Customers") + imported_customers_table_view = imported_customers_table.view_set.get( + name="Filtered" + ) imported_link_row_field = imported_table.field_set.all().first().specific imported_link_row_relation_field = ( imported_customers_table.field_set.all().first().specific diff --git a/backend/tests/baserow/contrib/database/ws/test_ws_table_tasks.py b/backend/tests/baserow/contrib/database/ws/test_ws_table_tasks.py index b87129f7aa..1b7073dbe1 100644 --- a/backend/tests/baserow/contrib/database/ws/test_ws_table_tasks.py +++ b/backend/tests/baserow/contrib/database/ws/test_ws_table_tasks.py @@ -9,6 +9,7 @@ ) from baserow.ws.tasks import send_messages_to_channel_group from baserow.ws.types import ChannelGroupMessage +from tests.baserow.contrib.database.utils import get_message, received_message @pytest.mark.asyncio @@ -42,24 +43,25 @@ async def test_unsubscribe_user_from_tables_and_rows_when_removed_from_workspace # Subscribe user to a table and a row from workspace 1 await communicator.send_json_to({"page": "table", "table_id": table_1.id}) - response = await communicator.receive_json_from(timeout=0.1) + assert await received_message(communicator, "page_add") is True await communicator.send_json_to( {"page": "row", "table_id": table_1.id, "row_id": row_1.id} ) - response = await communicator.receive_json_from(timeout=0.1) + assert await received_message(communicator, "page_add") is True # Subscribe user to a table from workspace 2 await communicator.send_json_to({"page": "table", "table_id": table_2.id}) - response = await communicator.receive_json_from(timeout=0.1) + assert await received_message(communicator, "page_add") is True # Send a message to consumers that user was removed from the workspace 1 await sync_to_async(unsubscribe_user_from_tables_when_removed_from_workspace)( user_1.id, workspace_1.id ) - # Receiving messages about being removed from the pages - response = await communicator.receive_json_from(timeout=0.1) + # Receiving messages about being removed from the pages (presence + # messages may be interleaved, so use get_message to skip them). + response = await get_message(communicator, "page_discard") assert response == { "page": "table", "parameters": { @@ -68,7 +70,7 @@ async def test_unsubscribe_user_from_tables_and_rows_when_removed_from_workspace "type": "page_discard", } - response = await communicator.receive_json_from(timeout=0.1) + response = await get_message(communicator, "page_discard") assert response == { "page": "row", "parameters": { diff --git a/backend/tests/baserow/ws/conftest.py b/backend/tests/baserow/ws/conftest.py index ec0fd1faa0..f06b20eb3d 100644 --- a/backend/tests/baserow/ws/conftest.py +++ b/backend/tests/baserow/ws/conftest.py @@ -1,3 +1,31 @@ import os +import pytest +from fakeredis.aioredis import FakeRedis +from loguru import logger + +from baserow.config.settings.test import _fake_redis_server +from baserow.core.async_redis import set_async_redis + os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") + + +@pytest.fixture +def caplog_loguru(caplog): + handler_id = logger.add(caplog.handler, format="{message}") + yield caplog + logger.remove(handler_id) + + +@pytest.fixture(autouse=True) +def _inject_fake_async_redis(): + """ + Inject a FakeRedis async client sharing the same FakeServer as + django-redis (sync) so both sync assertions and async production + code hit the same in-memory store. + """ + + client = FakeRedis(server=_fake_redis_server, decode_responses=True) + set_async_redis(client) + yield + set_async_redis(None) diff --git a/backend/tests/baserow/ws/test_presence.py b/backend/tests/baserow/ws/test_presence.py new file mode 100644 index 0000000000..72efab087a --- /dev/null +++ b/backend/tests/baserow/ws/test_presence.py @@ -0,0 +1,769 @@ +import json +import uuid +from unittest.mock import Mock + +import pytest +from channels.db import database_sync_to_async +from channels.layers import get_channel_layer +from channels.testing import WebsocketCommunicator + +from baserow.config.asgi import application +from baserow.core.async_redis import get_async_redis +from baserow.ws.presence import ( + PresenceHandler, + PresenceSpace, +) +from baserow.ws.registries import PageType, page_registry + +VALID_ONE_SEAT_ENTERPRISE_LICENSE = ( + # id: "1", instance_id: "1" + b"eyJ2ZXJzaW9uIjogMSwgImlkIjogIjUzODczYmVkLWJlNTQtNDEwZS04N2EzLTE2OTM2ODY2YjBiNiIsICJ2YWxpZF9mcm9tIjogIjIwMjItMTAtMDFUMDA6MDA6MDAiLCAidmFsaWRfdGhyb3VnaCI6ICIyMDY5LTA4LTA5VDIzOjU5OjU5IiwgInByb2R1Y3RfY29kZSI6ICJlbnRlcnByaXNlIiwgInNlYXRzIjogMSwgImlzc3VlZF9vbiI6ICIyMDIyLTEwLTI2VDE0OjQ4OjU0LjI1OTQyMyIsICJpc3N1ZWRfdG9fZW1haWwiOiAidGVzdEB0ZXN0LmNvbSIsICJpc3N1ZWRfdG9fbmFtZSI6ICJ0ZXN0QHRlc3QuY29tIiwgImluc3RhbmNlX2lkIjogIjEifQ==.B7aPXR0R4Fxr28AL7B5oopa2Yiz_MmEBZGdzSEHHLt4wECpnzjd_SF440KNLEZYA6WL1rhNkZ5znbjYIp6KdCqLdcm1XqNYOIKQvNTOtl9tUAYj_Qvhq1jhqSja-n3HFBjIh9Ve7a6T1PuaPLF1DoxSRGFZFXliMeJRBSzfTsiHiO22xRQ4GwafscYfUIWvIJJHGHtYEd9rk0tG6mfGEaQGB4e6KOsN-zw-bgLDBOKmKTGrVOkZnaGHBVVhUdpBn25r3CFWqHIApzUCo81zAA96fECHPlx_fBHhvIJXLsN5i3LdeJlwysg5SBO15Vt-tsdPmdcsec-fOzik-k3ib0A== " +) + + +def _enable_enterprise(): + from baserow.core.cache import local_cache + from baserow.core.models import Settings + from baserow_premium.license.models import License + + Settings.objects.update_or_create(defaults={"instance_id": "1"}) + License.objects.get_or_create( + cached_untrusted_instance_wide=True, + defaults={"license": VALID_ONE_SEAT_ENTERPRISE_LICENSE.decode()}, + ) + local_cache.clear() + + +GROUP = "test-presence-page-1" +SPACE_NAME = "test-space-1" +PRESENCE_KEY = f"presence:{SPACE_NAME}" + + +class PresenceTestPageType(PageType): + type = "test_presence_page" + parameters = ["test_param"] + + def can_add(self, user, web_socket_id, test_param, **kwargs): + return True + + def get_group_name(self, test_param, **kwargs): + return f"test-presence-page-{test_param}" + + def get_presence_space_name(self, test_param, **kwargs): + return f"test-space-{test_param}" + + +class NonPresencePageType(PageType): + type = "test_non_presence_page" + parameters = ["test_param"] + + def can_add(self, user, web_socket_id, test_param, **kwargs): + return True + + def get_group_name(self, test_param, **kwargs): + return f"test-non-presence-page-{test_param}" + + +class PresenceWithPermGroupPageType(PageType): + type = "test_presence_perm_page" + parameters = ["test_param"] + + def can_add(self, user, web_socket_id, test_param, **kwargs): + return True + + def get_group_name(self, test_param, **kwargs): + return f"test-presence-perm-page-{test_param}" + + def get_permission_channel_group_name(self, test_param, **kwargs): + return f"test-perm-group-{test_param}" + + def get_presence_space_name(self, test_param, **kwargs): + return f"test-perm-space-{test_param}" + + +@pytest.fixture +def presence_types(): + page_registry.register(PresenceTestPageType()) + page_registry.register(NonPresencePageType()) + page_registry.register(PresenceWithPermGroupPageType()) + yield + page_registry.unregister(PresenceTestPageType.type) + page_registry.unregister(NonPresencePageType.type) + page_registry.unregister(PresenceWithPermGroupPageType.type) + + +async def _connect(token): + ws_id = str(uuid.uuid4()) + communicator = WebsocketCommunicator( + application, + f"ws/core/?jwt_token={token}&web_socket_id={ws_id}", + headers=[(b"origin", b"http://localhost")], + ) + await communicator.connect() + await communicator.receive_json_from() # auth message + return communicator, ws_id + + +async def _subscribe(communicator, page="test_presence_page", test_param=1): + await communicator.send_json_to({"page": page, "test_param": test_param}) + return await communicator.receive_json_from(timeout=0.5) + + +async def _drain(communicator, timeout=0.1): + frames = [] + while not await communicator.receive_nothing(timeout=timeout): + frames.append(await communicator.receive_json_from()) + return frames + + +async def _subscribe_and_get_members( + communicator, page="test_presence_page", test_param=1 +): + """Subscribe and return (page_add, members_msg) tuple.""" + page_add = await _subscribe(communicator, page=page, test_param=test_param) + assert page_add["type"] == "page_add" + members_msg = await communicator.receive_json_from(timeout=0.5) + assert members_msg["type"] == "presence.members" + return page_add, members_msg + + +async def _presence_ids_in_redis(redis_key): + """Return set of presence_id keys stored in a Redis presence hash.""" + redis = await get_async_redis() + return set(await redis.hkeys(redis_key)) + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_subscribe_broadcasts_join_and_returns_members( + data_fixture, presence_types +): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + + comm_a, ws_a = await _connect(token_a) + page_add_a, active_a = await _subscribe_and_get_members(comm_a) + assert "presence_members" not in page_add_a + assert active_a["entries"] == [] + assert active_a["space"] == SPACE_NAME + + comm_b, ws_b = await _connect(token_b) + page_add_b, active_b = await _subscribe_and_get_members(comm_b) + assert "presence_members" not in page_add_b + assert len(active_b["entries"]) == 1 + assert active_b["entries"][0]["user_id"] == user_a.id + pid_a = active_b["entries"][0]["presence_id"] + + join = await comm_a.receive_json_from(timeout=0.5) + assert join["type"] == "presence.join" + assert join["space"] == SPACE_NAME + assert join["user_id"] == user_b.id + assert "presence_id" in join + assert "web_socket_id" not in join + + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_subscriber_does_not_receive_own_join(data_fixture, presence_types): + user_a, token_a = data_fixture.create_user_and_token() + comm_a, ws_a = await _connect(token_a) + await _subscribe_and_get_members(comm_a) + assert await comm_a.receive_nothing(timeout=0.3) + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_unsubscribe_broadcasts_leave_not_to_self(data_fixture, presence_types): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + + comm_a, ws_a = await _connect(token_a) + await _subscribe_and_get_members(comm_a) + comm_b, ws_b = await _connect(token_b) + _, active_b = await _subscribe_and_get_members(comm_b) + pid_a = active_b["entries"][0]["presence_id"] + await _drain(comm_a) # consume B's join + + await comm_b.send_json_to({"remove_page": "test_presence_page", "test_param": 1}) + b_frames = await _drain(comm_b, timeout=0.3) + assert any(f["type"] == "presence.space_discard" for f in b_frames) + assert any(f["type"] == "page_discard" for f in b_frames) + assert not any(f["type"] == "presence.leave" for f in b_frames) + + leave = await comm_a.receive_json_from(timeout=0.5) + assert leave["type"] == "presence.leave" + assert leave["space"] == SPACE_NAME + assert leave["user_id"] == user_b.id + assert "presence_id" in leave + assert "web_socket_id" not in leave + + pids = await _presence_ids_in_redis(PRESENCE_KEY) + assert pid_a in pids + assert leave["presence_id"] not in pids + + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_disconnect_broadcasts_leave(data_fixture, presence_types): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + + comm_a, ws_a = await _connect(token_a) + await _subscribe_and_get_members(comm_a) + comm_b, ws_b = await _connect(token_b) + _, active_b = await _subscribe_and_get_members(comm_b) + pid_a = active_b["entries"][0]["presence_id"] + await _drain(comm_a) # consume B's join + + await comm_b.disconnect() + + frames = await _drain(comm_a, timeout=0.3) + assert frames, "expected a presence.leave for the disconnected session" + assert all( + f["type"] == "presence.leave" + and f["user_id"] == user_b.id + and "presence_id" in f + for f in frames + ) + + pids = await _presence_ids_in_redis(PRESENCE_KEY) + assert pid_a in pids + + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_non_presence_page_omits_members(data_fixture, presence_types): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + + comm_a, ws_a = await _connect(token_a) + page_add_a = await _subscribe(comm_a, page="test_non_presence_page") + assert page_add_a["type"] == "page_add" + assert "presence_members" not in page_add_a + assert await comm_a.receive_nothing(timeout=0.3) + + comm_b, ws_b = await _connect(token_b) + page_add_b = await _subscribe(comm_b, page="test_non_presence_page") + assert "presence_members" not in page_add_b + assert await comm_b.receive_nothing(timeout=0.3) + + assert await comm_a.receive_nothing(timeout=0.3) + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_channel_isolation_no_cross_delivery(data_fixture, presence_types): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + + comm_a, ws_a = await _connect(token_a) + await _subscribe_and_get_members(comm_a, test_param=1) + + comm_b, ws_b = await _connect(token_b) + await _subscribe_and_get_members(comm_b, test_param=2) + + assert await comm_a.receive_nothing(timeout=0.3) + + pids_1 = await _presence_ids_in_redis("presence:test-space-1") + pids_2 = await _presence_ids_in_redis("presence:test-space-2") + assert len(pids_1) == 1 + assert len(pids_2) == 1 + assert pids_1.isdisjoint(pids_2) + + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_disconnect_removes_from_all_subscribed_channels( + data_fixture, presence_types +): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + + comm_a, ws_a = await _connect(token_a) + await _subscribe_and_get_members(comm_a, test_param=1) + await _subscribe_and_get_members(comm_a, test_param=2) + + comm_b, ws_b = await _connect(token_b) + _, active_b = await _subscribe_and_get_members(comm_b, test_param=1) + pid_a = active_b["entries"][0]["presence_id"] + await _drain(comm_b) + + await comm_a.disconnect() + + leave = await comm_b.receive_json_from(timeout=0.5) + assert leave["type"] == "presence.leave" + assert leave["presence_id"] == pid_a + + assert len(await _presence_ids_in_redis("presence:test-space-1")) == 1 # only B + assert len(await _presence_ids_in_redis("presence:test-space-2")) == 0 + + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_multi_tab_same_user_separate_entries(data_fixture, presence_types): + user_a, token_a = data_fixture.create_user_and_token() + + comm_1, ws_1 = await _connect(token_a) + _, active_1 = await _subscribe_and_get_members(comm_1) + assert active_1["entries"] == [] + + comm_2, ws_2 = await _connect(token_a) + _, active_2 = await _subscribe_and_get_members(comm_2) + assert len(active_2["entries"]) == 1 + assert active_2["entries"][0]["user_id"] == user_a.id + pid_1 = active_2["entries"][0]["presence_id"] + + join = await comm_1.receive_json_from(timeout=0.5) + assert join["type"] == "presence.join" + assert join["user_id"] == user_a.id + pid_2 = join["presence_id"] + + pids = await _presence_ids_in_redis(PRESENCE_KEY) + assert pid_1 in pids + assert pid_2 in pids + assert pid_1 != pid_2 + + await comm_1.disconnect() + await comm_2.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_corrupt_entry_cleaned_on_new_subscribe(data_fixture, presence_types): + redis = await get_async_redis() + await redis.hset(PRESENCE_KEY, "corrupt-pid", "not-json") + + user_a, token_a = data_fixture.create_user_and_token() + comm_a, ws_a = await _connect(token_a) + _, members_resp = await _subscribe_and_get_members(comm_a) + + assert members_resp["entries"] == [] + assert not await redis.hexists(PRESENCE_KEY, "corrupt-pid") + assert len(await _presence_ids_in_redis(PRESENCE_KEY)) == 1 + + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_double_subscribe_does_not_broadcast_duplicate_join( + data_fixture, presence_types +): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + + comm_a, ws_a = await _connect(token_a) + await _subscribe_and_get_members(comm_a) + + comm_b, ws_b = await _connect(token_b) + await _subscribe_and_get_members(comm_b) + await _drain(comm_a) + + # second subscribe — already in space, no members or join broadcast + await comm_b.send_json_to({"page": "test_presence_page", "test_param": 1}) + page_add_2 = await comm_b.receive_json_from(timeout=0.5) + assert page_add_2["type"] == "page_add" + assert await comm_b.receive_nothing(timeout=0.3) + assert await comm_a.receive_nothing(timeout=0.3) + + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_redis_key_has_sliding_ttl(data_fixture, presence_types): + user_a, token_a = data_fixture.create_user_and_token() + comm_a, ws_a = await _connect(token_a) + await _subscribe_and_get_members(comm_a) + + redis = await get_async_redis() + ttl = await redis.ttl(PRESENCE_KEY) + assert 0 < ttl <= 43200 + + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.websockets +async def test_presence_space_and_handler_members_self_exclusion(): + space = PresenceSpace("g") + mock_ctx_1 = Mock() + mock_ctx_1.channel_layer = Mock() + mock_ctx_1.channel_name = "chan-1" + mock_ctx_2 = Mock() + mock_ctx_2.channel_layer = Mock() + mock_ctx_2.channel_name = "chan-2" + h1 = PresenceHandler(consumer=mock_ctx_1, web_socket_id="ws-1", user_id=7) + h2 = PresenceHandler(consumer=mock_ctx_2, web_socket_id="ws-2", user_id=9) + + assert await h1._join(space) == [] + active = await h2._join(space) + assert len(active) == 1 + assert active[0]["user_id"] == 7 + assert active[0]["presence_id"] == h1.presence_id + + await h1._leave(space) + active = await space.get_members(exclude_presence_id=h2.presence_id) + assert active == [] + + +@pytest.mark.asyncio +@pytest.mark.websockets +async def test_presence_space_cleanup_removes_corrupt_entries(): + redis = await get_async_redis() + space = PresenceSpace("g2") + await redis.hset(space.redis_key, "valid", json.dumps({"user_id": 1})) + await redis.hset(space.redis_key, "corrupt", "not-json") + + active = await space.get_members() + assert len(active) == 1 + assert active[0]["user_id"] == 1 + assert active[0]["presence_id"] == "valid" + assert await redis.hexists(space.redis_key, "valid") is True + assert await redis.hexists(space.redis_key, "corrupt") is False + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_permission_revocation_removes_presence_and_broadcasts_leave( + data_fixture, presence_types +): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + + comm_a, ws_a = await _connect(token_a) + await _subscribe_and_get_members(comm_a, page="test_presence_perm_page") + + comm_b, ws_b = await _connect(token_b) + _, active_b = await _subscribe_and_get_members( + comm_b, page="test_presence_perm_page" + ) + pid_a = active_b["entries"][0]["presence_id"] + await _drain(comm_a) + + pres_key = "presence:test-perm-space-1" + pids_before = await _presence_ids_in_redis(pres_key) + assert pid_a in pids_before + assert len(pids_before) == 2 + + channel_layer = get_channel_layer() + await channel_layer.group_send( + "test-perm-group-1", + { + "type": "users_removed_from_permission_group", + "user_ids_to_remove": [user_b.id], + "permission_group_name": "test-perm-group-1", + }, + ) + + b_frames = await _drain(comm_b, timeout=0.5) + assert any(f["type"] == "page_discard" for f in b_frames) + + a_frames = await _drain(comm_a, timeout=0.5) + leaves = [f for f in a_frames if f["type"] == "presence.leave"] + assert len(leaves) == 1 + assert leaves[0]["user_id"] == user_b.id + pid_b = leaves[0]["presence_id"] + + pids_after = await _presence_ids_in_redis(pres_key) + assert pid_b not in pids_after + assert pid_a in pids_after + + await comm_a.disconnect() + await comm_b.disconnect() + + +async def _create_enterprise_table_with_restricted_view(data_fixture): + setup = await database_sync_to_async( + lambda: ( + _enable_enterprise(), + data_fixture.create_user_and_token(), + data_fixture.create_user_and_token(), + ) + )() + _, (user_a, token_a), (user_b, token_b) = setup + + _, _, table, restricted_view = await database_sync_to_async( + lambda: ( + (w := data_fixture.create_workspace(user=user_a, members=[user_b])), + (db := data_fixture.create_database_application(workspace=w)), + (t := data_fixture.create_database_table(database=db)), + data_fixture.create_grid_view(table=t, ownership_type="restricted"), + ) + )() + return user_a, token_a, user_b, token_b, table, restricted_view + + +# --------------------------------------------------------------------------- +# Integration tests with real page types +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_table_page_subscribe_returns_members(data_fixture): + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a, members=[user_b]) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + page_add_a = await comm_a.receive_json_from(timeout=1) + assert page_add_a["type"] == "page_add" + active_a = await comm_a.receive_json_from(timeout=1) + assert active_a["type"] == "presence.members" + assert active_a["space"] == f"table-{table.id}" + assert active_a["entries"] == [] + + comm_b, ws_b = await _connect(token_b) + await comm_b.send_json_to({"page": "table", "table_id": table.id}) + page_add_b = await comm_b.receive_json_from(timeout=1) + assert page_add_b["type"] == "page_add" + active_b = await comm_b.receive_json_from(timeout=1) + assert active_b["type"] == "presence.members" + assert len(active_b["entries"]) == 1 + assert active_b["entries"][0]["user_id"] == user_a.id + assert "presence_id" in active_b["entries"][0] + + join = await comm_a.receive_json_from(timeout=1) + assert join["type"] == "presence.join" + assert join["user_id"] == user_b.id + + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_restricted_view_excluded_from_presence(data_fixture): + """Restricted views return None for presence — no join/members events.""" + + ( + user_a, + token_a, + user_b, + token_b, + table, + restricted_view, + ) = await _create_enterprise_table_with_restricted_view(data_fixture) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + await comm_a.receive_json_from(timeout=1) # page_add + active_a = await comm_a.receive_json_from(timeout=1) + assert active_a["space"] == f"table-{table.id}" + + comm_b, ws_b = await _connect(token_b) + await comm_b.send_json_to( + { + "page": "restricted_view", + "restricted_view_id": restricted_view.id, + } + ) + page_add_b = await comm_b.receive_json_from(timeout=1) + assert page_add_b["type"] == "page_add" + # No presence.members expected — restricted views opt out + assert await comm_b.receive_nothing(timeout=0.5) + # Table subscriber should NOT see a join from the restricted view user + assert await comm_a.receive_nothing(timeout=0.5) + + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_public_view_has_no_presence(data_fixture): + user_a, token_a = data_fixture.create_user_and_token() + workspace = data_fixture.create_workspace(user=user_a) + database = data_fixture.create_database_application(workspace=workspace) + table = data_fixture.create_database_table(database=database) + view = data_fixture.create_grid_view(table=table, public=True) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "view", "slug": view.slug}) + page_add = await comm_a.receive_json_from(timeout=1) + assert page_add["type"] == "page_add" + assert await comm_a.receive_nothing(timeout=0.5) + + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_restricted_view_no_presence_entries_in_redis(data_fixture): + """Restricted view subscription creates no Redis presence entries.""" + + ( + user_a, + token_a, + user_b, + token_b, + table, + restricted_view, + ) = await _create_enterprise_table_with_restricted_view(data_fixture) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to( + { + "page": "restricted_view", + "restricted_view_id": restricted_view.id, + } + ) + await comm_a.receive_json_from(timeout=1) # page_add + assert await comm_a.receive_nothing(timeout=0.5) + + pids = await _presence_ids_in_redis(f"presence:table-{table.id}") + assert len(pids) == 0 + + await comm_a.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_independent_space_isolation_on_partial_unsubscribe( + data_fixture, presence_types +): + """Removing one page leaves its space; other spaces remain unaffected.""" + user_a, token_a = data_fixture.create_user_and_token() + user_b, token_b = data_fixture.create_user_and_token() + + comm_a, ws_a = await _connect(token_a) + await _subscribe_and_get_members(comm_a, test_param=1) + + comm_b, ws_b = await _connect(token_b) + _, active_b = await _subscribe_and_get_members(comm_b, test_param=1) + # Also subscribe to perm page with same param (different space) + await _subscribe_and_get_members( + comm_b, page="test_presence_perm_page", test_param=1 + ) + await _drain(comm_a) + + await comm_b.send_json_to({"remove_page": "test_presence_page", "test_param": 1}) + b_frames = await _drain(comm_b, timeout=0.5) + assert any(f["type"] == "page_discard" for f in b_frames) + + a_frames = await _drain(comm_a, timeout=0.5) + leaves = [f for f in a_frames if f["type"] == "presence.leave"] + assert len(leaves) == 1 + + # But the perm page (different space) should still have B's presence + assert len(await _presence_ids_in_redis("presence:test-perm-space-1")) == 1 + assert len(await _presence_ids_in_redis("presence:test-space-1")) == 1 # only A + + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.django_db(transaction=True) +@pytest.mark.websockets +async def test_permission_revocation_no_presence_leave_for_restricted_view( + data_fixture, +): + """Restricted view has no presence, so permission revocation produces + no presence.leave event on the table subscriber side.""" + + ( + user_a, + token_a, + user_b, + token_b, + table, + restricted_view, + ) = await _create_enterprise_table_with_restricted_view(data_fixture) + + comm_a, ws_a = await _connect(token_a) + await comm_a.send_json_to({"page": "table", "table_id": table.id}) + await _drain(comm_a, timeout=0.5) + + comm_b, ws_b = await _connect(token_b) + await comm_b.send_json_to( + { + "page": "restricted_view", + "restricted_view_id": restricted_view.id, + } + ) + await _drain(comm_b, timeout=0.5) + await _drain(comm_a, timeout=0.5) + + perm_group = f"permissions-restricted-view-{restricted_view.id}" + channel_layer = get_channel_layer() + await channel_layer.group_send( + perm_group, + { + "type": "users_removed_from_permission_group", + "user_ids_to_remove": [user_b.id], + "permission_group_name": perm_group, + }, + ) + + b_frames = await _drain(comm_b, timeout=0.5) + assert any(f["type"] == "page_discard" for f in b_frames) + + # No presence.leave on table side — restricted view had no presence + a_frames = await _drain(comm_a, timeout=0.5) + leaves = [f for f in a_frames if f["type"] == "presence.leave"] + assert len(leaves) == 0 + + await comm_a.disconnect() + await comm_b.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.websockets +async def test_invalid_shape_entries_cleaned_from_redis(): + """Wrong-shape JSON entries are treated as corrupt and removed (R20).""" + + redis = await get_async_redis() + space = PresenceSpace("g3") + await redis.hset(space.redis_key, "valid", json.dumps({"user_id": 42})) + await redis.hset(space.redis_key, "list", json.dumps([1, 2, 3])) + await redis.hset(space.redis_key, "empty", json.dumps({})) + await redis.hset(space.redis_key, "null-uid", json.dumps({"user_id": None})) + await redis.hset(space.redis_key, "str-uid", json.dumps({"user_id": "abc"})) + + active = await space.get_members() + assert len(active) == 1 + assert active[0]["user_id"] == 42 + assert active[0]["presence_id"] == "valid" + + assert await redis.hexists(space.redis_key, "valid") is True + for bad_key in ("list", "empty", "null-uid", "str-uid"): + assert await redis.hexists(space.redis_key, bad_key) is False diff --git a/backend/tests/baserow/ws/test_ws_consumers.py b/backend/tests/baserow/ws/test_ws_consumers.py index 715ac04073..e365999307 100644 --- a/backend/tests/baserow/ws/test_ws_consumers.py +++ b/backend/tests/baserow/ws/test_ws_consumers.py @@ -6,6 +6,7 @@ from baserow.config.asgi import application from baserow.ws.auth import ANONYMOUS_USER_TOKEN from baserow.ws.consumers import CoreConsumer, PageContext, PageScope, SubscribedPages +from baserow.ws.presence import PresenceHandler from baserow.ws.realtime_events import FIRST_CONNECT_CURSOR, NO_REPLAY_AVAILABLE from baserow.ws.registries import PageType, page_registry @@ -297,9 +298,14 @@ async def test_core_consumer_remove_all_page_scopes(data_fixture, test_page_type pages.add(scope_2) consumer = CoreConsumer() - consumer.scope = {"pages": pages, "user": user_1, "web_socket_id": 123} + consumer.scope = {"pages": pages, "user": user_1, "web_socket_id": "ws-test"} consumer.channel_name = "test_channel_name" consumer.channel_layer = AsyncMock() + consumer.presence = PresenceHandler( + consumer=consumer, + web_socket_id="ws-test", + user_id=user_1.id, + ) async def base_send(message): pass @@ -519,6 +525,7 @@ async def test_core_consumer_records_disconnect_metric_when_cleanup_fails(): from baserow.ws import consumers as ws_consumers consumer = CoreConsumer() + consumer.scope = {} consumer.channel_name = "test_channel_name" consumer.channel_layer = AsyncMock() # A Redis hiccup during cleanup must not swallow the disconnect metric. diff --git a/backend/tests/baserow/ws/test_ws_realtime_events.py b/backend/tests/baserow/ws/test_ws_realtime_events.py index 67c2173c36..05be0e00e8 100644 --- a/backend/tests/baserow/ws/test_ws_realtime_events.py +++ b/backend/tests/baserow/ws/test_ws_realtime_events.py @@ -1525,6 +1525,8 @@ async def test_replay_events_replays_page_group_event(data_fixture): await communicator.send_json_to({"page": "table", "table_id": table.id}) page_response = await communicator.receive_json_from(timeout=1) assert page_response["type"] == "page_add" + members_resp = await communicator.receive_json_from(timeout=1) + assert members_resp["type"] == "presence.members" await communicator.send_json_to( { @@ -1584,6 +1586,8 @@ async def test_replay_events_forces_refresh_when_replay_event_type_is_unsupporte await communicator.send_json_to({"page": "table", "table_id": table.id}) page_response = await communicator.receive_json_from(timeout=1) assert page_response["type"] == "page_add" + members_resp = await communicator.receive_json_from(timeout=1) + assert members_resp["type"] == "presence.members" log_messages: list[str] = [] sink_id = logger.add( diff --git a/backend/tests/baserow/ws/test_ws_tasks.py b/backend/tests/baserow/ws/test_ws_tasks.py index c17b42ec0e..27f6e911a7 100755 --- a/backend/tests/baserow/ws/test_ws_tasks.py +++ b/backend/tests/baserow/ws/test_ws_tasks.py @@ -156,6 +156,8 @@ async def test_broadcast_to_channel_group(data_fixture): assert response["type"] == "page_add" assert response["page"] == "table" assert response["parameters"]["table_id"] == table_1.id + members_resp = await communicator_1.receive_json_from(0.1) + assert members_resp["type"] == "presence.members" await sync_to_async(broadcast_to_channel_group)( f"table-{table_1.id}", {"message": "test"} @@ -170,12 +172,19 @@ async def test_broadcast_to_channel_group(data_fixture): assert response["type"] == "page_add" assert response["page"] == "table" assert response["parameters"]["table_id"] == table_3.id + members_resp = await communicator_1.receive_json_from(0.1) + assert members_resp["type"] == "presence.members" await communicator_2.send_json_to({"page": "table", "table_id": table_3.id}) response = await communicator_2.receive_json_from(0.1) assert response["type"] == "page_add" assert response["page"] == "table" assert response["parameters"]["table_id"] == table_3.id + members_resp = await communicator_2.receive_json_from(0.1) + assert members_resp["type"] == "presence.members" + # comm_1 receives presence.join when comm_2 subscribes to the same table + join = await communicator_1.receive_json_from(0.1) + assert join["type"] == "presence.join" await sync_to_async(broadcast_to_channel_group)( f"table-{table_3.id}", {"message": "test2"} diff --git a/docs/development/feature-flags.md b/docs/development/feature-flags.md index 1f2bdad33e..39e52992cb 100644 --- a/docs/development/feature-flags.md +++ b/docs/development/feature-flags.md @@ -7,6 +7,8 @@ and/or released. Add/remove features flags to the list below: +- `user_presence` - Enables real-time user presence indicators showing which users are viewing the same table. Displays avatar badges in the grid view header. + ## Enabling feature flags To enable specific feature flags set the environment variable diff --git a/docs/technical/realtime-presence.md b/docs/technical/realtime-presence.md new file mode 100644 index 0000000000..0a23bfcdb4 --- /dev/null +++ b/docs/technical/realtime-presence.md @@ -0,0 +1,121 @@ +# Realtime Presence + +This document defines the **language and conceptual model** of Baserow's presence feature. + +Presence is built on Baserow's WebSocket infrastructure; see [realtime-reliability.md](realtime-reliability.md) for connection lifecycle, reconnection, and event durability. This document reuses its terms (web socket ID, page subscriptions, channel groups) without re-defining them. + +Presence answers one question for people working in the same place: **who else is here, and what are they doing?** — bounded by what each viewer is permitted to see. + +> **Presence is best-effort and partial by design.** It reflects only what is observable over live connections: who is connected and looking, and their focus. Mutations that arrive through other paths — imports, API calls, data sync, server jobs — have no connection and no focus, and never appear. Presence is descriptive ("who we know is here right now"); it is **never an activity feed, an audit log, or a security/completeness guarantee**. An empty presence space does not mean nobody touched the data. + +--- + +## Terminology + +| Term | Definition | +|---|---| +| **Presence** | The feature: who is present on a shared place and what they're doing, bounded by each viewer's permissions. Best-effort and partial — not an activity/audit/security record. | +| **Connection** | One WebSocket connection, identified by a `web_socket_id` at the transport layer. A single user may have several (multiple tabs). | +| **Member** | A connection's representation within a presence space, identified by a `presence_id` (an opaque UUID generated per connection, separate from `web_socket_id`). `presence_id` is the key in Redis, in WS payloads, and in the frontend store. `web_socket_id` is never exposed in presence payloads — it stays in the transport layer for routing and self-echo suppression. | +| **User presence** | The user-visible roll-up: "User X is here," derived by collapsing a space's members by `user_id`. An avatar bar is one way to surface it. | +| **Presence space** | The single logical location presence is tracked for — e.g. one table's grid, whose presence space is `table-42` (Redis key `presence:table-42`, channel group `presence.table-42`). Every connection viewing that location shares one space, **independent of how many data channel groups back it**. One space per location; a presence concept, distinct from any single channel group. | +| **Presence visibility** | The per-recipient decision "should this recipient see that a connection is present *at all*?" Evaluated — depends on how both the observer and the observed entered the space, not a static flag on the connection. | +| **Presence focus** | What one connection is doing within a space — a typed value (e.g. a selected cell) or nothing. Each connection has at most one current focus **per space**; only the latest matters (it is a current state, not a history). | +| **Focus visibility** | The per-recipient decision "should this recipient see this focus?" Lets one space serve viewers with different permissions without leaking what a viewer may not see. | +| **Presence focus type** | A registered kind of focus (e.g. `cell`, `row`) that owns its payload shape, the page types it applies to, and its visibility rule. Pluggable; not owned by any one page type. | +| **Presence focus staleness** | A focus state no longer trustworthy. Enforced client-side, per focus type (e.g. an `editing: true` indicator older than ~30s). | + +### Entity relationships + +| Relationship | Cardinality | Example | +|---|---|---| +| User → Connections | 1:N | 3 browser tabs = 3 connections | +| Connection → Presence spaces | 1:N | One tab on a table grid and an expanded-row modal | +| Presence space → Members | N:M | A space has 5 members from 3 users | +| Member → Presence focus (per space) | 1:1 | A tab's grid focus and modal focus are independent; within a space only the latest focus is kept | + +### Example: one space, many channel groups + +Bram (full access) and Davide (a restricted view) both open table 42: + +- **Bram** subscribes via the `table` page; his connection belongs to the channel group `table-42`. He is looking at rows 1–100. +- **Davide** cannot listen to the whole table, so he subscribes via the `restricted_view` page; his connection belongs to a *different* channel group, `restricted-view-7`. His view exposes only rows 50–75, and that is what he is looking at. + +> **V1 note:** In the initial release, restricted views are **excluded** from presence entirely — `RestrictedViewPageType.get_presence_space_name` returns `None`. This avoids leaking full-access user IDs to restricted view users before a proper asymmetric visibility gate is in place. The example below describes the **target model** for a future version. + +Conceptually, they are on different channel groups but interact in the **same presence space**, `table-42`. So, where visibility allows, they see each other: + +- Whether Davide sees Bram's avatar at all depends on **presence visibility** — because Davide entered via a restricted view, the visibility rule might hide full-access users from him. Bram, with full access, always sees Davide. +- They see each other's **focus** on rows they can both see (rows 50–75) — **focus visibility**. +- If Bram selects a cell on row 30, Davide does **not** see it: row 30 is outside what Davide may see, so focus visibility withholds it — even though both are in the same space. + +--- + +## The conceptual model + +### One space per place + +A presence space is a logical location, not a transport channel. The same place (e.g. a table) is delivered to different users over different per-permission channel groups; presence collapses all of them into **one** space so that everyone viewing that place can see each other. A host (today a page type) opts in to presence and declares which logical place each subscription belongs to; permission-tiered entry points for the same place resolve to the same space. Presence is not tied to tables — any place a host enables it for (a grid, a dashboard, a builder page, …) is a presence space; table 42 is just the running example. + +### Joining and leaving + +A connection becomes present when it subscribes to a presence-enabled place, and is removed when it unsubscribes or disconnects. A connection can be present in several spaces at once (e.g. a grid and an expanded-row modal), each tracked independently; a disconnect clears all of them. + +Cleanup leans on the **disconnect signal**, which is reliable because both runtime stacks run a server-side WebSocket keepalive that closes connections which stop responding — so a dead client is detected and removed within seconds, while a merely idle-but-connected client (whose browser keeps answering keepalives automatically) correctly stays present. Any further cleanup is a best-effort backstop, never the primary mechanism. Because presence is best-effort, a connection's mere presence is not guaranteed to be complete or permanent. + +### Two-axis visibility + +Two independent, per-recipient questions decide what a viewer sees. They compose — a connection must be presence-visible before its focus can be seen at all: + +| Axis | Question | Owner | Default | +|---|---|---|---| +| **Presence visibility** | May this viewer see that a connection is present at all? | The place's host | Visible | +| **Focus visibility** | Given the connection is visible, may this viewer see its focus? | The focus type | Visible | + +**Presence visibility** is evaluated per recipient — the host determines who sees whom based on how each entered the space. A full-access viewer may see everyone; a restricted viewer may see only others at the same access level. The rule is **asymmetric by design**: who you see depends on your entry point, not theirs. + +**Focus visibility** is how one shared space safely serves viewers with different permissions: a viewer sees focus only on the rows and fields they are permitted to see. + +Focus visibility is all-or-nothing: a viewer sees a connection's focus in full, or not at all. + +### Focus + +Focus is what a connection is doing within a space — a single current value per space, replaced on each change, with no history. + +Before a focus is shown, three questions are answered, in order: + +1. **Is presence enabled here?** — a property of the place. +2. **Does this kind of place support this kind of focus?** — *applicability*, not permission. A grid has cells and rows; a dashboard has neither. Both full and restricted grid views support the same focus kinds and emit them identically. +3. **Is the focus well-formed?** — invalid focus is silently dropped. + +**Emitting and seeing are separate.** Which page types may *emit* a focus kind is independent of who may *see* it. Two users on the same grid emit cell focus identically; the difference in what each *sees* is purely the area-visibility rule (which rows/fields each may see), never the page type. + +**Staleness** is judged on the client, per focus type: a state implying active engagement (someone is *editing*) stops being shown after a short silence, because it is no longer trustworthy. Passive states linger longer or indefinitely. + +--- + +## Membership + +Presence uses **one** space per place and enforces visibility *within* it, per recipient — rather than relying on the permission-tiered split of the data channels. A connection is in a space only because it was already allowed to subscribe to a page that maps there — presence adds no separate permission gate. + +--- + +## Presentation behavior + +- **Avatars show unique users**, not connections — multiple tabs of one person collapse to a single avatar, with a deterministic per-user colour. When more users are present than fit, the rest collapse into a counter. +- **A user does not see their own focus** as a presence indicator — the native selection UI already shows it. Their own avatar is **not** shown in the presence bar — only other users appear. +- **Focus highlights** show another user's selected cell or row (with their colour and initials), and an editing indicator when they are actively editing. Highlights render only for what is currently on screen. +- **Focus emission is debounced per space.** Rapid navigation within a space emits only the final position (others see where you land, not each step), and activity in one space never interferes with another. Two purposes: a clean end-state for viewers, and protection against flooding. +- **When several users focus the same target**, their labels collapse into a count. + +--- + +## Future capabilities (permitted, not built) + +The model deliberately *reserves room for* these without building them now; each can be added without changing the concepts or the wire-level contract: + +- **Filtered focus on restricted views** — showing a viewer only the focus on rows/fields they may see. The focus-visibility rule is the reserved seam; today it passes everything through. +- **Entry-point-aware presence visibility** — visibility rules that depend on how each party entered the space (e.g. restricted viewers not seeing full-access users, admins seeing anonymous public-view users). The evaluated presence-visibility model is the reserved seam. In V1, restricted views are excluded from presence entirely; in V2 they will join with asymmetric visibility (full-access sees restricted users, not vice versa). +- **Stronger cleanup of abandoned entries** — a backstop for the rare case where a disconnect signal is lost. Disconnect remains the primary mechanism either way. + + diff --git a/enterprise/backend/src/baserow_enterprise/ws/pages.py b/enterprise/backend/src/baserow_enterprise/ws/pages.py index 82fcb79a61..bdd84595d0 100644 --- a/enterprise/backend/src/baserow_enterprise/ws/pages.py +++ b/enterprise/backend/src/baserow_enterprise/ws/pages.py @@ -31,8 +31,6 @@ def can_add(self, user, web_socket_id, restricted_view_id, **kwargs): if view.ownership_type != RestrictedViewOwnershipType.type: return False - # Check if the user has any permissions to access the view. If so, - # we'll allow the user to listen for events. CoreHandler().check_permissions( user, ListenToAllRestrictedViewEventsOperationType.type, @@ -49,3 +47,6 @@ def get_group_name(self, restricted_view_id, **kwargs): def get_permission_channel_group_name(self, restricted_view_id, **kwargs): return f"permissions-restricted-view-{restricted_view_id}" + + def get_presence_space_name(self, **kwargs) -> str | None: + return None diff --git a/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py index 382bed91e4..6ae92af499 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/data_sync/test_enterprise_data_sync_handler.py @@ -838,7 +838,7 @@ def test_periodic_sync_failure_deactivation_shows_failure_message( @override_settings(DEBUG=True) @patch("baserow.contrib.database.table.signals.table_created.send") def test_create_two_way_data_sync_table( - send_mock, enterprise_data_fixture, create_postgresql_test_table + send_mock, enterprise_data_fixture, create_postgresql_test_table, synced_roles ): enterprise_data_fixture.enable_enterprise() @@ -879,7 +879,7 @@ def test_create_two_way_data_sync_table( @override_settings(DEBUG=True) @patch("baserow.contrib.database.table.signals.table_created.send") def test_create_two_way_data_sync_table_and_add_properties( - send_mock, enterprise_data_fixture, create_postgresql_test_table + send_mock, enterprise_data_fixture, create_postgresql_test_table, synced_roles ): enterprise_data_fixture.enable_enterprise() @@ -931,7 +931,7 @@ def test_create_two_way_data_sync_table_and_add_properties( @override_settings(DEBUG=True) @patch("baserow.contrib.database.table.signals.table_created.send") def test_create_and_unset_two_way_data_sync_table( - send_mock, enterprise_data_fixture, create_postgresql_test_table + send_mock, enterprise_data_fixture, create_postgresql_test_table, synced_roles ): enterprise_data_fixture.enable_enterprise() @@ -983,7 +983,7 @@ def test_create_and_unset_two_way_data_sync_table( @override_settings(DEBUG=True) @patch("baserow.contrib.database.table.signals.table_created.send") def test_create_and_set_two_way_data_sync_table( - send_mock, enterprise_data_fixture, create_postgresql_test_table + send_mock, enterprise_data_fixture, create_postgresql_test_table, synced_roles ): enterprise_data_fixture.enable_enterprise() diff --git a/enterprise/backend/tests/baserow_enterprise_tests/ws/test_role_signals.py b/enterprise/backend/tests/baserow_enterprise_tests/ws/test_role_signals.py index 198868b7aa..602b150a31 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/ws/test_role_signals.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/ws/test_role_signals.py @@ -8,7 +8,6 @@ from baserow.config.asgi import application from baserow.core.apps import sync_operations_after_migrate -from baserow.test_utils.helpers import AnyInt from baserow.ws.tasks import send_messages_to_channel_group from baserow.ws.types import ChannelGroupMessage from baserow_enterprise.apps import sync_default_roles_after_migrate @@ -16,7 +15,7 @@ from baserow_enterprise.role.handler import RoleAssignmentHandler from baserow_enterprise.role.models import Role from baserow_enterprise.teams.handler import TeamHandler -from tests.baserow.contrib.database.utils import received_message +from tests.baserow.contrib.database.utils import get_message, received_message # We have to run this every time between test executions since we are using @@ -481,22 +480,21 @@ async def test_unsubscribe_user_from_tables_and_rows_when_role_updated(data_fixt # Subscribe user to a table and a row from workspace 1 await communicator.send_json_to({"page": "table", "table_id": table_1.id}) - response = await communicator.receive_json_from(timeout=0.1) + assert await received_message(communicator, "page_add") is True await communicator.send_json_to( {"page": "row", "table_id": table_1.id, "row_id": row_1.id} ) - response = await communicator.receive_json_from(timeout=0.1) + assert await received_message(communicator, "page_add") is True # Remove role from user await sync_to_async(RoleAssignmentHandler().assign_role)( user_1, workspace_1, no_access_role ) - response = await communicator.receive_json_from(timeout=0.1) - - # Receiving messages about being removed from the pages - response = await communicator.receive_json_from(timeout=0.1) + # Receiving messages about being removed from the pages (presence + # messages may be interleaved, so use get_message to skip them). + response = await get_message(communicator, "page_discard") assert response == { "page": "table", "parameters": { @@ -505,7 +503,7 @@ async def test_unsubscribe_user_from_tables_and_rows_when_role_updated(data_fixt "type": "page_discard", } - response = await communicator.receive_json_from(timeout=0.1) + response = await get_message(communicator, "page_discard") assert response == { "page": "row", "parameters": { @@ -515,12 +513,9 @@ async def test_unsubscribe_user_from_tables_and_rows_when_role_updated(data_fixt "type": "page_discard", } - response = await communicator.receive_json_from(timeout=0.1) - assert response == { - "type": "permissions_updated", - "workspace_id": workspace_1.id, - "_event_id": AnyInt(), - } + response = await get_message(communicator, "permissions_updated") + assert response["type"] == "permissions_updated" + assert response["workspace_id"] == workspace_1.id # User should not receive any messages to a table in workspace 1 await send_messages_to_channel_group( @@ -581,18 +576,19 @@ async def test_unsubscribe_user_from_tables_and_rows_when_team_trashed( # Subscribe user to a table and a row from workspace 1 await communicator.send_json_to({"page": "table", "table_id": table_1.id}) - response = await communicator.receive_json_from(timeout=0.1) + assert await received_message(communicator, "page_add") is True await communicator.send_json_to( {"page": "row", "table_id": table_1.id, "row_id": row_1.id} ) - response = await communicator.receive_json_from(timeout=0.1) + assert await received_message(communicator, "page_add") is True # Team deleted await sync_to_async(TeamHandler().delete_team)(user_1, team) - # Receiving messages about being removed from the pages - response = await communicator.receive_json_from(timeout=0.1) + # Receiving messages about being removed from the pages (presence + # messages may be interleaved, so use get_message to skip them). + response = await get_message(communicator, "page_discard") assert response == { "page": "table", "parameters": { @@ -600,7 +596,8 @@ async def test_unsubscribe_user_from_tables_and_rows_when_team_trashed( }, "type": "page_discard", } - response = await communicator.receive_json_from(timeout=0.1) + + response = await get_message(communicator, "page_discard") assert response == { "page": "row", "parameters": { @@ -610,12 +607,9 @@ async def test_unsubscribe_user_from_tables_and_rows_when_team_trashed( "type": "page_discard", } - response = await communicator.receive_json_from(timeout=0.1) - assert response == { - "type": "permissions_updated", - "workspace_id": workspace_1.id, - "_event_id": AnyInt(), - } + response = await get_message(communicator, "permissions_updated") + assert response["type"] == "permissions_updated" + assert response["workspace_id"] == workspace_1.id # User should not receive any messages to a table in workspace 1 await send_messages_to_channel_group( diff --git a/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js b/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js index dcc9b77f93..4342103586 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/viewOwnershipTypes.js @@ -68,7 +68,7 @@ export class RestrictedViewOwnershipType extends ViewOwnershipType { } payload.page = 'restricted_view' - payload.params = { restricted_view_id: view.id } + payload.params = { restricted_view_id: view.id, table_id: table.id } return payload } diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss index fa5caca258..06c349eda9 100644 --- a/web-frontend/modules/core/assets/scss/components/all.scss +++ b/web-frontend/modules/core/assets/scss/components/all.scss @@ -210,3 +210,4 @@ @import 'formula_input_explorer_context'; @import 'formula_input_error_context'; @import 'tabs_body'; +@import 'presence'; diff --git a/web-frontend/modules/core/assets/scss/components/header.scss b/web-frontend/modules/core/assets/scss/components/header.scss index a705a7d7b5..4ada0b485b 100644 --- a/web-frontend/modules/core/assets/scss/components/header.scss +++ b/web-frontend/modules/core/assets/scss/components/header.scss @@ -71,6 +71,7 @@ .header__filter { flex: 0 0; display: flex; + align-items: center; list-style: none; padding: 0; margin: auto 0; @@ -79,6 +80,7 @@ flex: 1 1; width: 100%; min-width: 0; + margin: 0; } } @@ -99,6 +101,12 @@ margin-right: 10px; } + &.header__filter-item--stretch { + align-self: stretch; + display: flex; + align-items: center; + } + &.header__filter-item--no-margin-left { margin-left: 0; } diff --git a/web-frontend/modules/core/assets/scss/components/presence.scss b/web-frontend/modules/core/assets/scss/components/presence.scss new file mode 100644 index 0000000000..e1d754dd43 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/presence.scss @@ -0,0 +1,30 @@ +.presence-bar { + display: flex; + align-items: center; + height: 100%; + padding: 0 8px; +} + +.presence-bar__avatar { + margin-left: -2px; + border: 2px solid $white; + + &:first-child { + margin-left: 0; + } +} + +.presence-bar__overflow { + margin-left: -2px; + border: 2px solid $white; + background-color: $palette-neutral-500; + font-size: 10px; + cursor: default; +} + +.presence-bar__divider { + align-self: stretch; + width: 1px; + margin: 16px 0; + background-color: $palette-neutral-200; +} diff --git a/web-frontend/modules/core/components/presence/PresenceBadge.vue b/web-frontend/modules/core/components/presence/PresenceBadge.vue new file mode 100644 index 0000000000..e960a03f8f --- /dev/null +++ b/web-frontend/modules/core/components/presence/PresenceBadge.vue @@ -0,0 +1,39 @@ + + + diff --git a/web-frontend/modules/core/components/presence/PresenceBar.vue b/web-frontend/modules/core/components/presence/PresenceBar.vue new file mode 100644 index 0000000000..0431438749 --- /dev/null +++ b/web-frontend/modules/core/components/presence/PresenceBar.vue @@ -0,0 +1,72 @@ + + + diff --git a/web-frontend/modules/core/plugins/featureFlags.js b/web-frontend/modules/core/plugins/featureFlags.js index 301e9251c2..e245494762 100644 --- a/web-frontend/modules/core/plugins/featureFlags.js +++ b/web-frontend/modules/core/plugins/featureFlags.js @@ -1,6 +1,7 @@ import { useRuntimeConfig } from '#imports' const FF_ENABLE_ALL = '*' +export const FF_USER_PRESENCE = 'user_presence' function getFeatureFlags(env) { return (env.FEATURE_FLAGS || '') diff --git a/web-frontend/modules/core/plugins/realTimeHandler.js b/web-frontend/modules/core/plugins/realTimeHandler.js index a39a00f612..1390b91742 100644 --- a/web-frontend/modules/core/plugins/realTimeHandler.js +++ b/web-frontend/modules/core/plugins/realTimeHandler.js @@ -162,6 +162,7 @@ export class RealTimeHandler { this.socket.onclose = () => { this.connected = false this.subscribedToPages = this.pages.length === 0 + this.context.store.dispatch('presence/clearAllSpaces') this.delayedReconnect() } } @@ -573,6 +574,32 @@ export class RealTimeHandler { store.dispatch('notification/forceClearAll') }) + this.registerEvent('presence.members', ({ store }, data) => { + store.dispatch('presence/handleMembers', { + space: data.space, + entries: data.entries, + }) + }) + + this.registerEvent('presence.space_discard', ({ store }, data) => { + store.dispatch('presence/clearSpace', { space: data.space }) + }) + + this.registerEvent('presence.join', ({ store }, data) => { + store.dispatch('presence/handleJoin', { + space: data.space, + presence_id: data.presence_id, + user_id: data.user_id, + }) + }) + + this.registerEvent('presence.leave', ({ store }, data) => { + store.dispatch('presence/handleLeave', { + space: data.space, + presence_id: data.presence_id, + }) + }) + this.registerEvent('force_disconnect', ({ store }) => { this.reconnect = false logoutAndRedirectToLogin(this.context.app.router, store, false, true) diff --git a/web-frontend/modules/core/plugins/store.js b/web-frontend/modules/core/plugins/store.js index b5006d788d..e006087cc6 100644 --- a/web-frontend/modules/core/plugins/store.js +++ b/web-frontend/modules/core/plugins/store.js @@ -14,6 +14,7 @@ import userSourceStoreModule from '../store/userSource' import toastStoreModule from '../store/toast' import routeMountedStoreModule from '../store/routeMounted' import integrationStoreModule from '../store/integration' +import presenceStoreModule from '../store/presence' export default defineNuxtPlugin({ name: 'create-store', @@ -34,6 +35,7 @@ export default defineNuxtPlugin({ routeMounted: routeMountedStoreModule, toast: toastStoreModule, integration: integrationStoreModule, + presence: presenceStoreModule, }, }) nuxtApp.vueApp.use(store) diff --git a/web-frontend/modules/core/store/presence.js b/web-frontend/modules/core/store/presence.js new file mode 100644 index 0000000000..ee190d17d5 --- /dev/null +++ b/web-frontend/modules/core/store/presence.js @@ -0,0 +1,83 @@ +export const state = () => ({ + spaces: {}, +}) + +export const mutations = { + SET_MEMBERS(state, { space, entries }) { + const members = {} + for (const entry of entries) { + members[entry.presence_id] = { + user_id: entry.user_id, + } + } + state.spaces = { ...state.spaces, [space]: { members } } + }, + ADD_MEMBER(state, { space, presence_id, user_id }) { + const spaceData = state.spaces[space] + if (!spaceData) return + state.spaces = { + ...state.spaces, + [space]: { + members: { ...spaceData.members, [presence_id]: { user_id } }, + }, + } + }, + REMOVE_MEMBER(state, { space, presence_id }) { + const spaceData = state.spaces[space] + if (!spaceData) return + const { [presence_id]: _, ...rest } = spaceData.members + state.spaces = { + ...state.spaces, + [space]: { members: rest }, + } + }, + CLEAR_SPACE(state, { space }) { + const { [space]: _, ...rest } = state.spaces + state.spaces = rest + }, + CLEAR_ALL_SPACES(state) { + state.spaces = {} + }, +} + +export const actions = { + handleMembers({ commit }, { space, entries }) { + commit('SET_MEMBERS', { space, entries }) + }, + handleJoin({ commit }, { space, presence_id, user_id }) { + commit('ADD_MEMBER', { space, presence_id, user_id }) + }, + handleLeave({ commit }, { space, presence_id }) { + commit('REMOVE_MEMBER', { space, presence_id }) + }, + clearSpace({ commit }, { space }) { + commit('CLEAR_SPACE', { space }) + }, + clearAllSpaces({ commit }) { + commit('CLEAR_ALL_SPACES') + }, +} + +export const getters = { + getUniqueUsersBySpace: (state) => (spaceName) => { + const spaceData = state.spaces[spaceName] + if (!spaceData) return [] + const seen = new Set() + const users = [] + for (const [, data] of Object.entries(spaceData.members)) { + if (!seen.has(data.user_id)) { + seen.add(data.user_id) + users.push({ user_id: data.user_id }) + } + } + return users.sort((a, b) => a.user_id - b.user_id) + }, +} + +export default { + namespaced: true, + state, + mutations, + actions, + getters, +} diff --git a/web-frontend/modules/core/utils/presenceColors.js b/web-frontend/modules/core/utils/presenceColors.js new file mode 100644 index 0000000000..d320bf4035 --- /dev/null +++ b/web-frontend/modules/core/utils/presenceColors.js @@ -0,0 +1,27 @@ +const PALETTE = [ + '#4C6EF5', + '#7950F2', + '#BE4BDB', + '#E64980', + '#FA5252', + '#FD7E14', + '#FAB005', + '#40C057', + '#12B886', + '#15AABF', + '#228BE6', + '#3B5BDB', + '#6741D9', + '#9C36B5', + '#C2255C', + '#E8590C', + '#F08C00', + '#2F9E44', + '#099268', + '#0C8599', +] + +export function getPresenceUserColor(userId) { + const hash = ((userId * 2654435761) >>> 0) % PALETTE.length + return PALETTE[hash] +} diff --git a/web-frontend/modules/database/components/view/grid/GridViewHeader.vue b/web-frontend/modules/database/components/view/grid/GridViewHeader.vue index d93c07b52e..61d1cc10ef 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewHeader.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewHeader.vue @@ -17,7 +17,25 @@ :read-only="readOnly" > -
  • + +
  • state.table.loading, }), + presenceEnabled() { + return ( + typeof this.$featureFlagIsEnabled === 'function' && + this.$featureFlagIsEnabled(FF_USER_PRESENCE) + ) + }, + activePresenceSpace() { + const table = this.$store.state.table.selected + if (!table || !table.id) return '' + return `table-${table.id}` + }, + hasPresenceUsers() { + if (!this.activePresenceSpace) return false + const currentUserId = this.$store.getters['auth/getUserId'] + const users = this.$store.getters['presence/getUniqueUsersBySpace']( + this.activePresenceSpace + ) + return users.some((u) => u.user_id !== currentUserId) + }, fieldsAllowedToBeHidden() { return this.fields.filter((field) => !field.primary) }, diff --git a/web-frontend/test/fixtures/workspaces.js b/web-frontend/test/fixtures/workspaces.js index 056e189455..c8ff516741 100644 --- a/web-frontend/test/fixtures/workspaces.js +++ b/web-frontend/test/fixtures/workspaces.js @@ -4,6 +4,7 @@ export function createWorkspace(mock, { workspaceId = 1 }) { permissions: 'ADMIN', id: workspaceId, name: `workspace_${workspaceId}`, + users: [], } mock.onGet('/workspaces/').reply(200, [workspace]) return workspace diff --git a/web-frontend/test/unit/core/realTimeHandler.spec.js b/web-frontend/test/unit/core/realTimeHandler.spec.js index 3a7aaa16ea..3ad634ff31 100644 --- a/web-frontend/test/unit/core/realTimeHandler.spec.js +++ b/web-frontend/test/unit/core/realTimeHandler.spec.js @@ -596,11 +596,40 @@ describe('RealTimeHandler max attempts', () => { ([n, v]) => n === 'toast/setFailedConnecting' && v === true ) ).toBe(true) + }) + + test('connect does not show failed toast while tab is hidden', () => { + const { handler, store } = makeHandler() + // Genuine auth rejection — the path that still raises the Failed toast. + handler.authResponseReceived = true + handler.authenticationSuccess = false + handler.lastToken = 'token' + handler.reconnect = true + handler.socket = null + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + configurable: true, + }) + + handler.connect(true, false) + + expect( + store._dispatched.some( + ([n, v]) => n === 'toast/setFailedConnecting' && v === true + ) + ).toBe(false) expect( store._dispatched.some( ([n, v]) => n === 'toast/setReconnecting' && v === false ) ).toBe(true) + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) }) test('delayedReconnect still schedules retry at exactly max attempts', () => { @@ -697,3 +726,67 @@ describe('RealTimeHandler connect early-exit', () => { ).toBe(true) }) }) + +describe('RealTimeHandler presence events', () => { + test('presence.members dispatches handleMembers', () => { + const { handler, store } = makeHandler() + fire(handler, 'presence.members', { + space: 'table-1', + entries: [{ presence_id: 'pid-1', user_id: 10 }], + }) + expect( + store._dispatched.some( + ([n, v]) => + n === 'presence/handleMembers' && + v.space === 'table-1' && + v.entries.length === 1 + ) + ).toBe(true) + }) + + test('presence.join dispatches handleJoin', () => { + const { handler, store } = makeHandler() + fire(handler, 'presence.join', { + space: 'table-1', + presence_id: 'pid-2', + user_id: 20, + }) + expect( + store._dispatched.some( + ([n, v]) => + n === 'presence/handleJoin' && + v.space === 'table-1' && + v.presence_id === 'pid-2' && + v.user_id === 20 + ) + ).toBe(true) + }) + + test('presence.leave dispatches handleLeave', () => { + const { handler, store } = makeHandler() + fire(handler, 'presence.leave', { + space: 'table-1', + presence_id: 'pid-3', + }) + expect( + store._dispatched.some( + ([n, v]) => + n === 'presence/handleLeave' && + v.space === 'table-1' && + v.presence_id === 'pid-3' + ) + ).toBe(true) + }) + + test('presence.space_discard dispatches clearSpace', () => { + const { handler, store } = makeHandler() + fire(handler, 'presence.space_discard', { + space: 'table-1', + }) + expect( + store._dispatched.some( + ([n, v]) => n === 'presence/clearSpace' && v.space === 'table-1' + ) + ).toBe(true) + }) +}) diff --git a/web-frontend/test/unit/core/store/presence.spec.js b/web-frontend/test/unit/core/store/presence.spec.js new file mode 100644 index 0000000000..156d962b02 --- /dev/null +++ b/web-frontend/test/unit/core/store/presence.spec.js @@ -0,0 +1,113 @@ +import { describe, test, expect } from 'vitest' + +import { + state as makeState, + mutations, + getters, +} from '@baserow/modules/core/store/presence' + +function applyGetter(getter, currentState) { + const boundGetters = {} + for (const [key, fn] of Object.entries(getters)) { + Object.defineProperty(boundGetters, key, { + get: () => fn(currentState, boundGetters), + }) + } + return boundGetters[getter] +} + +describe('presence store', () => { + test('SET_MEMBERS populates members from snapshot data', () => { + const s = makeState() + mutations.SET_MEMBERS(s, { + space: 'table-1', + entries: [ + { presence_id: 'pid-1', user_id: 10 }, + { presence_id: 'pid-2', user_id: 20 }, + ], + }) + + expect(s.spaces['table-1'].members['pid-1']).toEqual({ + user_id: 10, + }) + expect(s.spaces['table-1'].members['pid-2']).toEqual({ + user_id: 20, + }) + expect(Object.keys(s.spaces)).toContain('table-1') + }) + + test('ADD_MEMBER adds a new connection', () => { + const s = makeState() + mutations.SET_MEMBERS(s, { space: 'table-1', entries: [] }) + mutations.ADD_MEMBER(s, { + space: 'table-1', + presence_id: 'pid-3', + user_id: 30, + }) + + expect(s.spaces['table-1'].members['pid-3']).toEqual({ + user_id: 30, + }) + }) + + test('REMOVE_MEMBER removes a connection', () => { + const s = makeState() + mutations.SET_MEMBERS(s, { + space: 'table-1', + entries: [{ presence_id: 'pid-1', user_id: 10 }], + }) + mutations.REMOVE_MEMBER(s, { space: 'table-1', presence_id: 'pid-1' }) + + expect(s.spaces['table-1'].members['pid-1']).toBeUndefined() + }) + + test('CLEAR_SPACE removes all data for a space', () => { + const s = makeState() + mutations.SET_MEMBERS(s, { + space: 'table-1', + entries: [{ presence_id: 'pid-1', user_id: 10 }], + }) + mutations.CLEAR_SPACE(s, { space: 'table-1' }) + + expect(s.spaces['table-1']).toBeUndefined() + expect(Object.keys(s.spaces)).not.toContain('table-1') + }) + + test('CLEAR_ALL_SPACES removes all spaces', () => { + const s = makeState() + mutations.SET_MEMBERS(s, { + space: 'table-1', + entries: [{ presence_id: 'pid-1', user_id: 10 }], + }) + mutations.SET_MEMBERS(s, { + space: 'table-2', + entries: [{ presence_id: 'pid-2', user_id: 20 }], + }) + mutations.CLEAR_ALL_SPACES(s) + + expect(Object.keys(s.spaces)).toHaveLength(0) + }) + + test('getUniqueUsersBySpace deduplicates by user_id', () => { + const s = makeState() + mutations.SET_MEMBERS(s, { + space: 'table-1', + entries: [ + { presence_id: 'pid-1', user_id: 10 }, + { presence_id: 'pid-2', user_id: 10 }, + { presence_id: 'pid-3', user_id: 20 }, + ], + }) + + const users = applyGetter('getUniqueUsersBySpace', s)('table-1') + expect(users).toHaveLength(2) + expect(users.map((u) => u.user_id)).toEqual([10, 20]) + }) + + test('unknown space name returns empty array', () => { + const s = makeState() + const users = applyGetter('getUniqueUsersBySpace', s)('nonexistent') + + expect(users).toEqual([]) + }) +}) diff --git a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap index 41eb5354f0..753ffd7b77 100644 --- a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap +++ b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap @@ -336,8 +336,9 @@ exports[`Public View Page Tests > Can see a publicly shared grid view 1`] = `
  • +
  • -
  • + +
  • -
  • + +