diff --git a/web/lib/opal/src/components/buttons/attachment-item-button/AttachmentItemButton.stories.tsx b/web/lib/opal/src/components/buttons/attachment-item-button/AttachmentItemButton.stories.tsx index f983813c83..afcc56cec5 100644 --- a/web/lib/opal/src/components/buttons/attachment-item-button/AttachmentItemButton.stories.tsx +++ b/web/lib/opal/src/components/buttons/attachment-item-button/AttachmentItemButton.stories.tsx @@ -67,7 +67,7 @@ export const Prominences: Story = { prominence="secondary" icon={SvgFileText} title="secondary" - description="Rests on background-neutral-01" + description="Rests on background-tint-01" /> ( - - ); - export default SvgExternalLink; diff --git a/web/src/app/css/attachment-item.css b/web/src/app/css/attachment-item.css deleted file mode 100644 index 8b31438ffd..0000000000 --- a/web/src/app/css/attachment-item.css +++ /dev/null @@ -1,85 +0,0 @@ -/* AttachmentButton styles */ - -.attachment-item { - display: flex; - flex-direction: row; - width: 100%; - padding: 0.25rem; - background-color: var(--background-tint-00); - border-radius: var(--radius-12); - gap: 0.5rem; -} - -.attachment-item:hover { - background-color: var(--background-tint-02); -} - -.attachment-item[data-state="selected"] { - background-color: var(--action-selection-01); -} - -.attachment-item__content { - flex: 1; - display: flex; - flex-direction: row; - gap: 0.5rem; - min-width: 0; -} - -.attachment-item__icon-wrapper { - height: 100%; - aspect-ratio: 1; - background-color: var(--background-tint-01); - border-radius: var(--radius-08); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.attachment-item__icon { - height: 1rem; - width: 1rem; - stroke: var(--text-02); -} - -.attachment-item[data-state="processing"] .attachment-item__icon { - stroke: var(--text-01); -} - -.attachment-item__text-container { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - min-width: 0; - flex: 1; -} - -.attachment-item__title-row { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5rem; - width: 100%; - min-width: 0; -} - -.attachment-item__title-wrapper { - max-width: 70%; - min-width: 0; - flex-shrink: 1; - overflow: hidden; -} - -.attachment-item__actions { - display: flex; - flex-direction: row; - align-self: stretch; - justify-content: flex-end; - align-items: center; - gap: 0.5rem; - padding: 0.25rem; - flex-shrink: 0; -} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 533856bada..79d74e827d 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,5 +1,4 @@ @import "@onyx-ai/opal/root.css"; -@import "./css/attachment-item.css" layer(base); @import "./css/content-editable.css" layer(base); @import "./css/card.css" layer(base); @import "./css/comet.css" layer(base); diff --git a/web/src/i18n/messages/ar.json b/web/src/i18n/messages/ar.json index f3e6fc0764..a10405841c 100644 --- a/web/src/i18n/messages/ar.json +++ b/web/src/i18n/messages/ar.json @@ -10396,6 +10396,14 @@ "fileCount": { "label": "{count, plural, one {# ملف} other {# ملفات}}" }, + "fileRow": { + "deleteButton": { + "ariaLabel": "حذف الملف" + }, + "viewButton": { + "ariaLabel": "فتح الملف" + } + }, "fileStatus": { "deleting": { "label": "جارٍ الحذف..." diff --git a/web/src/i18n/messages/de.json b/web/src/i18n/messages/de.json index f6e2ec21de..dc70c80b7b 100644 --- a/web/src/i18n/messages/de.json +++ b/web/src/i18n/messages/de.json @@ -10396,6 +10396,14 @@ "fileCount": { "label": "{count, plural, one {# Datei} other {# Dateien}}" }, + "fileRow": { + "deleteButton": { + "ariaLabel": "Datei löschen" + }, + "viewButton": { + "ariaLabel": "Datei öffnen" + } + }, "fileStatus": { "deleting": { "label": "Wird gelöscht..." diff --git a/web/src/i18n/messages/en.json b/web/src/i18n/messages/en.json index ea876a6c61..3b132592d0 100644 --- a/web/src/i18n/messages/en.json +++ b/web/src/i18n/messages/en.json @@ -10396,6 +10396,14 @@ "fileCount": { "label": "{count, plural, one {# File} other {# Files}}" }, + "fileRow": { + "deleteButton": { + "ariaLabel": "Delete file" + }, + "viewButton": { + "ariaLabel": "Open file" + } + }, "fileStatus": { "deleting": { "label": "Deleting..." diff --git a/web/src/i18n/messages/es.json b/web/src/i18n/messages/es.json index ff0b2d8e00..b533a33dd4 100644 --- a/web/src/i18n/messages/es.json +++ b/web/src/i18n/messages/es.json @@ -10396,6 +10396,14 @@ "fileCount": { "label": "{count, plural, one {# archivo} other {# archivos}}" }, + "fileRow": { + "deleteButton": { + "ariaLabel": "Eliminar archivo" + }, + "viewButton": { + "ariaLabel": "Abrir archivo" + } + }, "fileStatus": { "deleting": { "label": "Eliminando..." diff --git a/web/src/i18n/messages/fr.json b/web/src/i18n/messages/fr.json index cde5f5ecd0..d2da2cb9bb 100644 --- a/web/src/i18n/messages/fr.json +++ b/web/src/i18n/messages/fr.json @@ -10396,6 +10396,14 @@ "fileCount": { "label": "{count, plural, one {# fichier} other {# fichiers}}" }, + "fileRow": { + "deleteButton": { + "ariaLabel": "Supprimer le fichier" + }, + "viewButton": { + "ariaLabel": "Ouvrir le fichier" + } + }, "fileStatus": { "deleting": { "label": "Suppression en cours..." diff --git a/web/src/i18n/messages/ja.json b/web/src/i18n/messages/ja.json index e8a6cafb15..afea46dfbb 100644 --- a/web/src/i18n/messages/ja.json +++ b/web/src/i18n/messages/ja.json @@ -10396,6 +10396,14 @@ "fileCount": { "label": "{count, plural, one {# 件のファイル} other {# 件のファイル}}" }, + "fileRow": { + "deleteButton": { + "ariaLabel": "ファイルを削除" + }, + "viewButton": { + "ariaLabel": "ファイルを開く" + } + }, "fileStatus": { "deleting": { "label": "削除中..." diff --git a/web/src/i18n/messages/ko.json b/web/src/i18n/messages/ko.json index 67506b4874..fe2ca44e15 100644 --- a/web/src/i18n/messages/ko.json +++ b/web/src/i18n/messages/ko.json @@ -10396,6 +10396,14 @@ "fileCount": { "label": "{count, plural, one {파일 #개} other {파일 #개}}" }, + "fileRow": { + "deleteButton": { + "ariaLabel": "파일 삭제" + }, + "viewButton": { + "ariaLabel": "파일 열기" + } + }, "fileStatus": { "deleting": { "label": "삭제 중..." diff --git a/web/src/i18n/messages/pt.json b/web/src/i18n/messages/pt.json index 087fb4f868..0c9d6bd653 100644 --- a/web/src/i18n/messages/pt.json +++ b/web/src/i18n/messages/pt.json @@ -10396,6 +10396,14 @@ "fileCount": { "label": "{count, plural, one {# arquivo} other {# arquivos}}" }, + "fileRow": { + "deleteButton": { + "ariaLabel": "Excluir arquivo" + }, + "viewButton": { + "ariaLabel": "Abrir arquivo" + } + }, "fileStatus": { "deleting": { "label": "Excluindo..." diff --git a/web/src/i18n/messages/zh.json b/web/src/i18n/messages/zh.json index b5ea40e550..9ad7c8675d 100644 --- a/web/src/i18n/messages/zh.json +++ b/web/src/i18n/messages/zh.json @@ -10396,6 +10396,14 @@ "fileCount": { "label": "{count, plural, one {# 个文件} other {# 个文件}}" }, + "fileRow": { + "deleteButton": { + "ariaLabel": "删除文件" + }, + "viewButton": { + "ariaLabel": "打开文件" + } + }, "fileStatus": { "deleting": { "label": "正在删除..." diff --git a/web/src/layouts/general-layouts.tsx b/web/src/layouts/general-layouts.tsx index 1cfcf57a78..3c4f0743b9 100644 --- a/web/src/layouts/general-layouts.tsx +++ b/web/src/layouts/general-layouts.tsx @@ -1,7 +1,6 @@ import { cn } from "@opal/utils"; import Text from "@/refresh-components/texts/Text"; import Truncated from "@/refresh-components/texts/Truncated"; -import { Content } from "@opal/layouts"; import { IconProps } from "@opal/types"; import React from "react"; @@ -18,61 +17,6 @@ export { import { Section } from "@opal/layouts/general/components"; -export interface AttachmentItemLayoutProps { - title: string; - description: string; - icon: React.FunctionComponent; - middleText?: string; - rightChildren?: React.ReactNode; -} -function AttachmentItemLayout({ - title, - description, - icon: Icon, - middleText, - rightChildren, -}: AttachmentItemLayoutProps) { - return ( -
-
-
-
- -
-
-
-
-
- -
- {middleText && ( -
- - {middleText} - -
- )} - {rightChildren &&
{rightChildren}
} -
-
- ); -} - export interface CardItemLayoutProps { icon: React.FunctionComponent; title: string; @@ -118,4 +62,4 @@ function CardItemLayout({ ); } -export { CardItemLayout, AttachmentItemLayout }; +export { CardItemLayout }; diff --git a/web/src/refresh-components/buttons/AttachmentButton.stories.tsx b/web/src/refresh-components/buttons/AttachmentButton.stories.tsx deleted file mode 100644 index 60a6d75d33..0000000000 --- a/web/src/refresh-components/buttons/AttachmentButton.stories.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import AttachmentButton from "./AttachmentButton"; -import { SvgTextLines, SvgTrash, SvgFiles } from "@opal/icons"; -import * as TooltipPrimitive from "@radix-ui/react-tooltip"; - -const meta: Meta = { - title: "refresh-components/buttons/AttachmentButton", - component: AttachmentButton, - tags: ["autodocs"], - decorators: [ - (Story) => ( - -
- -
-
- ), - ], -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - icon: SvgTextLines, - children: "Project Proposal", - description: "document.pdf", - rightText: "2.4 MB", - }, -}; - -export const Selected: Story = { - args: { - icon: SvgTextLines, - children: "Project Proposal", - description: "document.pdf", - rightText: "2.4 MB", - selected: true, - }, -}; - -export const Processing: Story = { - args: { - icon: SvgTextLines, - children: "Project Proposal", - description: "Uploading...", - rightText: "45%", - processing: true, - }, -}; - -export const WithViewButton: Story = { - args: { - icon: SvgTextLines, - children: "Project Proposal", - description: "document.pdf", - rightText: "2.4 MB", - onView: () => {}, - }, -}; - -export const WithActionButton: Story = { - args: { - icon: SvgTextLines, - children: "Project Proposal", - description: "document.pdf", - rightText: "2.4 MB", - actionIcon: SvgTrash, - onAction: () => {}, - }, -}; - -export const FileList: Story = { - render: () => ( -
- {}} - > - Project Proposal - - - Quarterly Report - - - Meeting Notes - - {}} - > - README - -
- ), -}; diff --git a/web/src/refresh-components/buttons/AttachmentButton.tsx b/web/src/refresh-components/buttons/AttachmentButton.tsx deleted file mode 100644 index 363c7e95dd..0000000000 --- a/web/src/refresh-components/buttons/AttachmentButton.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * AttachmentButton - A button component for displaying file attachments or similar items - * - * Displays an attachment item with an icon, title, description, metadata text, - * and optional action buttons. Commonly used for file lists, attachment pickers, - * and similar UI patterns where items can be viewed or acted upon. - * - * Features: - * - Three visual states: default, selected (shows checkbox), processing - * - Left icon that changes to checkbox when selected - * - Truncated title and description text - * - Right-aligned metadata text (e.g., file size, date) - * - Optional view button (external link icon) that appears on hover - * - Optional action button (custom icon) that appears on hover - * - Full-width button with hover states - * - Prevents event bubbling for nested action buttons - * - * @example - * ```tsx - * import AttachmentButton from "@/refresh-components/buttons/AttachmentButton"; - * import { SvgFileText, SvgTrash } from "@opal/icons"; - * - * // Basic attachment - * - * Project Proposal - * - * - * // Selected state with view button - * window.open('/view/doc')} - * > - * Project Proposal - * - * - * // With action button (delete) - * handleDelete()} - * > - * Project Proposal - * - * - * // Processing state - * - * Project Proposal - * - * ``` - */ - -import React from "react"; -import { noProp } from "@/lib/utils"; -import Truncated from "@/refresh-components/texts/Truncated"; -import { Button } from "@opal/components"; -import Text from "@/refresh-components/texts/Text"; -import type { IconProps } from "@opal/types"; -import { Checkbox } from "@opal/components"; -import { SvgExternalLink } from "@opal/icons"; -import type { WithoutStyles } from "@opal/types"; -import { Hoverable } from "@opal/core"; - -export interface AttachmentProps extends WithoutStyles< - React.ButtonHTMLAttributes -> { - selected?: boolean; - processing?: boolean; - - icon: React.FunctionComponent; - children: string; - description?: string; - rightText?: string; - onView?: () => void; - - // Action button: An optional secondary action button that appears on hover. - // Commonly used for actions like delete, download, or remove. - // Both `actionIcon` and `onAction` must be provided for the button to appear. - actionIcon?: React.FunctionComponent; - onAction?: () => void; -} - -export default function AttachmentButton({ - selected, - processing, - icon: Icon, - children, - description, - rightText, - onView, - actionIcon, - onAction, - ...props -}: AttachmentProps) { - const state = selected ? "selected" : processing ? "processing" : "default"; - - return ( - - - - ); -} diff --git a/web/src/sections/cards/DocumentSetCard.tsx b/web/src/sections/cards/DocumentSetCard.tsx index 177a4cf205..a6ac3feca5 100644 --- a/web/src/sections/cards/DocumentSetCard.tsx +++ b/web/src/sections/cards/DocumentSetCard.tsx @@ -1,12 +1,9 @@ "use client"; import { DocumentSetSummary } from "@/lib/types"; -import { Checkbox } from "@opal/components"; -import { Tooltip } from "@opal/components"; -import { SvgFiles } from "@opal/icons"; +import { AttachmentItemButton } from "@opal/components"; import { Interactive } from "@opal/core"; -import { AttachmentItemLayout } from "@/layouts/general-layouts"; -import { Spacer } from "@opal/components"; +import { SvgFiles } from "@opal/icons"; export interface DocumentSetCardProps { documentSet: DocumentSetSummary; @@ -23,47 +20,22 @@ export default function DocumentSetCard({ disabled, disabledTooltip, }: DocumentSetCardProps) { + const selectable = !disabled && isSelected !== undefined; + return ( - -
- onSelectToggle?.(!isSelected) - } - > - - e.stopPropagation()}> - onSelectToggle?.(!isSelected) - } - /> -
- ) - } - /> - - - - -
+
+ + onSelectToggle?.(!isSelected) : undefined} + disabled={disabled} + tooltip={disabled && disabledTooltip ? disabledTooltip : undefined} + /> + +
); } diff --git a/web/src/sections/cards/FileCard.tsx b/web/src/sections/cards/FileCard.tsx index f90b1be474..53becb460f 100644 --- a/web/src/sections/cards/FileCard.tsx +++ b/web/src/sections/cards/FileCard.tsx @@ -8,8 +8,7 @@ import { isImageFile } from "@/lib/utils"; import { cn } from "@opal/utils"; import { SvgFileText, SvgX, SvgSimpleLoader } from "@opal/icons"; import { Interactive, Hoverable } from "@opal/core"; -import { AttachmentItemLayout } from "@/layouts/general-layouts"; -import { Spacer } from "@opal/components"; +import { AttachmentItemButton } from "@opal/components"; interface RemovableProps { onRemove?: () => void; @@ -216,7 +215,8 @@ export function FileCard({ >
- -
); } - -// Skeleton loading component for file cards -export function FileCardSkeleton() { - return ( -
- ); -} diff --git a/web/src/sections/modals/UserFilesModal.tsx b/web/src/sections/modals/UserFilesModal.tsx index 9d59e11b97..9f6dc7939a 100644 --- a/web/src/sections/modals/UserFilesModal.tsx +++ b/web/src/sections/modals/UserFilesModal.tsx @@ -7,11 +7,11 @@ import Text from "@/refresh-components/texts/Text"; import type { IconProps } from "@opal/types"; import { getFileExtension, isImageExtension } from "@/lib/utils"; import { UserFileStatus } from "@/lib/projects/types"; -import AttachmentButton from "@/refresh-components/buttons/AttachmentButton"; import { Modal } from "@opal/components"; import { useModal } from "@opal/components"; import TextSeparator from "@/refresh-components/TextSeparator"; import { + SvgExternalLink, SvgEye, SvgFiles, SvgFileText, @@ -21,6 +21,8 @@ import { SvgXCircle, SvgSimpleLoader, } from "@opal/icons"; +import { Hoverable } from "@opal/core"; +import { AttachmentItemButton, Text as OpalText } from "@opal/components"; import { Section } from "@/layouts/general-layouts"; import useFilter from "@/hooks/useFilter"; import { Button } from "@opal/components"; @@ -89,19 +91,49 @@ function FileAttachment({ : ""; return ( - - {file.name} - + + + + {rightText} + + + ) : undefined + } + rightChildren={ + +
+ {onView && ( +
+
+ } + /> +
); } @@ -228,7 +260,7 @@ export default function UserFilesModal({ {filtered.length === 0 ? ( {t("emptyState.description")} ) : ( - + {filtered.map((projectFle) => { const isSelected = selectedIds.has(projectFle.id); return ( diff --git a/web/src/views/SettingsPage.tsx b/web/src/views/SettingsPage.tsx index 2a543e5e7b..e24b816f10 100644 --- a/web/src/views/SettingsPage.tsx +++ b/web/src/views/SettingsPage.tsx @@ -3,7 +3,7 @@ import { useRef, useCallback, useEffect, useMemo, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Section, AttachmentItemLayout } from "@/layouts/general-layouts"; +import { Section } from "@/layouts/general-layouts"; import { Content, ContentAction, @@ -52,7 +52,13 @@ import useSWR from "swr"; import { SWR_KEYS } from "@/lib/swr-keys"; import { errorHandlingFetcher } from "@/lib/fetcher"; import useFilter from "@/hooks/useFilter"; -import { Button, Divider, Checkbox, Text } from "@opal/components"; +import { + AttachmentItemButton, + Button, + Divider, + Checkbox, + Text, +} from "@opal/components"; import useFederatedOAuthStatus from "@/hooks/useFederatedOAuthStatus"; import useCCPairs from "@/hooks/useCCPairs"; import { ValidSources } from "@/lib/types"; @@ -73,7 +79,6 @@ import { } from "@/lib/constants/chatBackgrounds"; import { SvgCheck } from "@opal/icons"; import { cn } from "@opal/utils"; -import { Interactive } from "@opal/core"; import { useTierAtLeast } from "@/hooks/useTierAtLeast"; import { Tier } from "@/lib/settings/types"; import { useIsSearchModeAvailable, useSettings } from "@/lib/settings/hooks"; @@ -2384,32 +2389,37 @@ function AccountsAccessSettings() { }); return ( - -
- setTokenToDelete(pat)} - prominence="tertiary" - size="sm" - aria-label={t( - "apiKeys.list.deleteTokenAriaLabel", - { name: pat.name } - )} - /> - } + presentational + prominence="secondary" + icon={SvgKey} + title={pat.name} + description={pat.token_display} + centerChildren={ +
+ + {middleText} + +
+ } + rightChildren={ +
-
+ } + /> ); })} diff --git a/web/tests/e2e/chat/project_files_visual_regression.spec.ts b/web/tests/e2e/chat/project_files_visual_regression.spec.ts index d4c6e46f66..d8f774d626 100644 --- a/web/tests/e2e/chat/project_files_visual_regression.spec.ts +++ b/web/tests/e2e/chat/project_files_visual_regression.spec.ts @@ -1,11 +1,10 @@ import { expect, test, type Locator, type Page } from "@playwright/test"; +import { ProjectFilesSection } from "@tests/e2e/pages/ProjectFilesSection"; import { loginAsWorkerUser } from "@tests/e2e/utils/auth"; import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient"; import { expectElementScreenshot } from "@tests/e2e/utils/visualRegression"; const PROJECT_NAME = "E2E-PROJECT-FILES-VISUAL"; -const ATTACHMENT_ITEM_TITLE_TEST_ID = "attachment-item-title"; -const ATTACHMENT_ITEM_ICON_WRAPPER_TEST_ID = "attachment-item-icon-wrapper"; const LONG_FILE_NAME = "CSE_202_Final_Project_Solution_Regression_Check_Long_Name.txt"; const FILE_CONTENT = "Visual regression test content for long filename cards."; @@ -23,14 +22,6 @@ type Geometry = { cardBottom: number; }; -function getFilesSection(page: Page): Locator { - return page - .locator("div") - .filter({ has: page.getByRole("button", { name: "Add Files" }) }) - .filter({ hasText: "Chats in this project can access these files." }) - .first(); -} - async function uploadFileToProject( page: Page, targetProjectId: number, @@ -146,27 +137,24 @@ test.describe("Project Files visual regression", () => { test("long underscore filename stays visually contained in file card", async ({ page, }) => { - const filesSection = getFilesSection(page); - await expect(filesSection).toBeVisible(); + const files = new ProjectFilesSection(page); + await expect(files.section).toBeVisible(); - const fileTitle = filesSection - .locator(`[data-testid="${ATTACHMENT_ITEM_TITLE_TEST_ID}"]`) - .filter({ hasText: LONG_FILE_NAME }) - .first(); + const fileTitle = files.fileTitle(LONG_FILE_NAME); await expect(fileTitle).toBeVisible(); - // Wait for deterministic post-processing state before geometry checks/screenshot. - await expect(fileTitle).not.toContainText("Processing...", { + // Wait for deterministic post-processing state before geometry + // checks/screenshot. Title and description are separate elements now, so + // the state text is asserted on the whole files section. + await expect(files.section).not.toContainText("Processing...", { timeout: 30_000, }); - await expect(fileTitle).not.toContainText("Uploading...", { + await expect(files.section).not.toContainText("Uploading...", { timeout: 30_000, }); - await expect(fileTitle).toContainText("TXT", { timeout: 30_000 }); + await expect(files.section).toContainText("TXT", { timeout: 30_000 }); - const iconWrapper = filesSection - .locator(`[data-testid="${ATTACHMENT_ITEM_ICON_WRAPPER_TEST_ID}"]`) - .first(); + const iconWrapper = files.attachmentTile; await expect(iconWrapper).toBeVisible(); const container = page.locator("[data-main-container]"); diff --git a/web/tests/e2e/pages/ProjectFilesSection.ts b/web/tests/e2e/pages/ProjectFilesSection.ts new file mode 100644 index 0000000000..f05440a60d --- /dev/null +++ b/web/tests/e2e/pages/ProjectFilesSection.ts @@ -0,0 +1,31 @@ +import { Locator, Page } from "@playwright/test"; + +/** + * The "files in this project" section of the project view: the file cards + * plus the "Add Files" affordance above them. + * + * The file rows are Opal `AttachmentItemButton`s. The title is located by + * its visible text; the icon tile has no testid, role or text, so its Opal + * class is the documented last-resort locator. + */ +export class ProjectFilesSection { + constructor(readonly page: Page) {} + + get section(): Locator { + return this.page + .locator("div") + .filter({ has: this.page.getByRole("button", { name: "Add Files" }) }) + .filter({ hasText: "Chats in this project can access these files." }) + .first(); + } + + /** A file card's title, by the file's visible name. */ + fileTitle(fileName: string): Locator { + return this.section.getByText(fileName).first(); + } + + /** The first file card's icon tile. */ + get attachmentTile(): Locator { + return this.section.locator(".opal-attachment-item-button-tile").first(); + } +}