From 5922cd1c39423e41f40d9a7d2af4f40eb6b17132 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 6 Oct 2026 22:51:42 -0400 Subject: [PATCH] Bring the V2 admin Help group to parity with the classic page Support now declares the menu name, the Send Feedback destination and its recipient, nested under the menu switch. The card reads Needs configuration while Send Feedback has no recipient, the empty field is marked Required, and a malformed address is refused in place instead of silently cleared. Send Feedback gains its overview and the bug report and feature request forms, posting to the existing endpoint and opening the mailto draft without touching the settings draft. User-Facing Latest Features lists every announcement by release with share checkboxes, bulk actions and a preview of what users see, and stays editable before publishing with a publication notice that jumps to Support. The visibility map merges over the stored one so partial saves cannot re-share. Admin Latest Features renders as a catalogue card marked New, served by the new admin-only GET /api/v2/admin/latest-features. Shortcuts jump to V2 cards or fall back to the classic tab through a Python mirror of LEGACY_TAB_REDIRECTS. The version banner gains the release notifications registration badge. Also fixes the application title being substituted twice in Latest Features copy when it contains "SimpleChat". Version 0.261.260. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../single_app/admin_settings_fields.py | 327 ++++++++++-- application/single_app/admin_settings_nav.py | 32 ++ application/single_app/config.py | 2 +- .../functions_support_latest_features.py | 235 +++++++++ application/single_app/route_backend_v2.py | 49 +- application/single_app/support_menu_config.py | 52 +- .../components/admin/AdminLatestFeatures.tsx | 261 ++++++++++ .../components/admin/LatestFeatureParts.tsx | 300 +++++++++++ .../admin/LatestFeaturesPublication.tsx | 126 +++++ .../admin/LatestFeaturesVisibility.tsx | 315 ++++++++++++ .../admin/ReleaseNotificationsBadge.tsx | 357 +++++++++++++ .../src/components/admin/SendFeedback.tsx | 303 +++++++++++ .../src/components/admin/SettingsSection.tsx | 59 ++- .../v2_ui/src/components/admin/fields.tsx | 97 +++- .../components/admin/latestFeatureIcons.ts | 111 ++++ .../components/admin/sectionStatusContext.ts | 14 + application/v2_ui/src/lib/adminFields.ts | 7 + application/v2_ui/src/lib/latestFeatures.ts | 244 +++++++++ application/v2_ui/src/lib/supportFeedback.ts | 130 +++++ application/v2_ui/src/lib/types.ts | 6 + .../v2_ui/src/pages/AdminSettingsPage.tsx | 370 +++++++++++-- docs/admin/help.md | 121 +++-- docs/admin/index.md | 2 +- .../features/V2_ADMIN_HELP_SETTINGS.md | 188 +++++++ docs/explanation/release_notes.md | 18 + .../test_v2_admin_capability_placement.py | 20 +- functional_tests/test_v2_admin_help_logic.py | 96 ++++ functional_tests/test_v2_admin_help_logic.ts | 484 ++++++++++++++++++ functional_tests/test_v2_admin_help_parity.py | 367 +++++++++++++ ...st_v2_admin_help_settings_normalization.py | 221 ++++++++ .../test_v2_admin_latest_features_api.py | 287 +++++++++++ .../test_v2_admin_settings_schema.py | 37 +- ui_tests/fixtures/v2_admin_settings.py | 87 +++- ui_tests/test_v2_admin_help_settings.py | 273 ++++++++++ 34 files changed, 5420 insertions(+), 178 deletions(-) create mode 100644 application/single_app/functions_support_latest_features.py create mode 100644 application/v2_ui/src/components/admin/AdminLatestFeatures.tsx create mode 100644 application/v2_ui/src/components/admin/LatestFeatureParts.tsx create mode 100644 application/v2_ui/src/components/admin/LatestFeaturesPublication.tsx create mode 100644 application/v2_ui/src/components/admin/LatestFeaturesVisibility.tsx create mode 100644 application/v2_ui/src/components/admin/ReleaseNotificationsBadge.tsx create mode 100644 application/v2_ui/src/components/admin/SendFeedback.tsx create mode 100644 application/v2_ui/src/components/admin/latestFeatureIcons.ts create mode 100644 application/v2_ui/src/components/admin/sectionStatusContext.ts create mode 100644 application/v2_ui/src/lib/latestFeatures.ts create mode 100644 application/v2_ui/src/lib/supportFeedback.ts create mode 100644 docs/explanation/features/V2_ADMIN_HELP_SETTINGS.md create mode 100644 functional_tests/test_v2_admin_help_logic.py create mode 100644 functional_tests/test_v2_admin_help_logic.ts create mode 100644 functional_tests/test_v2_admin_help_parity.py create mode 100644 functional_tests/test_v2_admin_help_settings_normalization.py create mode 100644 functional_tests/test_v2_admin_latest_features_api.py create mode 100644 ui_tests/test_v2_admin_help_settings.py diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index 374c9632f..92eb90f25 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -30,7 +30,7 @@ to the same normalizers the server-rendered form uses. Both interfaces therefore agree on what a valid value is. -Beyond a field's type, four optional descriptors shape how a section reads: +Beyond a field's type, five optional descriptors shape how a section reads: ``group`` Names the cluster a field belongs to, either as a label or with a variant @@ -59,8 +59,17 @@ input would otherwise save to a top-level key nothing reads. See ``_apply_nested_paths``. -The Appearance, Agents & Actions, Chat, Knowledge, Workflow, Workspaces and -Security groups are described in full. Sections with no entry here fall back to the V2 surface's +``related_section`` + Points at another section whose settings only matter because of this + one, as ``{"id":
, "label": }``. ``requires`` is + the opposite direction: it names a prerequisite a field waits on. This + names the follow-up a switch leads to -- turning on the Latest Features + destination is what makes the User-Facing Latest Features choices + visible to users -- and the renderer draws it as an in-page jump beneath + the control. + +The Appearance, Agents & Actions, Chat, Help, Knowledge, Workflow, Workspaces +and Security groups are described in full. 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 @@ -160,6 +169,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}$") @@ -354,6 +367,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,;<>\"]+$") + ADMIN_SETTINGS_FIELDS = { "branding-section": [ @@ -809,16 +836,16 @@ "default": True, }, ], - # The sections below are not part of the Appearance group. They are described - # here because the V2 surface's `enable_*` fallback was filing their toggles + # The Health Check section is not part of the Appearance group. It is described + # here because the V2 surface's `enable_*` fallback was filing its toggles # under Appearance: it matches a key to a section by shared leading word # stems and takes the first section that scores at all, so # `enable_external_healthcheck` matched "external" in External Links long # before it could reach Health Check, whose id splits into "health" and # "check" and so never matches the single token "healthcheck". Declaring a - # key is what takes it out of that scan, so these five are declared rather - # than guessed at. Wording is taken from the V1 panes so both interfaces say - # the same thing. + # key is what takes it out of that scan, so these are declared rather than + # guessed at, as is Personal Workspaces further down. Wording is taken from + # the V1 panes so both interfaces say the same thing. "health-check-section": [ { "key": "enable_external_healthcheck", @@ -843,32 +870,14 @@ "default": False, }, ], - "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", @@ -880,6 +889,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", @@ -887,6 +939,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": { + "id": "user-facing-latest-features-section", + "label": "Choose which announcements users see", + }, + }, + ], + # 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": [ @@ -6193,6 +6317,29 @@ "inbound_mcp_allowed_client_app_entries": ["inbound_mcp_allowed_client_app_entries_json"], "inbound_mcp_allowed_tenant_entries": ["inbound_mcp_allowed_tenant_entries_json"], "inbound_mcp_allowed_source_entries": ["inbound_mcp_allowed_source_entries_json"], + # 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 @@ -6291,6 +6438,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"], + }, + ], + }, } @@ -6592,6 +6750,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() @@ -7466,6 +7667,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 + field_value, error, warning = _normalize_field_value(key, value, field) if error: errors[key] = error @@ -7495,6 +7716,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. @@ -7612,6 +7834,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 _check_minimum_selections(normalized, current_settings, errors): """Enforce ``min_selected`` once the merged state of a save is known.""" def read_value(key): diff --git a/application/single_app/admin_settings_nav.py b/application/single_app/admin_settings_nav.py index f63b48806..8194dc8c9 100644 --- a/application/single_app/admin_settings_nav.py +++ b/application/single_app/admin_settings_nav.py @@ -634,6 +634,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 670ab4808..3a890675e 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.259" +VERSION = "0.261.260" 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 424079ae1..2d18315ce 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 ( @@ -167,6 +168,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, @@ -1463,6 +1465,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)} + /> + + +