mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(web): restore message center CTA (#6894)
* fix(web): restore message center CTA * fix(web): unify expanded message card surface
This commit is contained in:
@@ -244,6 +244,11 @@
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.itemExpanded {
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
}
|
||||
|
||||
.itemSummary {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -267,6 +272,10 @@
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
}
|
||||
|
||||
.itemExpanded .itemSummary:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
@@ -329,6 +338,33 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.itemActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0 13px 12px;
|
||||
}
|
||||
|
||||
.itemActions .primaryAction {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 72%, var(--border));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemActions .primaryAction:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 86%, var(--text-strong));
|
||||
color: var(--accent-contrast, #fff);
|
||||
}
|
||||
|
||||
.empty {
|
||||
min-height: 260px;
|
||||
display: flex;
|
||||
|
||||
@@ -294,7 +294,19 @@ function MessageItem({
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const formatted = formatPublishedDate(message.publishedAt, locale);
|
||||
const ctaUrl = safeExternalUrl(message.ctaUrl);
|
||||
return <article className={`${styles.item}${message.readAt ? '' : ` ${styles.itemUnread}`}${expanded ? ` ${styles.itemExpanded}` : ''}`}>
|
||||
<button type="button" className={styles.itemSummary} aria-expanded={expanded} onClick={() => { setExpanded((value) => !value); void onRead(message.id).catch(onError); }}><span className={styles.itemMeta}><span>{message.typeName}</span>{formatted ? <time dateTime={message.publishedAt}>{formatted}</time> : null}</span><strong>{message.title}</strong><span className={styles.bodyPreview}>{message.body}</span></button>
|
||||
{expanded && message.ctaLabel && ctaUrl ? <div className={styles.itemActions}><button type="button" className={styles.primaryAction} onClick={() => window.open(ctaUrl, '_blank', 'noopener,noreferrer')}>{message.ctaLabel}</button></div> : null}
|
||||
</article>;
|
||||
}
|
||||
|
||||
function safeExternalUrl(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = new URL(value, window.location.href);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,13 +130,13 @@ describe('MessageCenter', () => {
|
||||
await waitFor(() => expect(screen.getByText('All caught up')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('expands the whole message row without rendering view actions', async () => {
|
||||
it('expands the whole message row and opens its CTA', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
renderMessageCenter();
|
||||
await openCenter();
|
||||
const row = screen.getByRole('button', { name: /Open Design 0\.14 is available/ });
|
||||
|
||||
expect(row).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText('View')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'View update' })).toBeNull();
|
||||
|
||||
fireEvent.click(row);
|
||||
@@ -144,7 +144,8 @@ describe('MessageCenter', () => {
|
||||
expect(row).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(row.closest('article')?.className).toContain('itemExpanded');
|
||||
expect(screen.getByText('The new release is ready.')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'View update' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View update' }));
|
||||
expect(open).toHaveBeenCalledWith('https://open-design.ai/update', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('keeps both anonymous reads when two expands resolve out of order', async () => {
|
||||
@@ -475,6 +476,16 @@ describe('MessageCenter', () => {
|
||||
expect(readAttempts).toBe(1);
|
||||
});
|
||||
|
||||
it('hides CTA actions for non-http URLs', async () => {
|
||||
mockFetch({
|
||||
messages: [{ ...defaultMessages[0]!, ctaUrl: 'javascript:alert(1)' }],
|
||||
});
|
||||
renderMessageCenter();
|
||||
await openCenter();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Open Design 0\.14 is available/ }));
|
||||
expect(screen.queryByRole('button', { name: 'View update' })).toBeNull();
|
||||
});
|
||||
|
||||
it('closes with Escape and restores trigger focus', async () => {
|
||||
renderMessageCenter();
|
||||
const trigger = screen.getByTestId('message-center-trigger');
|
||||
|
||||
Reference in New Issue
Block a user