diff --git a/react/src/__generated__/loginSessionAuthMyUserQuery.graphql.ts b/react/src/__generated__/loginSessionAuthMyUserQuery.graphql.ts new file mode 100644 index 00000000000..5cceafb8102 --- /dev/null +++ b/react/src/__generated__/loginSessionAuthMyUserQuery.graphql.ts @@ -0,0 +1,306 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type UserRoleV2 = "ADMIN" | "MONITOR" | "SUPERADMIN" | "USER" | "%future added value"; +export type loginSessionAuthMyUserQuery$variables = { + limit: number; + offset: number; +}; +export type loginSessionAuthMyUserQuery$data = { + readonly myUserV2: { + readonly basicInfo: { + readonly email: string; + readonly fullName: string | null | undefined; + }; + readonly domain: { + readonly basicInfo: { + readonly name: string; + }; + readonly entityId: string; + } | null | undefined; + readonly id: string; + readonly organization: { + readonly domainName: string | null | undefined; + readonly role: UserRoleV2 | null | undefined; + }; + readonly projects: { + readonly count: number; + readonly edges: ReadonlyArray<{ + readonly node: { + readonly basicInfo: { + readonly name: string; + }; + readonly id: string; + }; + }>; + } | null | undefined; + } | null | undefined; +}; +export type loginSessionAuthMyUserQuery = { + response: loginSessionAuthMyUserQuery$data; + variables: loginSessionAuthMyUserQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "limit" + }, + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "offset" + } +], +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v2 = { + "alias": null, + "args": null, + "concreteType": "UserV2BasicInfo", + "kind": "LinkedField", + "name": "basicInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + } + ], + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "concreteType": "UserV2OrganizationInfo", + "kind": "LinkedField", + "name": "organization", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "domainName", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "role", + "storageKey": null + } + ], + "storageKey": null +}, +v4 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "entityId", + "storageKey": null +}, +v5 = [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + } +], +v6 = { + "alias": null, + "args": null, + "concreteType": "DomainBasicInfo", + "kind": "LinkedField", + "name": "basicInfo", + "plural": false, + "selections": (v5/*: any*/), + "storageKey": null +}, +v7 = { + "alias": null, + "args": [ + { + "kind": "Literal", + "name": "filter", + "value": { + "isActive": true + } + }, + { + "kind": "Variable", + "name": "limit", + "variableName": "limit" + }, + { + "kind": "Variable", + "name": "offset", + "variableName": "offset" + } + ], + "concreteType": "ProjectV2Connection", + "kind": "LinkedField", + "name": "projects", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "count", + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "ProjectV2Edge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "ProjectV2", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v1/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "ProjectBasicInfo", + "kind": "LinkedField", + "name": "basicInfo", + "plural": false, + "selections": (v5/*: any*/), + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "loginSessionAuthMyUserQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "UserV2", + "kind": "LinkedField", + "name": "myUserV2", + "plural": false, + "selections": [ + (v1/*: any*/), + (v2/*: any*/), + (v3/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "DomainV2", + "kind": "LinkedField", + "name": "domain", + "plural": false, + "selections": [ + (v4/*: any*/), + (v6/*: any*/) + ], + "storageKey": null + }, + (v7/*: any*/) + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "loginSessionAuthMyUserQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "UserV2", + "kind": "LinkedField", + "name": "myUserV2", + "plural": false, + "selections": [ + (v1/*: any*/), + (v2/*: any*/), + (v3/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "DomainV2", + "kind": "LinkedField", + "name": "domain", + "plural": false, + "selections": [ + (v4/*: any*/), + (v6/*: any*/), + (v1/*: any*/) + ], + "storageKey": null + }, + (v7/*: any*/) + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "990919f8c616b56de2ca782cf493a464", + "id": null, + "metadata": {}, + "name": "loginSessionAuthMyUserQuery", + "operationKind": "query", + "text": "query loginSessionAuthMyUserQuery(\n $limit: Int!\n $offset: Int!\n) {\n myUserV2 {\n id\n basicInfo {\n email\n fullName\n }\n organization {\n domainName\n role\n }\n domain {\n entityId\n basicInfo {\n name\n }\n id\n }\n projects(filter: {isActive: true}, limit: $limit, offset: $offset) {\n count\n edges {\n node {\n id\n basicInfo {\n name\n }\n }\n }\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "ecda331eff5b74e6402f2006ef4f0732"; + +export default node; diff --git a/react/src/__generated__/useAppConfigDomainRawQuery.graphql.ts b/react/src/__generated__/useAppConfigDomainRawQuery.graphql.ts new file mode 100644 index 00000000000..a40e17db085 --- /dev/null +++ b/react/src/__generated__/useAppConfigDomainRawQuery.graphql.ts @@ -0,0 +1,131 @@ +/** + * @generated SignedSource<<09bf8a0f63ad5f2e17a0ffce88af9582>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type useAppConfigDomainRawQuery$variables = { + configNames: ReadonlyArray; + scopeId: string; +}; +export type useAppConfigDomainRawQuery$data = { + readonly scopedAppConfigFragmentsByNames: ReadonlyArray<{ + readonly config: any; + readonly configName: string; + readonly id: string; + } | null | undefined>; +}; +export type useAppConfigDomainRawQuery = { + response: useAppConfigDomainRawQuery$data; + variables: useAppConfigDomainRawQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "configNames" +}, +v1 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "scopeId" +}, +v2 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "configNames", + "variableName": "configNames" + }, + { + "fields": [ + { + "kind": "Variable", + "name": "scopeId", + "variableName": "scopeId" + }, + { + "kind": "Literal", + "name": "scopeType", + "value": "DOMAIN" + } + ], + "kind": "ObjectValue", + "name": "scope" + } + ], + "concreteType": "AppConfigFragment", + "kind": "LinkedField", + "name": "scopedAppConfigFragmentsByNames", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "configName", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "config", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": [ + (v0/*: any*/), + (v1/*: any*/) + ], + "kind": "Fragment", + "metadata": null, + "name": "useAppConfigDomainRawQuery", + "selections": (v2/*: any*/), + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [ + (v1/*: any*/), + (v0/*: any*/) + ], + "kind": "Operation", + "name": "useAppConfigDomainRawQuery", + "selections": (v2/*: any*/) + }, + "params": { + "cacheID": "1d361fa75e2154bda74a382fd8621e51", + "id": null, + "metadata": {}, + "name": "useAppConfigDomainRawQuery", + "operationKind": "query", + "text": "query useAppConfigDomainRawQuery(\n $scopeId: UUID!\n $configNames: [String!]!\n) {\n scopedAppConfigFragmentsByNames(scope: {scopeType: DOMAIN, scopeId: $scopeId}, configNames: $configNames) {\n id\n configName\n config\n }\n}\n" + } +}; +})(); + +(node as any).hash = "da4a8cf295f7377e3ce13a08a869b0f1"; + +export default node; diff --git a/react/src/components/AnnouncementBanner.css b/react/src/components/AnnouncementBanner.css index 7bf21ee0554..c4784531370 100644 --- a/react/src/components/AnnouncementBanner.css +++ b/react/src/components/AnnouncementBanner.css @@ -19,6 +19,16 @@ min-height: var(--webui-header-height); padding-inline: var(--webui-header-padding-inline); align-content: center; + /* Banner centres its icon and actions only while there is no description; + keep them centred once the expanded body makes the band taller too. */ + align-items: center; +} + +/* The info band's tint swallows Blockquote's grey rule and secondary text, so + colour a quote like the band's own icon to keep it distinguishable. */ +.webui-announcement-body .astryx-blockquote { + border-inline-start-color: var(--color-info); + color: var(--color-text-primary); } /* Electron/macOS: the banner is the window's topmost band, so it grows by the diff --git a/react/src/components/AnnouncementBanner.tsx b/react/src/components/AnnouncementBanner.tsx index c71c089040a..cfddf04b94e 100644 --- a/react/src/components/AnnouncementBanner.tsx +++ b/react/src/components/AnnouncementBanner.tsx @@ -3,12 +3,14 @@ Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ import { + DOMAIN_ANNOUNCEMENT_CONFIG_KEY, + DomainAnnouncement, isAnnouncementCollapsible, - splitAnnouncement, - summarizeAnnouncement, -} from '../helper/announcementSummary'; + isAnnouncementVisible, + summarizeAnnouncementTitle, +} from '../helper/announcement'; import { useCurrentUserRole } from '../hooks/backendai'; -import { useSuspenseGetAnnouncement } from '../hooks/useSuspenseGetAnnouncement'; +import { useDomainAppConfig } from '../hooks/useAppConfig'; import './AnnouncementBanner.css'; import AnnouncementEditModal from './AnnouncementEditModal'; import { Banner } from '@astryxdesign/core/Banner'; @@ -20,17 +22,16 @@ import { useSessionStorageState, useToggle, } from 'backend.ai-ui'; -import * as _ from 'lodash-es'; import { ChevronDownIcon, ChevronUpIcon, SquarePenIcon } from 'lucide-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; /** - * The system announcement as AppShell's top banner (FR-3612), replacing the - * StartPage-only `AnnouncementAlert`. A long announcement renders collapsed — - * a one-line summary followed by an explicit labelled expand toggle revealing - * the full markdown. Dismissal is remembered per session and per message, so - * a new announcement resurfaces the banner. + * The domain's system announcement as AppShell's top banner (FR-3612), read + * from the domain app config (FR-3877). The title shows in every state; a + * body (or a title past the cutoff) renders collapsed behind an explicit + * labelled expand toggle. Dismissal is remembered per session and per + * publication, so a re-published announcement resurfaces the banner. */ const AnnouncementBanner: React.FC = () => { 'use memo'; @@ -42,31 +43,26 @@ const AnnouncementBanner: React.FC = () => { const [isEditOpen, { toggle: toggleEditModal }] = useToggle(false); // Expansion is owned here rather than by Banner's `children` slot: Banner's // own toggle sits at the far end of the header, away from the text it - // reveals. (0.5.0's `collapsible` config added a controlled mode; the - // placement is what still rules the slot out.) + // reveals. const [isExpanded, { toggle: toggleExpanded }] = useToggle(false); - const { data: announcement } = useSuspenseGetAnnouncement(); - const [dismissedMessage, setDismissedMessage] = useSessionStorageState< + const announcement = useDomainAppConfig( + DOMAIN_ANNOUNCEMENT_CONFIG_KEY, + ); + const [dismissedKey, setDismissedKey] = useSessionStorageState< string | undefined >('backendaiwebui.dismissed_announcement'); - const message = announcement.message ?? ''; - if (_.isEmpty(message) || dismissedMessage === message) { + if (!isAnnouncementVisible(announcement)) { + return null; + } + const dismissKey = announcement.updatedAt ?? announcement.title; + if (dismissedKey === dismissKey) { return null; } - // Must use the same renderer settings as the editor preview (FR-3402); the - // banner sits above the page h1, so markdown `#` starts at h3. - const renderMarkdown = (source: string) => ( - - {source} - - ); - const isCollapsible = isAnnouncementCollapsible(message); - // The first line is the banner's title in both states, so the expanded body - // is the source WITHOUT it — expanding adds only what the title does not - // already show. - const { headline, body } = splitAnnouncement(message); + const title = announcement.title.trim(); + const body = (announcement.body ?? '').trim(); + const isCollapsible = isAnnouncementCollapsible(announcement); return ( <> @@ -83,14 +79,13 @@ const AnnouncementBanner: React.FC = () => { } as React.CSSProperties } isDismissable - onDismiss={() => setDismissedMessage(message)} - // Collapsible shape: the first line is the title in BOTH states, with - // the expand toggle right beside it — collapsed it is cut to one row, - // expanded it shows in full (so a cropped long line is never lost) and - // `description` adds the rest of the source below it. Short shape: - // body in `description`, not `title` — Banner centres its header on + onDismiss={() => setDismissedKey(dismissKey)} + // Collapsible shape: the title in BOTH states with the expand toggle + // right beside it — collapsed it is cut to one row, expanded it shows + // in full and `description` adds the body below it. Short shape: the + // title in `description`, not `title` — Banner centres its header on // `description == null && hasActions`, which misaligns the icon and - // Edit button against a multi-line announcement (FR-3482). + // Edit button against a wrapping line (FR-3482). title={ isCollapsible ? ( @@ -101,7 +96,7 @@ const AnnouncementBanner: React.FC = () => { : 'webui-announcement-summary' } > - {isExpanded ? headline : summarizeAnnouncement(message)} + {isExpanded ? title : summarizeAnnouncementTitle(title)}