diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index c478a0793..a2c9a3ee2 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -98,11 +98,21 @@ the switch on without naming the companion, so an API client gets the same result. A value named in the same save always wins. -The Appearance, Agents & Actions, Chat, Data Lifecycle, Knowledge, Operations, Scale, -Workflow, Workspaces and Security groups are described in full. Backup & Recovery is -described by one component per section, because its settings are a separate document -saved through the data-management API and the rest of it is workbenches over that same -API. Sections with no entry here fall back to the V2 surface's +``related_section`` + Points a setting at the section its effect shows up in, as + ``{"section_id":
, "label": }``. ``requires`` is + the opposite direction: it names a prerequisite a field waits on. The page + says where the section sits and offers a jump to it, or a link to the + classic page when ``"classic_only": True`` says only that page draws what + the setting changes. The Document Access Index diagnostics switch points at + DAI Metrics; the Latest Features destination points at the User-Facing + Latest Features choices it publishes. + +The Appearance, Agents & Actions, Chat, Data Lifecycle, Governance, Help, Knowledge, +Operations, Scale, Workflow, Workspaces and Security groups are described in full. +Backup & Recovery is described by one component per section, because its settings are +a separate document saved through the data-management API and the rest of it is +workbenches over that same API. Sections with no entry here fall back to the V2 surface's ``enable_*`` scan, so undescribed groups keep working exactly as they did. A handful of individual fields outside those groups are also declared: that scan places a key by guessing from shared word stems, and declaring a field is the @@ -214,6 +224,10 @@ validate_workflow_max_repeat_iterations, validate_workflow_min_schedule_interval_seconds, ) +from support_menu_config import ( + get_support_latest_feature_catalog, + normalize_support_latest_features_visibility, +) HEX_COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$") @@ -428,6 +442,20 @@ ACCESS_DENIED_MESSAGE_MAX_LENGTH = 2000 +# The Support menu title sits in the navigation rail beside other menu titles, so +# it shares the External Links menu name's cap. +SUPPORT_MENU_NAME_MAX_LENGTH = 60 + +# RFC 5321 caps a forward path at 254 characters, which is as long as any address +# a mail client will accept in a mailto: draft. +SUPPORT_FEEDBACK_RECIPIENT_MAX_LENGTH = 254 + +# One address, no display name and no spaces. The end-user route only checks for +# an "@", so anything this accepts it accepts too; this is stricter so a typo such +# as a second address after a comma is caught where it is typed rather than in a +# user's draft. +SUPPORT_FEEDBACK_RECIPIENT_PATTERN = re.compile(r"^[^@\s,;<>\"]+@[^@\s,;<>\"]+$") + # Scale groups. Each is shared by several fields, and the first field of a group # decides its label, variant and help, so declaring them once keeps them in step. REDIS_CONNECTION_GROUP = { @@ -1020,32 +1048,14 @@ # below, beside the mixed-source telemetry switch that reports to the same # place. Wording is taken from the V1 panes so both interfaces say the same # thing. - "user-facing-latest-features-section": [ - { - "key": "enable_support_latest_feature_documentation_links", - "type": "switch", - "label": "Show Simple Chat Documentation Guide Links", - "help": ( - "User-facing Latest Features cards show public documentation guide " - "buttons in addition to the direct in-app shortcuts." - ), - "default": False, - # V1 hides this control entirely while the Latest Features destination - # is off, because the cards it affects are not reachable then. The - # Support Menu condition is repeated because visibility is evaluated - # per field rather than recursively: `enable_support_latest_features` - # defaults to True, so gating on it alone would leave this on screen - # while the whole Support menu is off. - "depends_on": [ - {"key": "enable_support_menu", "equals": True}, - {"key": "enable_support_latest_features", "equals": True}, - ], - }, - ], - # Declared so the dependency above resolves to a control an administrator can - # actually find and flip, and so the Support Menu gate chain reads the same in - # both interfaces. The remaining fields in this section are still discovered - # by the fallback scan. + # ------------------------------------------------------------------ + # Help group. Sections follow admin_settings_nav.py. The Support Menu + # fields mirror the V1 support-menu pane. Send Feedback and the + # User-Facing Latest Features choices are bespoke components, because + # what they hold is a utility form and a release catalogue rather than + # scalar settings. Admin Latest Features declares no navigation sections + # at all; the V2 page renders it from the catalogue, as V1 does. + # ------------------------------------------------------------------ "support-menu-section": [ { "key": "enable_support_menu", @@ -1057,6 +1067,49 @@ ), "default": False, }, + { + "key": "support_menu_name", + "type": "text", + "label": "Menu Name", + "help": "Appears in user navigation as the Support menu title.", + "default": "Support", + "placeholder": "Support", + "max_length": SUPPORT_MENU_NAME_MAX_LENGTH, + "fallback_when_empty": True, + "depends_on": {"key": "enable_support_menu", "equals": True}, + }, + { + "key": "enable_support_send_feedback", + "type": "switch", + "label": "Enable Send Feedback Destination", + "help": ( + "Users can prepare bug reports and feature requests as email drafts " + "addressed to your own support mailbox." + ), + "default": True, + "depends_on": {"key": "enable_support_menu", "equals": True}, + }, + { + "key": "support_feedback_recipient_email", + "type": "text", + "input_type": "email", + "label": "Support Recipient Email", + "help": ( + "User Send Feedback drafts are addressed to this internal mailbox. " + "Users do not see Send Feedback until it is set." + ), + "default": "", + "placeholder": "support@contoso.com", + "max_length": SUPPORT_FEEDBACK_RECIPIENT_MAX_LENGTH, + # Marks the section as needing configuration while Send Feedback is on + # and nothing is set, instead of switching the destination off on save + # the way the server-rendered form does. + "required": True, + "depends_on": [ + {"key": "enable_support_menu", "equals": True}, + {"key": "enable_support_send_feedback", "equals": True}, + ], + }, { "key": "enable_support_latest_features", "type": "switch", @@ -1064,6 +1117,78 @@ "help": "Publishes a user-facing Latest Features page from the Support menu.", "default": True, "depends_on": {"key": "enable_support_menu", "equals": True}, + "related_section": { + "section_id": "user-facing-latest-features-section", + "label": "User-Facing Latest Features", + }, + }, + ], + # Send Feedback is a utility rather than a setting: it prepares an email to the + # SimpleChat product team through /api/admin/settings/send_feedback_email, the + # endpoint the server-rendered tab posts to. Nothing here is saved with the page. + "send-feedback-overview-card": [ + { + "type": "component", + "component": "send-feedback-overview", + "label": "Send Feedback to the SimpleChat Team", + "help": ( + "Report a bug or request a feature directly to the SimpleChat product " + "team. Each submission is recorded in the activity log, then a " + "text-only email draft opens in your mail app." + ), + }, + ], + "send-feedback-bug-card": [ + { + "type": "component", + "component": "send-feedback-bug-report", + "label": "Report a Bug", + "help": "Something isn't working as expected.", + }, + ], + "send-feedback-feature-card": [ + { + "type": "component", + "component": "send-feedback-feature-request", + "label": "Request a Feature", + "help": "Suggest an improvement or new capability.", + }, + ], + "user-facing-latest-features-section": [ + { + # Reads the Support menu and destination switches, which live in another + # section, so it says plainly whether anything here is reaching users. + "type": "component", + "component": "support-latest-features-publication", + "label": "Publication", + "help": "Whether users can reach these announcements right now.", + }, + { + "key": "enable_support_latest_feature_documentation_links", + "type": "switch", + "label": "Show Simple Chat Documentation Guide Links", + "help": ( + "User-facing Latest Features cards show public documentation guide " + "buttons in addition to the direct in-app shortcuts." + ), + "default": False, + # Deliberately ungated. V1 hides this while the destination is off; V2 + # lets the cards be prepared before they are published, and the + # publication notice above says when nothing is reaching users yet. + }, + { + # Writes the whole visibility map into the draft. The map is checked and + # merged over the stored one on save, so a partial payload can never + # re-share an announcement that was hidden. + "type": "component", + "component": "support-latest-features-visibility", + "key": "support_latest_features_visibility", + "label": "Announcements Shared with Users", + "help": ( + "Choose which release highlights appear on the user Latest Features " + "page. Two archive items, Deployment and Redis, start hidden because " + "they are mainly admin-facing rollout and infrastructure topics." + ), }, ], "personal-workspaces-section": [ @@ -8379,6 +8504,29 @@ # stored key, which reads the other way round. "debug_logging_timer_enabled": ["enable_debug_logging_timer"], "file_processing_logs_timer_enabled": ["enable_file_processing_logs_timer"], + # V1 renders one checkbox per catalogue feature, named after the feature id, + # and assembles the map server-side; V2 edits the stored map directly. The + # names are generated from the same catalogue the pane loops over. + "support_latest_features_visibility": [ + f"support_latest_feature_{feature['id']}" + for feature in get_support_latest_feature_catalog() + ], +} + +# V1 form inputs that are not settings at all, mapped to the V2 component that +# offers the same input. The Send Feedback tab is a utility form inside the +# settings page: its inputs are posted to /api/admin/settings/send_feedback_email +# by script and ignored by the settings save. The parity test reads this so each +# of them is accounted for by a component the schema actually declares. +LEGACY_FORM_ONLY_FIELDS = { + "send_feedback_bug_name": "send-feedback-bug-report", + "send_feedback_bug_email": "send-feedback-bug-report", + "send_feedback_bug_org": "send-feedback-bug-report", + "send_feedback_bug_details": "send-feedback-bug-report", + "send_feedback_feature_name": "send-feedback-feature-request", + "send_feedback_feature_email": "send-feedback-feature-request", + "send_feedback_feature_org": "send-feedback-feature-request", + "send_feedback_feature_details": "send-feedback-feature-request", } # Field names present in the V1 panes covered by a parity test that intentionally @@ -8548,6 +8696,17 @@ "enabled_key": "enable_custom_rate_limit_message", "configured": [{"requires": ["rate_limit_message"]}], }, + # Send Feedback stays out of the user Support menu until a recipient exists, + # so "on with no recipient" is a configuration gap rather than a working state. + "support-menu-section": { + "enabled_key": "enable_support_menu", + "configured": [ + { + "when": {"enable_support_send_feedback": True}, + "requires": ["support_feedback_recipient_email"], + }, + ], + }, } @@ -8943,6 +9102,49 @@ def _validate_front_door_url(value): return candidate, None +def _validate_support_feedback_recipient(value): + """Return ``(address, error)`` for the end-user Send Feedback recipient. + + The server-rendered form clears a malformed address and switches the Send + Feedback destination off with a flash message. Refusing the save instead keeps + what was typed on screen beside the reason, which is how V2 treats any value an + administrator entered. A blank value is accepted: it is how the destination is + left unconfigured, and the Support section status says so. + """ + candidate = str(value if value is not None else "").strip() + if not candidate: + return "", None + if len(candidate) > SUPPORT_FEEDBACK_RECIPIENT_MAX_LENGTH: + return None, ( + f"Email addresses are at most {SUPPORT_FEEDBACK_RECIPIENT_MAX_LENGTH} characters." + ) + if not SUPPORT_FEEDBACK_RECIPIENT_PATTERN.match(candidate): + return None, "Enter one email address, such as support@contoso.com." + return candidate, None + + +def _merge_support_latest_features_visibility(value, current_settings): + """Return ``(visibility, error)`` for the User-Facing Latest Features choices. + + The V2 control sends the whole map, but the endpoint accepts partial updates, + so submitted entries are merged over what is stored rather than over the + catalogue defaults. Merging over the defaults would let a payload naming one + announcement quietly re-share every other one an administrator had hidden. + Ids the catalogue no longer has are dropped, and the result goes through the + same normalizer the server-rendered save uses. + """ + if not isinstance(value, dict): + return None, "Expected a map of announcement ids to true or false." + + merged = normalize_support_latest_features_visibility( + (current_settings or {}).get("support_latest_features_visibility", {}) + ) + for feature_id, shared in value.items(): + if feature_id in merged: + merged[feature_id] = _coerce_bool(shared) + return normalize_support_latest_features_visibility(merged), None + + def _validate_external_link_url(url): """Return an error message when a navigation link URL is not safe to render.""" candidate = str(url or "").strip() @@ -9982,6 +10184,26 @@ def normalize_admin_settings_updates(updates, current_settings=None): normalized[key] = front_door_value continue + if key == "support_feedback_recipient_email": + recipient, recipient_error = _validate_support_feedback_recipient(value) + if recipient_error: + errors[key] = recipient_error + else: + normalized[key] = recipient + continue + + if key == "support_latest_features_visibility": + # Declared as a component field, which the type-driven path below + # refuses, so it is validated here instead. + visibility, visibility_error = _merge_support_latest_features_visibility( + value, current + ) + if visibility_error: + errors[key] = visibility_error + else: + normalized[key] = visibility + continue + if key == "redis_port": port_value, port_error = _validate_redis_port(value) if port_error: @@ -10051,6 +10273,7 @@ def normalize_admin_settings_updates(updates, current_settings=None): # together or one at a time, so the merged state is the only thing worth # judging. _apply_cross_field_rules(normalized, current, warnings) + _note_unaddressed_support_feedback(normalized, current, warnings) # Folded last so the checks above still see the flat keys they were written # against, and so a rejected save never assembles a container. @@ -10417,6 +10640,43 @@ def merged(key, fallback): ) +# The settings that together decide whether Send Feedback reaches end users. +SUPPORT_FEEDBACK_SETTING_KEYS = ( + "enable_support_menu", + "enable_support_send_feedback", + "support_feedback_recipient_email", +) + + +def _note_unaddressed_support_feedback(normalized, current_settings, warnings): + """Say when Send Feedback is on for users but has nowhere to send. + + The server-rendered form switches the destination off in this case. V2 keeps + what was saved and explains why users will not see it yet, so turning on the + Support menu never silently changes a different setting; the Support section + status reads "Needs configuration" for the same reason. + + Judged on the merged state, and only when the save touches one of the three + keys, so an unrelated save does not repeat the note. + """ + if not any(key in normalized for key in SUPPORT_FEEDBACK_SETTING_KEYS): + return + + def merged(key, fallback): + if key in normalized: + return normalized[key] + stored = current_settings.get(key) + return fallback if stored is None else stored + + menu_on = _coerce_bool(merged("enable_support_menu", False)) + feedback_on = _coerce_bool(merged("enable_support_send_feedback", True)) + recipient = str(merged("support_feedback_recipient_email", "") or "").strip() + if menu_on and feedback_on and not recipient: + warnings["support_feedback_recipient_email"] = ( + "Users won't see Send Feedback until a recipient email is set." + ) + + def _apply_logging_timer_limits(normalized, current_settings, warnings): """Keep each log's auto-turnoff duration inside its unit's range. diff --git a/application/single_app/admin_settings_nav.py b/application/single_app/admin_settings_nav.py index 2a87a2ba7..c6ccde670 100644 --- a/application/single_app/admin_settings_nav.py +++ b/application/single_app/admin_settings_nav.py @@ -638,6 +638,38 @@ def iter_tabs(): yield group, tab +# Tabs that existed before the information architecture rework, mapped to where +# their content now lives. ``LEGACY_TAB_REDIRECTS`` in +# ``static/js/admin/admin_sidebar_nav.js`` resolves the same ids for the +# server-rendered page; this copy lets the server resolve a Latest Features +# shortcut for the V2 surface, and a functional test keeps the two identical. +LEGACY_TAB_REDIRECTS = { + "governance": "feature-governance", + "scale": "redis-caching", + "general": "branding", + "safety": "access-roles", + "security": "secrets", + "workspaces": "workspace-types", + "search-extract": "web-research", + "ai-models": "model-endpoints", + "data-management": "backup", +} + + +def resolve_admin_tab_id(tab_id): + """Return the live tab id for a current or pre-rework tab id. + + A leading ``#`` is accepted because Latest Features shortcuts store their + target as a fragment. Returns None when the id names no tab, directly or + through ``LEGACY_TAB_REDIRECTS``. + """ + candidate = str(tab_id or "").strip().lstrip("#") + if not candidate: + return None + candidate = LEGACY_TAB_REDIRECTS.get(candidate, candidate) + return candidate if candidate in get_tab_ids() else None + + def get_tab_ids(): """Return every tab id in navigation order.""" return [tab["id"] for _, tab in iter_tabs()] diff --git a/application/single_app/config.py b/application/single_app/config.py index 877179baf..e4efb1724 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.275" +VERSION = "0.261.276" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_support_latest_features.py b/application/single_app/functions_support_latest_features.py new file mode 100644 index 000000000..14a89b5ff --- /dev/null +++ b/application/single_app/functions_support_latest_features.py @@ -0,0 +1,235 @@ +# functions_support_latest_features.py +"""The Latest Features catalogues, shaped for the V2 admin surface. + +``support_menu_config`` holds two release catalogues: one written for end users +and shown from the Support menu, and one written for administrators and shown on +the Admin Latest Features tab. The server-rendered page turns both into markup +with Jinja, resolving each shortcut with ``url_for`` and each screenshot with +``url_for('static', ...)``. The V2 surface can do neither, so this module does +that resolution once, on the server, and returns plain data. + +It imports no Flask and no Azure client, so it can be exercised directly: the +route passes in the two resolvers it needs. + +``admin`` + Administrator release groups. Each shortcut that targets an admin tab is + resolved to a live tab id, following ``LEGACY_TAB_REDIRECTS``, so the V2 page + can jump to the matching card, or fall back to the server-rendered tab when V2 + does not draw it. + +``user`` + End-user release groups as users read them, with each announcement's default + visibility. Shortcuts keep their ``requires_settings`` so the V2 preview can + show or hide them as the draft changes; the documentation guide buttons + depend on a switch in the same section. +""" + +import re + +from admin_settings_nav import resolve_admin_tab_id +from support_menu_config import ( + get_admin_latest_feature_release_groups_for_settings, + get_default_support_latest_features_visibility, + get_support_latest_feature_release_groups_for_preview, +) + + +ACTION_KIND_ADMIN = "admin" +ACTION_KIND_PAGE = "page" +ACTION_KIND_EXTERNAL = "external" + +# Section ids are element ids on both admin surfaces. Anything else is dropped +# rather than passed to the browser as a jump target. +SECTION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,120}$") + +# Settings keys an action may depend on. They are compared against the draft in +# the browser, so only plain keys are passed through. +SETTING_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_]{1,120}$") + + +def _text(value): + """Return a trimmed string for any catalogue value.""" + return str(value if value is not None else "").strip() + + +def _is_same_origin_path(href): + """Whether a destination is a path on this application.""" + return href.startswith("/") and not href.startswith("//") and "\\" not in href + + +def _is_http_url(href): + """Whether a destination is an absolute http(s) address.""" + return href.lower().startswith(("https://", "http://")) + + +def _serialize_image(image, resolve_static_url): + """Return one screenshot with a browser-ready URL, or None when it has no path.""" + path = _text(image.get("path")) + if not path: + return None + url = _text(resolve_static_url(path)) + if not _is_same_origin_path(url): + return None + return { + "url": url, + "alt": _text(image.get("alt")), + "title": _text(image.get("title")), + "caption": _text(image.get("caption")), + "label": _text(image.get("label")), + } + + +def _resolve_page_href(action, resolve_endpoint_url): + """Return the destination of a non-admin shortcut, or '' when it has none.""" + href = _text(action.get("href")) + if href: + return href + + endpoint = _text(action.get("endpoint")) + if not endpoint: + return "" + href = _text(resolve_endpoint_url(endpoint)) + fragment = _text(action.get("fragment")) + if href and fragment: + href = f"{href}#{fragment}" + return href + + +def _serialize_action(action, resolve_endpoint_url): + """Return one shortcut as the V2 page needs it, or None when it cannot be followed. + + ``kind`` says how the browser follows it: + + ``admin`` + A tab on the Admin Settings page, optionally narrowed to a section. ``href`` + is the server-rendered tab, used when V2 does not draw the target itself. + ``page`` + Another page of this application. + ``external`` + An absolute http(s) address, opened in a new tab. + """ + label = _text(action.get("label")) + if not label: + return None + + serialized = { + "label": label, + "description": _text(action.get("description")), + "icon": _text(action.get("icon")), + "requires_settings": [ + key + for key in (_text(item) for item in action.get("requires_settings") or []) + if SETTING_KEY_PATTERN.match(key) + ], + } + + admin_tab = resolve_admin_tab_id(action.get("admin_tab")) + if admin_tab: + section = _text(action.get("admin_section")) + serialized.update( + { + "kind": ACTION_KIND_ADMIN, + "admin_tab": admin_tab, + "admin_section": section if SECTION_ID_PATTERN.match(section) else None, + "href": f"/admin/settings#{admin_tab}", + } + ) + return serialized + + href = _resolve_page_href(action, resolve_endpoint_url) + if _is_http_url(href): + serialized.update({"kind": ACTION_KIND_EXTERNAL, "href": href}) + return serialized + if _is_same_origin_path(href): + serialized.update({"kind": ACTION_KIND_PAGE, "href": href}) + return serialized + return None + + +def _serialize_feature(feature, resolve_endpoint_url, resolve_static_url, default_visible=None): + """Return one announcement with its screenshots and shortcuts resolved.""" + images = [ + image + for image in ( + _serialize_image(item, resolve_static_url) + for item in feature.get("images") or [] + if isinstance(item, dict) + ) + if image + ] + actions = [ + action + for action in ( + _serialize_action(item, resolve_endpoint_url) + for item in feature.get("actions") or [] + if isinstance(item, dict) + ) + if action + ] + serialized = { + "id": _text(feature.get("id")), + "title": _text(feature.get("title")), + "icon": _text(feature.get("icon")), + "summary": _text(feature.get("summary")), + "details": _text(feature.get("details")), + "why": _text(feature.get("why")), + "guidance": [_text(item) for item in feature.get("guidance") or [] if _text(item)], + "images": images, + "actions": actions, + } + if default_visible is not None: + serialized["default_visible"] = bool(default_visible) + return serialized + + +def _serialize_group(group, resolve_endpoint_url, resolve_static_url, visibility_defaults=None): + """Return one release group and its announcements.""" + features = [] + for feature in group.get("features") or []: + if not isinstance(feature, dict) or not _text(feature.get("id")): + continue + default_visible = None + if visibility_defaults is not None: + default_visible = visibility_defaults.get(feature["id"], True) + features.append( + _serialize_feature( + feature, + resolve_endpoint_url, + resolve_static_url, + default_visible=default_visible, + ) + ) + return { + "id": _text(group.get("id")), + "label": _text(group.get("label")), + "description": _text(group.get("description")), + "release_version": _text(group.get("release_version")), + "default_expanded": bool(group.get("default_expanded")), + "features": features, + } + + +def build_latest_features_payload(settings, *, resolve_endpoint_url, resolve_static_url, version): + """Return both Latest Features catalogues for the V2 admin surface. + + ``resolve_endpoint_url`` turns a Flask endpoint name into a path and returns + '' when the endpoint does not exist; ``resolve_static_url`` turns a static file + path into a URL. Both are supplied by the route so this stays free of Flask. + """ + visibility_defaults = get_default_support_latest_features_visibility() + return { + "version": _text(version), + "admin": [ + _serialize_group(group, resolve_endpoint_url, resolve_static_url) + for group in get_admin_latest_feature_release_groups_for_settings(settings) + ], + "user": [ + _serialize_group( + group, + resolve_endpoint_url, + resolve_static_url, + visibility_defaults=visibility_defaults, + ) + for group in get_support_latest_feature_release_groups_for_preview(settings) + ], + } diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index 01d244c87..306ab2d5c 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -23,8 +23,9 @@ import logging import uuid -from flask import current_app, jsonify, request, session +from flask import current_app, jsonify, request, session, url_for from werkzeug.exceptions import BadRequest, RequestEntityTooLarge +from werkzeug.routing import BuildError from admin_app_roles import get_app_role_requirements from admin_settings_fields import ( @@ -172,6 +173,7 @@ is_url_access_enabled_for_user, ) from functions_workspace_sections import build_workspace_section_availability +from functions_support_latest_features import build_latest_features_payload from functions_workspace_context import ( WorkspaceContextError, build_group_workspace_context, @@ -1669,6 +1671,51 @@ def v2_admin_get_update_status(): ) return jsonify({"error": "Unable to check for application updates."}), 500 + def _latest_feature_endpoint_url(endpoint): + """Resolve a catalogue shortcut's Flask endpoint, or '' when it is not registered.""" + try: + return url_for(endpoint) + except BuildError: + log_event( + "[V2_ADMIN_SETTINGS] A Latest Features shortcut names an unknown endpoint.", + extra={"endpoint": endpoint}, + level=logging.WARNING, + ) + return "" + + @bp.route("/api/v2/admin/latest-features", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def v2_admin_get_latest_features(): + """Return the admin and user-facing Latest Features catalogues. + + The server-rendered Help tabs build these cards with Jinja, resolving each + shortcut through ``url_for`` and each screenshot through the static route. + The V2 surface cannot, so the resolved catalogues are served here. + + Kept apart from the settings GET because the catalogues are long and only the + Help group draws them: the SPA requests this beside the settings when its + navigation includes those cards, so it never holds up the rest of the page. + The payload holds release copy and links only, no settings values. + """ + try: + payload = build_latest_features_payload( + get_settings(), + resolve_endpoint_url=_latest_feature_endpoint_url, + resolve_static_url=lambda path: url_for("static", filename=path), + version=VERSION, + ) + return jsonify(payload), 200 + except Exception as exc: + log_event( + "[V2_ADMIN_SETTINGS] Failed to load the Latest Features catalogues.", + extra={"error_type": type(exc).__name__}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({"error": "Failed to load Latest Features."}), 500 + @bp.route("/api/v2/admin/settings", methods=["PATCH"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/support_menu_config.py b/application/single_app/support_menu_config.py index 82c2735e8..fff8e7572 100644 --- a/application/single_app/support_menu_config.py +++ b/application/single_app/support_menu_config.py @@ -663,9 +663,14 @@ def _resolve_support_application_title(settings): def _apply_support_application_title(value, app_title): - """Replace hard-coded product naming in user-facing support metadata.""" + """Replace hard-coded product naming in user-facing support metadata. + + The product name is replaced before the ``{app_title}`` placeholder is filled + in, so a configured title that itself contains "SimpleChat" is not substituted + a second time inside the text just inserted. + """ if isinstance(value, str): - return value.replace('{app_title}', app_title).replace('SimpleChat', app_title) + return value.replace('SimpleChat', app_title).replace('{app_title}', app_title) if isinstance(value, list): return [_apply_support_application_title(item, app_title) for item in value] @@ -679,6 +684,20 @@ def _apply_support_application_title(value, app_title): return value +def _apply_support_application_title_to_group(feature_group, app_title): + """Personalize a release group's own copy, leaving its already-personalized features alone. + + Applying the title to the whole group would run every feature through the + substitution a second time, which doubles a title containing "SimpleChat". + """ + personalized = _apply_support_application_title( + {key: value for key, value in feature_group.items() if key != 'features'}, + app_title, + ) + personalized['features'] = feature_group.get('features', []) + return personalized + + _SUPPORT_RELEASE_241_FEATURE_CATALOG = [ { 'id': 'guided_tutorials', @@ -2866,7 +2885,7 @@ def get_visible_support_latest_feature_groups(settings): if visible_features: visible_group = deepcopy(feature_group) visible_group['features'] = visible_features - visible_group = _apply_support_application_title(visible_group, app_title) + visible_group = _apply_support_application_title_to_group(visible_group, app_title) visible_groups.append(visible_group) return visible_groups @@ -2887,11 +2906,34 @@ def get_support_latest_feature_release_groups_for_settings(settings): feature.update(_apply_support_application_title(feature, app_title)) _normalize_feature_media(feature) - feature_group.update(_apply_support_application_title(feature_group, app_title)) + feature_group.update(_apply_support_application_title_to_group(feature_group, app_title)) return filtered_groups +def get_support_latest_feature_release_groups_for_preview(settings): + """Return grouped user-facing latest features as users read them, with every action kept. + + ``get_support_latest_feature_release_groups_for_settings`` drops each shortcut + whose ``requires_settings`` are off in the stored settings. An admin preview + needs them all instead, because it filters against the settings being edited: + the documentation guide buttons depend on a switch an administrator may have + just flipped and not yet saved. + """ + preview_groups = deepcopy(_SUPPORT_LATEST_FEATURE_RELEASE_GROUPS) + app_title = _resolve_support_application_title(settings) + + for feature_group in preview_groups: + for feature in feature_group.get('features', []): + _normalize_feature_actions(feature) + feature.update(_apply_support_application_title(feature, app_title)) + _normalize_feature_media(feature) + + feature_group.update(_apply_support_application_title_to_group(feature_group, app_title)) + + return preview_groups + + def get_admin_latest_feature_release_groups_for_settings(settings): """Return grouped admin latest-feature metadata with safe media defaults.""" filtered_groups = deepcopy(_ADMIN_LATEST_FEATURE_RELEASE_GROUPS) @@ -2907,7 +2949,7 @@ def get_admin_latest_feature_release_groups_for_settings(settings): feature.update(_apply_support_application_title(feature, app_title)) _normalize_feature_media(feature) - feature_group.update(_apply_support_application_title(feature_group, app_title)) + feature_group.update(_apply_support_application_title_to_group(feature_group, app_title)) return filtered_groups diff --git a/application/v2_ui/src/components/admin/AdminLatestFeatures.tsx b/application/v2_ui/src/components/admin/AdminLatestFeatures.tsx new file mode 100644 index 000000000..bad64c5ef --- /dev/null +++ b/application/v2_ui/src/components/admin/AdminLatestFeatures.tsx @@ -0,0 +1,261 @@ +// AdminLatestFeatures.tsx +// The Admin Latest Features catalogue, as the body of its V2 section card. +// +// The classic page renders this tab from the admin release catalogue rather than from +// settings, so the navigation declares no sections for it. V2 does the same: the page draws +// one card for the tab and this fills it. What an administrator gets from it is a guided +// tour of each release -- what changed, why it matters, how to roll it out -- with +// shortcuts that open the settings involved. A shortcut jumps to the matching card when +// V2 draws one, and opens the classic tab when the work still lives there. + +import { useMemo, useState } from 'react'; +import { ArrowUpRight } from 'lucide-react'; +import { + CatalogueError, + CatalogueSkeleton, + FeatureDetails, + FeatureDisclosure, + ReleaseGroupPanel, + latestFeatureActionClass, +} from './LatestFeatureParts'; +import { resolveLatestFeatureIcon } from './latestFeatureIcons'; +import { + filterGroups, + latestFeatureCardId, + safeClassicAdminTabHref, + safeLatestFeatureHref, + type AdminActionTarget, + type LatestFeature, + type LatestFeatureAction, + type LatestFeatureGroup, +} from '../../lib/latestFeatures'; + +function FeatureShortcut({ + action, + resolveAction, + onNavigate, +}: { + action: LatestFeatureAction; + resolveAction: (action: LatestFeatureAction) => AdminActionTarget; + onNavigate: (sectionId: string) => void; +}) { + const Icon = resolveLatestFeatureIcon(action.icon); + + if (action.kind === 'admin') { + const target = resolveAction(action); + if (target.kind === 'section') { + return ( + + ); + } + return ( + + + ); + } + + if (!safeLatestFeatureHref(action.href)) { + return null; + } + const external = action.kind === 'external'; + return ( + + + ); +} + +function AdminFeatureRow({ + feature, + open, + onToggle, + resolveAction, + onNavigate, +}: { + feature: LatestFeature; + open: boolean; + onToggle: () => void; + resolveAction: (action: LatestFeatureAction) => AdminActionTarget; + onNavigate: (sectionId: string) => void; +}) { + const Icon = resolveLatestFeatureIcon(feature.icon); + const rowId = latestFeatureCardId(feature.id); + const detailsId = `${rowId}-details`; + + return ( +
  • +
    + +
    +

    {feature.title}

    +

    + {feature.summary} +

    +
    + +
    + {open ? ( +
    + + {feature.actions.map((action) => ( + + ))} +
    + ) : null + } + /> + + ) : null} +
  • + ); +} + +export function AdminLatestFeatures({ + groups, + loading, + error, + onRetry, + query, + resolveAction, + onNavigate, +}: { + groups: LatestFeatureGroup[] | null; + loading: boolean; + error: string | null; + onRetry: () => void; + /** The page search; narrows the rows to matching announcements. */ + query: string; + resolveAction: (action: LatestFeatureAction) => AdminActionTarget; + onNavigate: (sectionId: string) => void; +}) { + const [openGroups, setOpenGroups] = useState | null>(null); + const [openFeatures, setOpenFeatures] = useState>(() => new Set()); + + const searching = Boolean(query.trim()); + const shownGroups = useMemo(() => filterGroups(groups ?? [], query), [groups, query]); + + // Until an administrator opens or closes one, a release starts the way the catalogue + // asks: the current release open, older ones closed. + const isGroupOpen = (group: LatestFeatureGroup) => + searching || (openGroups ? openGroups.has(group.id) : group.default_expanded); + + const toggleGroup = (group: LatestFeatureGroup) => { + setOpenGroups((current) => { + const next = new Set( + current ?? (groups ?? []).filter((item) => item.default_expanded).map((item) => item.id), + ); + if (next.has(group.id)) { + next.delete(group.id); + } else { + next.add(group.id); + } + return next; + }); + }; + + const toggleFeature = (featureId: string) => { + setOpenFeatures((current) => { + const next = new Set(current); + if (next.has(featureId)) { + next.delete(featureId); + } else { + next.add(featureId); + } + return next; + }); + }; + + return ( +
    +

    + What each release changed for administrators, newest first. Shortcuts open the + settings involved: here when V2 shows them, otherwise on the classic admin page. + What end users are told lives in User-Facing Latest Features. +

    + + {!groups && (loading || !error) ? : null} + {error && !groups ? : null} + + {groups && searching && !shownGroups.length ? ( +

    No announcements match “{query.trim()}”.

    + ) : null} + + {shownGroups.map((group) => { + const open = isGroupOpen(group); + return ( + toggleGroup(group)} + > + {group.features.map((feature) => ( + toggleFeature(feature.id)} + resolveAction={resolveAction} + onNavigate={onNavigate} + /> + ))} + + ); + })} +
    + ); +} diff --git a/application/v2_ui/src/components/admin/LatestFeatureParts.tsx b/application/v2_ui/src/components/admin/LatestFeatureParts.tsx new file mode 100644 index 000000000..989f2bc97 --- /dev/null +++ b/application/v2_ui/src/components/admin/LatestFeatureParts.tsx @@ -0,0 +1,300 @@ +// LatestFeatureParts.tsx +// The pieces both Latest Features cards in the Help group are built from. +// +// Admin Latest Features and the User-Facing Latest Features choices show the same kind of +// content: release groups of announcements, each with details, guidance and screenshots. +// They share one vocabulary so they read as one feature. A release group collapses like any +// other admin field group, an announcement is a row that opens in place, and a screenshot +// opens in the admin dialog. Only what sits beside each row differs: shortcuts for +// administrators, and a share checkbox for what users see. + +import { useState, type ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { ChevronRight, Info, Maximize2, RotateCcw } from 'lucide-react'; +import { AdminModal } from './AdminModal'; +import { Skeleton } from '../ui/primitives'; +import { + safeLatestFeatureImageUrl, + type LatestFeature, + type LatestFeatureImage, +} from '../../lib/latestFeatures'; + +/** Marks content that is new in this release, as the classic navigation does. */ +export function NewBadge({ className }: { className?: string }) { + return ( + + New + + ); +} + +/** The compact button style shared by row shortcuts and bulk actions. */ +export const latestFeatureActionClass = clsx( + 'inline-flex min-h-8 items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1.5', + 'text-xs font-medium text-text-1 transition-colors', + 'hover:border-accent hover:bg-surface-2', + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent', + 'disabled:cursor-not-allowed disabled:opacity-60', +); + +/** + * One release, collapsed or open. + * + * Drawn like an admin field group so the catalogue reads as part of the settings page rather + * than a document pasted into it. Header actions only show while the group is open, because + * they act on rows that are otherwise out of sight. + */ +export function ReleaseGroupPanel({ + id, + label, + version, + description, + summary, + open, + onToggle, + actions, + children, +}: { + id: string; + label: string; + version?: string; + description?: string; + /** Shown at the end of the header, such as a count. */ + summary?: ReactNode; + open: boolean; + onToggle: () => void; + actions?: ReactNode; + children: ReactNode; +}) { + const panelId = `${id}-panel`; + return ( +
    +
    + + {open && actions ? ( +
    {actions}
    + ) : null} +
    + + {open ? ( +
    + {description ? ( +

    + {description} +

    + ) : null} +
      {children}
    +
    + ) : null} +
    + ); +} + +/** The disclosure on an announcement row. Names the announcement for screen readers. */ +export function FeatureDisclosure({ + label, + featureTitle, + controls, + open, + onToggle, +}: { + label: string; + featureTitle: string; + controls: string; + open: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +/** Screenshots as a strip of thumbnails; one opens full size in the admin dialog. */ +export function ScreenshotStrip({ images }: { images: LatestFeatureImage[] }) { + const [enlarged, setEnlarged] = useState(null); + const shown = images.filter((image) => safeLatestFeatureImageUrl(image.url)); + if (!shown.length) { + return null; + } + + return ( + <> +
      + {shown.map((image) => ( +
    • + +
    • + ))} +
    + + {enlarged ? ( + setEnlarged(null)} + > + {enlarged.alt} + + ) : null} + + ); +} + +/** + * What an announcement says once opened: details, why it matters, its steps, screenshots, + * and whatever the caller adds at the end. + * + * The catalogue phrases every "why" as "This matters because ...", so it needs no heading of + * its own; a quiet tinted line sets it apart from the details above it. + */ +export function FeatureDetails({ + id, + feature, + guidanceLabel, + footer, +}: { + id: string; + feature: LatestFeature; + guidanceLabel: string; + footer?: ReactNode; +}) { + return ( +
    + {feature.details ?

    {feature.details}

    : null} + {feature.why ? ( +

    +

    + ) : null} + {feature.guidance.length ? ( +
    +

    {guidanceLabel}

    +
      + {feature.guidance.map((step) => ( +
    • {step}
    • + ))} +
    +
    + ) : null} + + {footer} +
    + ); +} + +/** Rows standing in for a catalogue that is still loading. */ +export function CatalogueSkeleton() { + return ( + + ); +} + +/** A catalogue that could not be loaded, with a way to try again. */ +export function CatalogueError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( +
    + {message} + +
    + ); +} diff --git a/application/v2_ui/src/components/admin/LatestFeaturesPublication.tsx b/application/v2_ui/src/components/admin/LatestFeaturesPublication.tsx new file mode 100644 index 000000000..b159e7a80 --- /dev/null +++ b/application/v2_ui/src/components/admin/LatestFeaturesPublication.tsx @@ -0,0 +1,126 @@ +// LatestFeaturesPublication.tsx +// Whether the user-facing announcements are reaching anyone right now. +// +// What decides that lives in another section: the Support menu has to be on, and so does +// its Latest Features destination. V1 hides this whole tab while either is off. V2 keeps the +// choices editable so they can be prepared before launch, which makes it essential to say +// plainly when nothing is published yet, and to offer the way to the switch that publishes +// it. + +import { clsx } from 'clsx'; +import { AlertCircle, ArrowRight, CheckCircle2 } from 'lucide-react'; +import type { AdminField } from '../../lib/adminFields'; + +export const SUPPORT_MENU_SECTION_ID = 'support-menu-section'; + +export interface PublicationState { + tone: 'ok' | 'warn'; + headline: string; + detail: string; + /** Whether the fix is in the Support section. */ + pointsToSupport: boolean; +} + +/** Work out what an administrator needs to be told. Exported for direct testing. */ +export function describePublication({ + menuOn, + destinationOn, + menuName, + shared, + total, +}: { + menuOn: boolean; + destinationOn: boolean; + menuName: string; + shared: number | null; + total: number | null; +}): PublicationState { + const menu = menuName.trim() || 'Support'; + if (!menuOn) { + return { + tone: 'warn', + headline: 'Not published yet', + detail: + 'The Support menu is off, so users cannot reach Latest Features. Choices made here are saved now and take effect once it is on.', + pointsToSupport: true, + }; + } + if (!destinationOn) { + return { + tone: 'warn', + headline: 'Not published yet', + detail: `The Latest Features destination is off, so the ${menu} menu does not offer it. Choices made here are saved now and take effect once it is on.`, + pointsToSupport: true, + }; + } + if (shared === 0) { + return { + tone: 'warn', + headline: 'Published, but empty', + detail: `No announcements are shared, so users do not see Latest Features in the ${menu} menu.`, + pointsToSupport: false, + }; + } + return { + tone: 'ok', + headline: 'Published', + detail: + shared !== null && total !== null + ? `Users see ${shared} of ${total} announcements under ${menu} › Latest Features.` + : `Users reach Latest Features from the ${menu} menu.`, + pointsToSupport: false, + }; +} + +export function LatestFeaturesPublication({ + field, + state, + onNavigate, +}: { + field: AdminField; + state: PublicationState; + onNavigate: (sectionId: string) => void; +}) { + const Icon = state.tone === 'ok' ? CheckCircle2 : AlertCircle; + + return ( +
    +
    {field.label}
    + {field.help ? ( +

    {field.help}

    + ) : null} +
    +
    +
    +
    +
    + ); +} diff --git a/application/v2_ui/src/components/admin/LatestFeaturesVisibility.tsx b/application/v2_ui/src/components/admin/LatestFeaturesVisibility.tsx new file mode 100644 index 000000000..d0fedfe34 --- /dev/null +++ b/application/v2_ui/src/components/admin/LatestFeaturesVisibility.tsx @@ -0,0 +1,315 @@ +// LatestFeaturesVisibility.tsx +// Which release announcements end users see on the Support menu's Latest Features page. +// +// The classic page splits this across two tabs: a checklist on User-Facing Latest Features, +// and a read-only preview of older releases on Admin Latest Features. Here they are one +// list. Each announcement carries its share checkbox and opens to show exactly what users +// read -- the details, the steps they are given, the screenshots, and the shortcuts they +// would see with the settings as currently edited -- so the decision is made with the +// content in view. +// +// The whole map is written into the page draft and saved with everything else. Every +// announcement is named in it, so a save does not depend on what the stored map happened +// to contain, and the server merges it over the stored choices regardless. + +import { useMemo, useState } from 'react'; +import { clsx } from 'clsx'; +import { ArrowUpRight } from 'lucide-react'; +import { + CatalogueError, + CatalogueSkeleton, + FeatureDetails, + FeatureDisclosure, + ReleaseGroupPanel, + latestFeatureActionClass, +} from './LatestFeatureParts'; +import { resolveLatestFeatureIcon } from './latestFeatureIcons'; +import { + allFeatures, + countShared, + filterGroups, + isActionShown, + latestFeatureCardId, + readVisibility, + safeLatestFeatureHref, + type LatestFeature, + type LatestFeatureGroup, +} from '../../lib/latestFeatures'; +import type { AdminField } from '../../lib/adminFields'; + +/** The shortcuts a user would see, opened in a new tab so unsaved settings survive. */ +function UserShortcuts({ + feature, + read, +}: { + feature: LatestFeature; + read: (key: string) => unknown; +}) { + const shown = feature.actions.filter( + (action) => isActionShown(action, read) && safeLatestFeatureHref(action.href), + ); + if (!shown.length) { + return null; + } + return ( +
    +

    Shortcuts users see

    +
    + {shown.map((action) => { + const Icon = resolveLatestFeatureIcon(action.icon); + return ( + + + ); + })} +
    +
    + ); +} + +function UserFeatureRow({ + feature, + shared, + disabled, + open, + onShare, + onToggle, + read, +}: { + feature: LatestFeature; + shared: boolean; + disabled: boolean; + open: boolean; + onShare: (next: boolean) => void; + onToggle: () => void; + read: (key: string) => unknown; +}) { + const Icon = resolveLatestFeatureIcon(feature.icon); + const rowId = `user-${latestFeatureCardId(feature.id)}`; + const checkboxId = `${rowId}-shared`; + const previewId = `${rowId}-preview`; + + return ( +
  • +
    + onShare(event.target.checked)} + /> + +
    + +

    + {feature.summary} +

    +
    + +
    + {open ? ( +
    + } + /> +
    + ) : null} +
  • + ); +} + +export function LatestFeaturesVisibility({ + field, + groups, + loading, + error, + onRetry, + value, + disabled, + query, + read, + onChange, +}: { + field: AdminField; + groups: LatestFeatureGroup[] | null; + loading: boolean; + error: string | null; + onRetry: () => void; + /** The stored or drafted visibility map. */ + value: unknown; + disabled: boolean; + /** The page search; narrows the rows to matching announcements. */ + query: string; + /** Reads the settings being edited, so previews follow unsaved switches. */ + read: (key: string) => unknown; + onChange: (next: Record) => void; +}) { + const [openGroups, setOpenGroups] = useState | null>(null); + const [openFeatures, setOpenFeatures] = useState>(() => new Set()); + + const catalogue = useMemo(() => groups ?? [], [groups]); + const visibility = useMemo(() => readVisibility(value, catalogue), [value, catalogue]); + const searching = Boolean(query.trim()); + const shownGroups = useMemo(() => filterGroups(catalogue, query), [catalogue, query]); + const total = allFeatures(catalogue).length; + const shared = countShared(visibility, allFeatures(catalogue)); + + const isGroupOpen = (group: LatestFeatureGroup) => + searching || (openGroups ? openGroups.has(group.id) : group.default_expanded); + + const toggleGroup = (group: LatestFeatureGroup) => { + setOpenGroups((current) => { + const next = new Set( + current ?? catalogue.filter((item) => item.default_expanded).map((item) => item.id), + ); + if (next.has(group.id)) { + next.delete(group.id); + } else { + next.add(group.id); + } + return next; + }); + }; + + const toggleFeature = (featureId: string) => { + setOpenFeatures((current) => { + const next = new Set(current); + if (next.has(featureId)) { + next.delete(featureId); + } else { + next.add(featureId); + } + return next; + }); + }; + + const share = (features: LatestFeature[], next: boolean) => { + const updated = { ...visibility }; + for (const feature of features) { + updated[feature.id] = next; + } + onChange(updated); + }; + + return ( +
    +
    +

    {field.label}

    + {groups ? ( +

    + {shared} of {total} shared +

    + ) : null} +
    + {field.help ? ( +

    {field.help}

    + ) : null} + +
    + {!groups && (loading || !error) ? : null} + {error && !groups ? : null} + + {groups && searching && !shownGroups.length ? ( +

    No announcements match “{query.trim()}”.

    + ) : null} + + {shownGroups.map((group) => { + const open = isGroupOpen(group); + const groupShared = countShared(visibility, group.features); + const allShared = groupShared === group.features.length; + const noneShared = groupShared === 0; + return ( + toggleGroup(group)} + actions={ + <> + + + + } + > + {group.features.map((feature) => ( + share([feature], next)} + onToggle={() => toggleFeature(feature.id)} + read={read} + /> + ))} + + ); + })} +
    +
    + ); +} diff --git a/application/v2_ui/src/components/admin/ReleaseNotificationsBadge.tsx b/application/v2_ui/src/components/admin/ReleaseNotificationsBadge.tsx new file mode 100644 index 000000000..641ff6a66 --- /dev/null +++ b/application/v2_ui/src/components/admin/ReleaseNotificationsBadge.tsx @@ -0,0 +1,357 @@ +// ReleaseNotificationsBadge.tsx +// Registered / Unregistered, beside the version, and the registration behind it. +// +// The classic page shows whether this deployment is registered for SimpleChat release +// updates and community call notifications as a badge next to the version number, because +// that is where an administrator checking for a new release is already looking. Choosing it +// opens the registration: read-only once registered, with an edit step, and a form until +// then. Submitting saves the details through the classic endpoint, which also records the +// registration in the activity log, and opens a prefilled email draft to the product team. +// +// The registration is written straight to the settings document rather than through the +// page's draft, so the page is told the new values and keeps its copy current. + +import { useState, type FormEvent } from 'react'; +import { clsx } from 'clsx'; +import { AlertCircle, BellPlus, CheckCircle2, Loader2, Send, TriangleAlert } from 'lucide-react'; +import { api } from '../../lib/apiClient'; +import { asBoolean, asString, type AdminField } from '../../lib/adminFields'; +import { + formatRegistrationTime, + isPlausibleEmail, + registrationDraftBody, + safeMailtoHref, + type RegistrationResponse, +} from '../../lib/supportFeedback'; +import { toast } from '../../stores/toastStore'; +import type { Json } from '../../lib/types'; +import { AdminModal } from './AdminModal'; +import { FieldShell, inputClass } from './fields'; +import { GlassButton } from '../ui/primitives'; + +interface RegistrationFields { + name: string; + email: string; + organization: string; +} + +function rowField(key: string, label: string): AdminField { + return { key, type: 'text', label }; +} + +export function ReleaseNotificationsBadge({ + settings, + defaultName, + defaultEmail, + appVersion, + onRegistered, +}: { + settings: Json; + defaultName: string; + defaultEmail: string; + appVersion: string; + /** Receives the stored keys the registration just wrote. */ + onRegistered: (updates: Json) => void; +}) { + const registered = asBoolean(settings.release_notifications_registered); + const stored: RegistrationFields = { + name: asString(settings.release_notifications_name), + email: asString(settings.release_notifications_email), + organization: asString(settings.release_notifications_org), + }; + + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(false); + const [fields, setFields] = useState(stored); + const [errors, setErrors] = useState>>({}); + const [sending, setSending] = useState(false); + const [result, setResult] = useState< + | { ok: true; recipient: string; subject: string; body: string } + | { ok: false; message: string } + | null + >(null); + + const begin = () => { + // A first registration starts from the signed-in administrator, as the classic + // page does; an existing one starts from what is stored. + setFields({ + name: stored.name || defaultName, + email: stored.email || defaultEmail, + organization: stored.organization, + }); + setErrors({}); + setResult(null); + setEditing(!registered); + setOpen(true); + }; + + const close = () => { + setOpen(false); + setEditing(false); + }; + + const update = (key: keyof RegistrationFields, next: string) => { + setFields((current) => ({ ...current, [key]: next })); + setErrors((current) => { + if (!current[key]) { + return current; + } + const remaining = { ...current }; + delete remaining[key]; + return remaining; + }); + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const problems: Partial> = {}; + if (!fields.name.trim()) { + problems.name = 'Enter your name.'; + } + if (!fields.email.trim()) { + problems.email = 'Enter your email address.'; + } else if (!isPlausibleEmail(fields.email)) { + problems.email = 'Enter a valid email address.'; + } + if (!fields.organization.trim()) { + problems.organization = 'Enter your organization.'; + } + setErrors(problems); + if (Object.keys(problems).length) { + setResult({ ok: false, message: 'Complete the highlighted fields to register.' }); + return; + } + + setSending(true); + setResult(null); + try { + const name = fields.name.trim(); + const email = fields.email.trim(); + const organization = fields.organization.trim(); + const response = await api.post( + '/api/admin/settings/release_notifications_registration', + { name, email, organization }, + ); + const registeredAt = response.registeredAt || asString(settings.release_notifications_registered_at); + onRegistered({ + release_notifications_registered: true, + release_notifications_name: name, + release_notifications_email: email, + release_notifications_org: organization, + release_notifications_registered_at: registeredAt, + release_notifications_updated_at: + response.updatedAt || asString(settings.release_notifications_updated_at), + }); + + const body = registrationDraftBody({ name, email, organization, appVersion, registeredAt }); + const draftHref = safeMailtoHref(response.recipientEmail, response.subjectLine, body); + setEditing(false); + if (!draftHref) { + setResult({ + ok: false, + message: 'Registration saved, but the server returned a recipient that is not a valid email address.', + }); + return; + } + setResult({ ok: true, recipient: response.recipientEmail, subject: response.subjectLine, body }); + toast.success('Release notifications registration saved.'); + window.location.href = draftHref; + } catch (error) { + setResult({ + ok: false, + message: + error instanceof Error && error.message + ? error.message + : 'Unable to prepare the registration email draft.', + }); + } finally { + setSending(false); + } + }; + + const formId = 'release-notifications-form'; + + return ( + <> + + + {open ? ( + + { + if (registered) { + setEditing(false); + setErrors({}); + setResult(null); + } else { + close(); + } + }} + > + Cancel + + + {sending ? ( + + + ) : ( + <> + + Close + + { + setEditing(true); + setResult(null); + }} + > + Edit registration + + + ) + } + > + {editing ? ( +
    void submit(event)}> +

    +

    + + update('name', event.target.value)} + /> + + + update('email', event.target.value)} + /> + + + update('organization', event.target.value)} + /> + +
    + ) : ( +
    +

    +

    +
    +
    Name
    +
    {asString(settings.release_notifications_name) || '-'}
    +
    Email
    +
    {asString(settings.release_notifications_email) || '-'}
    +
    Organization
    +
    {asString(settings.release_notifications_org) || '-'}
    +
    Registered
    +
    + {formatRegistrationTime(settings.release_notifications_registered_at)} +
    +
    Last updated
    +
    + {formatRegistrationTime(settings.release_notifications_updated_at)} +
    +
    +
    + )} + +
    + {result?.ok === true ? ( +

    +

    + ) : null} + {result?.ok === false ? ( +

    +

    + ) : null} +
    +
    + ) : null} + + ); +} diff --git a/application/v2_ui/src/components/admin/SendFeedback.tsx b/application/v2_ui/src/components/admin/SendFeedback.tsx new file mode 100644 index 000000000..8d5140c5b --- /dev/null +++ b/application/v2_ui/src/components/admin/SendFeedback.tsx @@ -0,0 +1,303 @@ +// SendFeedback.tsx +// The Send Feedback cards: a bug report or feature request for the SimpleChat team. +// +// This is a utility, not a setting. Each card prepares an email draft through +// `/api/admin/settings/send_feedback_email` -- the endpoint the classic tab posts to, which +// records the submission in the activity log and returns the recipient and subject -- and +// then opens it in the local mail app. Nothing here touches the settings draft, so typing a +// report never lights up the save bar. +// +// The Support menu has a destination with the same name that routes end users' feedback to +// the organisation's own mailbox. The overview card says which is which and links across, +// because the shared name is the easiest thing in this group to get wrong. + +import { useRef, useState, type FormEvent } from 'react'; +import { clsx } from 'clsx'; +import { AlertCircle, ArrowRight, CheckCircle2, Loader2, Mail, TriangleAlert } from 'lucide-react'; +import { api } from '../../lib/apiClient'; +import type { AdminField } from '../../lib/adminFields'; +import { + FEEDBACK_LABELS, + feedbackDraftBody, + safeMailtoHref, + validateFeedbackFields, + type FeedbackFields, + type FeedbackKind, + type FeedbackResponse, +} from '../../lib/supportFeedback'; +import { toast } from '../../stores/toastStore'; +import { FieldShell, inputClass } from './fields'; +import { GlassButton } from '../ui/primitives'; +import { SUPPORT_MENU_SECTION_ID } from './LatestFeaturesPublication'; + +/** + * Reports in progress, kept for the life of the page. + * + * A search or a category change unmounts these cards, and losing a half-written bug report + * to a search for something else would be a small disaster. The text stays until it is + * replaced or the page is reloaded. + */ +const feedbackDrafts: Partial> = {}; + +const DETAILS_PLACEHOLDER: Record = { + bug_report: 'Describe what happened, what you expected, and how to reproduce it.', + feature_request: 'Describe the problem, the improvement you want, and the outcome you need.', +}; + +const DETAILS_LABEL: Record = { + bug_report: 'Bug Details', + feature_request: 'Feature Request Details', +}; + +const SUBMIT_LABEL: Record = { + bug_report: 'Open Bug Report Email', + feature_request: 'Open Feature Request Email', +}; + +/** A pseudo field, so the form rows use the same label/help/control layout as settings. */ +function rowField(key: string, label: string, help?: string): AdminField { + return { key, type: 'text', label, help }; +} + +export function SendFeedbackOverview({ + field, + onNavigate, +}: { + field: AdminField; + onNavigate: (sectionId: string) => void; +}) { + return ( +
    +
    {field.label}
    + {field.help ? ( +

    {field.help}

    + ) : null} +
    +

    +

    +
    +

    + This reaches the SimpleChat product team. Feedback from your own users goes + to your support mailbox instead, through the Support menu's Send + Feedback destination. +

    + +
    +
    +
    + ); +} + +export function SendFeedbackForm({ + kind, + field, + defaultName, + defaultEmail, + appVersion, +}: { + kind: FeedbackKind; + field: AdminField; + defaultName: string; + defaultEmail: string; + appVersion: string; +}) { + const [fields, setFields] = useState( + () => + feedbackDrafts[kind] ?? { + name: defaultName, + email: defaultEmail, + organization: '', + details: '', + }, + ); + const [errors, setErrors] = useState>>({}); + const [sending, setSending] = useState(false); + const [result, setResult] = useState< + | { ok: true; recipient: string; subject: string; body: string } + | { ok: false; message: string } + | null + >(null); + const formRef = useRef(null); + + const idPrefix = `send-feedback-${kind}`; + const label = FEEDBACK_LABELS[kind]; + + const update = (key: keyof FeedbackFields, next: string) => { + setFields((current) => { + const updated = { ...current, [key]: next }; + feedbackDrafts[kind] = updated; + return updated; + }); + setErrors((current) => { + if (!current[key]) { + return current; + } + const remaining = { ...current }; + delete remaining[key]; + return remaining; + }); + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + const problems = validateFeedbackFields(fields); + setErrors(problems); + const firstInvalid = (['name', 'email', 'organization', 'details'] as const).find( + (key) => problems[key], + ); + if (firstInvalid) { + setResult({ ok: false, message: 'Complete the highlighted fields to prepare the draft.' }); + formRef.current?.querySelector(`#${idPrefix}-${firstInvalid}`)?.focus(); + return; + } + + setSending(true); + setResult(null); + try { + const response = await api.post('/api/admin/settings/send_feedback_email', { + feedbackType: kind, + reporterName: fields.name.trim(), + reporterEmail: fields.email.trim(), + organization: fields.organization.trim(), + details: fields.details.trim(), + }); + const body = feedbackDraftBody(kind, fields, appVersion); + const draftHref = safeMailtoHref(response.recipientEmail, response.subjectLine, body); + if (!draftHref) { + throw new Error('The server returned a recipient that is not a valid email address.'); + } + setResult({ + ok: true, + recipient: response.recipientEmail, + subject: response.subjectLine, + body, + }); + toast.success(`${label} email draft prepared.`); + window.location.href = draftHref; + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Unable to prepare the feedback email draft.'; + setResult({ ok: false, message }); + toast.error(message); + } finally { + setSending(false); + } + }; + + return ( +
    void submit(event)} aria-label={field.label}> +
    + + update('name', event.target.value)} + /> + + + update('email', event.target.value)} + /> + + + update('organization', event.target.value)} + /> + + +