refactor(web): converge attachment rows on AttachmentItemButton (#14639)

This commit is contained in:
Raunak Bhagat authored and GitHub committed 2026-09-11 03:24:43 +00:00
1 parent 4ad6cfbc27
commit da15c93ffc
27 files changed
+229 -572

No files matched your search

@@ -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"
/>
<AttachmentItemButton
icon={SvgFileText}
@@ -42,7 +42,7 @@ Three modes, same contract as `LineItemButton`:
semantics of its own and defers them to the primitive that owns it.
`prominence` sets the intensity at rest: `"primary"` rests on
`background-tint-00`, `"secondary"` on `background-neutral-01`, and
`background-tint-00`, `"secondary"` on `background-tint-01`, and
`"tertiary"` (default) is transparent. Hover / selected / disabled palettes
come from `Interactive.Stateful` and are shared across all three.
@@ -112,7 +112,7 @@ type AttachmentItemButtonOwnProps = Pick<
* stay the same across all three.
*
* - `"primary"` — rests on `background-tint-00` (elevated rows on a card)
* - `"secondary"` — rests on `background-neutral-01` (rows on a plain page surface)
* - `"secondary"` — rests on `background-tint-01` (rows on a plain page surface)
* - `"tertiary"` — transparent at rest
*
* @default "tertiary"
@@ -9,7 +9,7 @@ Stateful interactive surface primitive for elements that maintain a value state
| Prop | Type | Default | Description |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ---------------------------------------- |
| `variant` | `"select-light" \| "select-heavy" \| "select-card" \| "select-input" \| "select-filter" \| "sidebar-heavy" \| "sidebar-light"` | `"select-heavy"` | Color variant |
| `prominence` | `"primary" \| "secondary" \| "tertiary"` | `"tertiary"` | Rest intensity — select-heavy only: `primary` rests on `background-tint-00`, `secondary` on `background-neutral-01`, `tertiary` transparent. Affects only the at-rest empty/filled backgrounds; other variants ignore it. |
| `prominence` | `"primary" \| "secondary" \| "tertiary"` | `"tertiary"` | Rest intensity — select-heavy only: `primary` rests on `background-tint-00`, `secondary` on `background-tint-01`, `tertiary` transparent. Affects only the at-rest empty/filled backgrounds; other variants ignore it. |
| `state` | `"empty" \| "filled" \| "selected"` | `"empty"` | Current value state |
| `interaction` | `"rest" \| "hover" \| "active"` | `"rest"` | JS-controlled interaction override |
| `group` | `string` | — | Tailwind group class for `group-hover:*` |
@@ -50,7 +50,7 @@ interface InteractiveStatefulProps extends WithoutStyles<
* prominence cells; other variants have a single look and ignore it.
*
* - `"primary"` — rests on `background-tint-00`
* - `"secondary"` — rests on `background-neutral-01`
* - `"secondary"` — rests on `background-tint-01`
* - `"tertiary"` — transparent at rest
*
* Prominence affects only the at-rest empty/filled backgrounds: hover,
@@ -37,7 +37,7 @@
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-prominence="secondary"][data-interactive-state="empty"] {
@apply bg-background-neutral-01;
@apply bg-background-tint-01;
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-prominence="primary"][data-interactive-state="empty"] {
@apply bg-background-tint-00;
@@ -76,7 +76,7 @@
--interactive-foreground-icon: var(--action-selection-05);
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-prominence="secondary"][data-interactive-state="filled"] {
@apply bg-background-neutral-01;
@apply bg-background-tint-01;
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-prominence="primary"][data-interactive-state="filled"] {
@apply bg-background-tint-00;
+2 -16
View File
@@ -1,34 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgExternalLink = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15 3h6v6"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 14L21 3"
d="M12 8.66667V12.6667C12 13.3929 11.3929 14 10.6667 14H3.33333C2.60711 14 2 13.3929 2 12.6667V5.33333C2 4.60711 2.60711 4 3.33333 4H7.33333M14 6V2H10M14 2L6.66667 9.33333"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgExternalLink;
-85
View File
@@ -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;
}
-1
View File
@@ -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);
+8
View File
@@ -10396,6 +10396,14 @@
"fileCount": {
"label": "{count, plural, one {# ملف} other {# ملفات}}"
},
"fileRow": {
"deleteButton": {
"ariaLabel": "حذف الملف"
},
"viewButton": {
"ariaLabel": "فتح الملف"
}
},
"fileStatus": {
"deleting": {
"label": "جارٍ الحذف..."
+8
View File
@@ -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..."
+8
View File
@@ -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..."
+8
View File
@@ -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..."
+8
View File
@@ -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..."
+8
View File
@@ -10396,6 +10396,14 @@
"fileCount": {
"label": "{count, plural, one {# 件のファイル} other {# 件のファイル}}"
},
"fileRow": {
"deleteButton": {
"ariaLabel": "ファイルを削除"
},
"viewButton": {
"ariaLabel": "ファイルを開く"
}
},
"fileStatus": {
"deleting": {
"label": "削除中..."
+8
View File
@@ -10396,6 +10396,14 @@
"fileCount": {
"label": "{count, plural, one {파일 #개} other {파일 #개}}"
},
"fileRow": {
"deleteButton": {
"ariaLabel": "파일 삭제"
},
"viewButton": {
"ariaLabel": "파일 열기"
}
},
"fileStatus": {
"deleting": {
"label": "삭제 중..."
+8
View File
@@ -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..."
+8
View File
@@ -10396,6 +10396,14 @@
"fileCount": {
"label": "{count, plural, one {# 个文件} other {# 个文件}}"
},
"fileRow": {
"deleteButton": {
"ariaLabel": "删除文件"
},
"viewButton": {
"ariaLabel": "打开文件"
}
},
"fileStatus": {
"deleting": {
"label": "正在删除..."
+1 -57
View File
@@ -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<IconProps>;
middleText?: string;
rightChildren?: React.ReactNode;
}
function AttachmentItemLayout({
title,
description,
icon: Icon,
middleText,
rightChildren,
}: AttachmentItemLayoutProps) {
return (
<Section flexDirection="row" justifyContent="start" gap={1} padding={1}>
<div className={cn("h-9 aspect-square rounded-08 shrink-0")}>
<Section>
<div
className="attachment-item__icon-wrapper"
data-testid="attachment-item-icon-wrapper"
>
<Icon className="attachment-item__icon" />
</div>
</Section>
</div>
<Section
flexDirection="row"
justifyContent="between"
alignItems="center"
gap={6}
className="min-w-0"
>
<div data-testid="attachment-item-title" className="flex-1 min-w-0">
<Content
title={title}
description={description}
sizePreset="main-ui"
variant="section"
width="full"
/>
</div>
{middleText && (
<div className="flex-1 min-w-0">
<Truncated text03 secondaryBody>
{middleText}
</Truncated>
</div>
)}
{rightChildren && <div className="shrink-0 px-1">{rightChildren}</div>}
</Section>
</Section>
);
}
export interface CardItemLayoutProps {
icon: React.FunctionComponent<IconProps>;
title: string;
@@ -118,4 +62,4 @@ function CardItemLayout({
);
}
export { CardItemLayout, AttachmentItemLayout };
export { CardItemLayout };
@@ -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<typeof AttachmentButton> = {
title: "refresh-components/buttons/AttachmentButton",
component: AttachmentButton,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<div style={{ width: 400 }}>
<Story />
</div>
</TooltipPrimitive.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof AttachmentButton>;
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: () => (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<AttachmentButton
icon={SvgTextLines}
description="proposal.pdf"
rightText="2.4 MB"
onView={() => {}}
>
Project Proposal
</AttachmentButton>
<AttachmentButton
icon={SvgFiles}
description="report.xlsx"
rightText="1.1 MB"
selected
>
Quarterly Report
</AttachmentButton>
<AttachmentButton
icon={SvgTextLines}
description="Uploading..."
rightText="72%"
processing
>
Meeting Notes
</AttachmentButton>
<AttachmentButton
icon={SvgFiles}
description="readme.md"
rightText="4 KB"
actionIcon={SvgTrash}
onAction={() => {}}
>
README
</AttachmentButton>
</div>
),
};
@@ -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
* <AttachmentButton
* icon={SvgFileText}
* description="document.pdf"
* rightText="2.4 MB"
* >
* Project Proposal
* </AttachmentButton>
*
* // Selected state with view button
* <AttachmentButton
* icon={SvgFileText}
* selected
* description="document.pdf"
* rightText="2.4 MB"
* onView={() => window.open('/view/doc')}
* >
* Project Proposal
* </AttachmentButton>
*
* // With action button (delete)
* <AttachmentButton
* icon={SvgFileText}
* description="document.pdf"
* rightText="2.4 MB"
* actionIcon={SvgTrash}
* onAction={() => handleDelete()}
* >
* Project Proposal
* </AttachmentButton>
*
* // Processing state
* <AttachmentButton
* icon={SvgFileText}
* processing
* description="Uploading..."
* rightText="45%"
* >
* Project Proposal
* </AttachmentButton>
* ```
*/
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<HTMLButtonElement>
> {
selected?: boolean;
processing?: boolean;
icon: React.FunctionComponent<IconProps>;
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<IconProps>;
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 (
<Hoverable.Root group="attachment-button">
<button
type="button"
className="attachment-item"
data-state={state}
{...props}
>
<div className="attachment-item__content">
<div className="attachment-item__icon-wrapper">
{selected ? (
<Checkbox checked />
) : (
<Icon className="attachment-item__icon" />
)}
</div>
<div className="attachment-item__text-container">
<div className="attachment-item__title-row">
<div className="attachment-item__title-wrapper">
<Truncated mainUiMuted text04 nowrap>
{children}
</Truncated>
</div>
{onView && (
<Hoverable.Item group="attachment-button">
<Button
size="sm"
icon={SvgExternalLink}
onClick={noProp(onView)}
prominence="tertiary"
/>
</Hoverable.Item>
)}
</div>
{description && (
<Truncated secondaryBody text03 className="w-full">
{description}
</Truncated>
)}
</div>
</div>
<div className="attachment-item__actions">
{rightText && (
<Text as="p" secondaryBody text03>
{rightText}
</Text>
)}
{actionIcon && onAction && (
<Hoverable.Item group="attachment-button">
<Button
icon={actionIcon}
onClick={noProp(onAction)}
prominence="tertiary"
size="sm"
/>
</Hoverable.Item>
)}
</div>
</button>
</Hoverable.Root>
);
}
+18 -46
View File
@@ -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 (
<Tooltip
tooltip={disabled && disabledTooltip ? disabledTooltip : undefined}
>
<div className="max-w-48">
<Interactive.Simple
onClick={
disabled || isSelected === undefined
? undefined
: () => onSelectToggle?.(!isSelected)
}
>
<Interactive.Container
data-testid={`document-set-card-${documentSet.id}`}
border
size="fit"
>
<AttachmentItemLayout
icon={SvgFiles}
title={documentSet.name}
description={documentSet.description}
rightChildren={
isSelected === undefined ? undefined : (
<div role="presentation" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
disabled={disabled}
onCheckedChange={
disabled
? undefined
: () => onSelectToggle?.(!isSelected)
}
/>
</div>
)
}
/>
<Spacer orientation="horizontal" rem={0.5} />
</Interactive.Container>
</Interactive.Simple>
</div>
</Tooltip>
<div className="max-w-48">
<Interactive.Container border size="fit" width="full">
<AttachmentItemButton
data-testid={`document-set-card-${documentSet.id}`}
icon={SvgFiles}
title={documentSet.name}
description={documentSet.description}
state={isSelected ? "selected" : undefined}
onClick={selectable ? () => onSelectToggle?.(!isSelected) : undefined}
disabled={disabled}
tooltip={disabled && disabledTooltip ? disabledTooltip : undefined}
/>
</Interactive.Container>
</div>
);
}
+3 -11
View File
@@ -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({
>
<div className="min-w-0 max-w-48">
<Interactive.Container border size="fit" width="full">
<AttachmentItemLayout
<AttachmentItemButton
presentational
icon={isProcessing ? SvgSimpleLoader : SvgFileText}
title={file.name}
description={
@@ -227,16 +227,8 @@ export function FileCard({
: typeLabel
}
/>
<Spacer orientation="horizontal" rem={0.5} />
</Interactive.Container>
</div>
</Removable>
);
}
// Skeleton loading component for file cards
export function FileCardSkeleton() {
return (
<div className="min-w-[120px] max-w-[240px] h-11 rounded-08 bg-background-tint-02 animate-pulse" />
);
}
+47 -15
View File
@@ -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 (
<AttachmentButton
onClick={onClick}
icon={Icon}
description={description}
rightText={rightText}
selected={isSelected}
processing={isProcessing}
onView={onView}
actionIcon={SvgTrash}
onAction={onDelete}
>
{file.name}
</AttachmentButton>
<Hoverable.Root group="user-file-row">
<AttachmentItemButton
prominence="primary"
onClick={onClick}
icon={Icon}
title={file.name}
description={description}
state={isSelected ? "selected" : undefined}
centerChildren={
rightText ? (
<Section alignItems="end">
<OpalText font="secondary-body" color="text-03" maxLines={1}>
{rightText}
</OpalText>
</Section>
) : undefined
}
rightChildren={
<Hoverable.Item group="user-file-row">
<Section flexDirection="row" gap={0} padding={1.5}>
{onView && (
<Button
icon={SvgExternalLink}
onClick={onView}
prominence="internal"
size="sm"
tooltip={t("fileRow.viewButton.ariaLabel")}
/>
)}
{onDelete && (
<Button
icon={SvgTrash}
onClick={onDelete}
prominence="internal"
size="sm"
tooltip={t("fileRow.deleteButton.ariaLabel")}
/>
)}
</Section>
</Hoverable.Item>
}
/>
</Hoverable.Root>
);
}
@@ -228,7 +260,7 @@ export default function UserFilesModal({
{filtered.length === 0 ? (
<Text text03>{t("emptyState.description")}</Text>
) : (
<ScrollIndicatorDiv className="p-2 gap-2 max-h-[70vh]">
<ScrollIndicatorDiv className="p-1 gap-1 max-h-[70vh]">
{filtered.map((projectFle) => {
const isSelected = selectedIds.has(projectFle.id);
return (
+37 -27
View File
@@ -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 (
<Interactive.Container
<AttachmentItemButton
key={pat.id}
size="fit"
width="full"
>
<div className="w-full bg-background-tint-01">
<AttachmentItemLayout
icon={SvgKey}
title={pat.name}
description={pat.token_display}
middleText={middleText}
rightChildren={
<Button
icon={SvgTrash}
onClick={() => 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={
<Section alignItems="end">
<Text
font="secondary-body"
color="text-03"
maxLines={1}
>
{middleText}
</Text>
</Section>
}
rightChildren={
<Button
icon={SvgTrash}
onClick={() => setTokenToDelete(pat)}
prominence="tertiary"
size="sm"
aria-label={t(
"apiKeys.list.deleteTokenAriaLabel",
{ name: pat.name }
)}
/>
</div>
</Interactive.Container>
}
/>
);
})}
</Section>
@@ -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]");
@@ -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();
}
}