mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
refactor(web): converge attachment rows on AttachmentItemButton (#14639)
This commit is contained in:
1 parent
4ad6cfbc27
commit
da15c93ffc
27 files changed
+229
-572
No files matched your search
+1
-1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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);
|
||||
|
||||
@@ -10396,6 +10396,14 @@
|
||||
"fileCount": {
|
||||
"label": "{count, plural, one {# ملف} other {# ملفات}}"
|
||||
},
|
||||
"fileRow": {
|
||||
"deleteButton": {
|
||||
"ariaLabel": "حذف الملف"
|
||||
},
|
||||
"viewButton": {
|
||||
"ariaLabel": "فتح الملف"
|
||||
}
|
||||
},
|
||||
"fileStatus": {
|
||||
"deleting": {
|
||||
"label": "جارٍ الحذف..."
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -10396,6 +10396,14 @@
|
||||
"fileCount": {
|
||||
"label": "{count, plural, one {# 件のファイル} other {# 件のファイル}}"
|
||||
},
|
||||
"fileRow": {
|
||||
"deleteButton": {
|
||||
"ariaLabel": "ファイルを削除"
|
||||
},
|
||||
"viewButton": {
|
||||
"ariaLabel": "ファイルを開く"
|
||||
}
|
||||
},
|
||||
"fileStatus": {
|
||||
"deleting": {
|
||||
"label": "削除中..."
|
||||
|
||||
@@ -10396,6 +10396,14 @@
|
||||
"fileCount": {
|
||||
"label": "{count, plural, one {파일 #개} other {파일 #개}}"
|
||||
},
|
||||
"fileRow": {
|
||||
"deleteButton": {
|
||||
"ariaLabel": "파일 삭제"
|
||||
},
|
||||
"viewButton": {
|
||||
"ariaLabel": "파일 열기"
|
||||
}
|
||||
},
|
||||
"fileStatus": {
|
||||
"deleting": {
|
||||
"label": "삭제 중..."
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -10396,6 +10396,14 @@
|
||||
"fileCount": {
|
||||
"label": "{count, plural, one {# 个文件} other {# 个文件}}"
|
||||
},
|
||||
"fileRow": {
|
||||
"deleteButton": {
|
||||
"ariaLabel": "删除文件"
|
||||
},
|
||||
"viewButton": {
|
||||
"ariaLabel": "打开文件"
|
||||
}
|
||||
},
|
||||
"fileStatus": {
|
||||
"deleting": {
|
||||
"label": "正在删除..."
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user