mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
fix: one status tile list for both admin cards; clear the copied timer on unmount
- The mail import's status tiles (which, labels, tones) were written out in the desktop and the mobile card alike; statTiles in the shared model holds them, each card keeps its own look. - The journey share link set 'copied' back two seconds after a copy even when the dialog had closed, which also broke a client test run (window gone at teardown). useCopied, the trip share's hook, moves to hooks/ and serves both; it clears its timer on unmount. - PlanLlmConfig is not exported: only the union is used outside.
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
Inbox,
|
||||
Loader2,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
Upload,
|
||||
Webhook,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -33,11 +33,13 @@ import { useToast } from '../../shared/Toast';
|
||||
import {
|
||||
TRANSPORTS,
|
||||
problemText,
|
||||
statTiles,
|
||||
statusFacts,
|
||||
transportInfo,
|
||||
transportLabel,
|
||||
type MailAdminForm,
|
||||
type MailAdminProblem,
|
||||
type StatTone,
|
||||
} from './mailIngestAdminModel';
|
||||
import { useMailIngestAdmin, type MailIngestAdmin } from './useMailIngestAdmin';
|
||||
|
||||
@@ -169,8 +171,6 @@ function WebhookFields({ admin, form, t, error }: FieldsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type StatTone = 'neutral' | 'success' | 'warning' | 'danger';
|
||||
|
||||
const STAT_TONE: Record<StatTone, { icon: string; value: string }> = {
|
||||
neutral: { icon: 'bg-surface-tertiary text-content-muted', value: 'text-content' },
|
||||
success: { icon: 'bg-success-soft text-success', value: 'text-content' },
|
||||
@@ -185,7 +185,7 @@ function StatTile({
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
icon: typeof Mail;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
tone: StatTone;
|
||||
@@ -229,15 +229,9 @@ function StatusBlock({ admin, t, locale }: { admin: MailIngestAdmin; t: T; local
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5 max-md:grid-cols-2">
|
||||
<StatTile icon={Mail} label={t('mailAdmin.status.mails')} value={facts.mails} tone="neutral" />
|
||||
<StatTile icon={CheckCircle} label={t('mailAdmin.status.added')} value={facts.added} tone="success" />
|
||||
<StatTile icon={Clock} label={t('mailAdmin.status.waiting')} value={facts.waiting} tone="warning" />
|
||||
<StatTile
|
||||
icon={XCircle}
|
||||
label={t('mailAdmin.status.failed')}
|
||||
value={facts.failed}
|
||||
tone={facts.failed > 0 ? 'danger' : 'neutral'}
|
||||
/>
|
||||
{statTiles(facts, t).map(({ key, ...tile }) => (
|
||||
<StatTile key={key} {...tile} />
|
||||
))}
|
||||
</div>
|
||||
{status.last_error && (
|
||||
<p className="m-0 flex items-start gap-1.5 rounded-lg bg-warning-soft px-3 py-2 text-caption leading-snug text-warning">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-MAILINGEST-001 to FE-ADMIN-MAILINGEST-007
|
||||
// FE-ADMIN-MAILINGEST-001 to FE-ADMIN-MAILINGEST-008
|
||||
import type { MailIngestAdminStatusResponse } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
formOf,
|
||||
problemText,
|
||||
problemsOf,
|
||||
statTiles,
|
||||
statusFacts,
|
||||
transportInfo,
|
||||
transportLabel,
|
||||
@@ -105,4 +106,16 @@ describe('mailIngestAdminModel', () => {
|
||||
'mailAdmin.transport.webhookInfo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MAILINGEST-008: the status tiles in order, the failed one red only when something failed', () => {
|
||||
const t = ((key: string) => key) as never;
|
||||
const tiles = statTiles({ mails: 4, added: 2, waiting: 1, failed: 0 }, t);
|
||||
expect(tiles.map(({ key, label, value, tone }) => [key, label, value, tone])).toEqual([
|
||||
['mails', 'mailAdmin.status.mails', 4, 'neutral'],
|
||||
['added', 'mailAdmin.status.added', 2, 'success'],
|
||||
['waiting', 'mailAdmin.status.waiting', 1, 'warning'],
|
||||
['failed', 'mailAdmin.status.failed', 0, 'neutral'],
|
||||
]);
|
||||
expect(statTiles({ mails: 1, added: 0, waiting: 0, failed: 1 }, t)[3].tone).toBe('danger');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MailIngestAdminStatusResponse, MailIngestImapTls } from '@trek/shared';
|
||||
import { CheckCircle, Clock, Mail, XCircle, type LucideIcon } from 'lucide-react';
|
||||
import type { TranslationFn } from '../../../types';
|
||||
|
||||
/**
|
||||
@@ -129,6 +130,34 @@ export function statusFacts(status: MailIngestAdminStatusResponse): {
|
||||
};
|
||||
}
|
||||
|
||||
/** How a status tile is coloured; each card maps it onto its own tokens. */
|
||||
export type StatTone = 'neutral' | 'success' | 'warning' | 'danger';
|
||||
|
||||
/** One number of the status card. */
|
||||
export interface StatTile {
|
||||
key: 'mails' | 'added' | 'waiting' | 'failed';
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
tone: StatTone;
|
||||
}
|
||||
|
||||
/** The day's numbers as the status tiles show them, in order, on desktop and mobile alike. */
|
||||
export function statTiles(facts: ReturnType<typeof statusFacts>, t: TranslationFn): StatTile[] {
|
||||
return [
|
||||
{ key: 'mails', icon: Mail, label: t('mailAdmin.status.mails'), value: facts.mails, tone: 'neutral' },
|
||||
{ key: 'added', icon: CheckCircle, label: t('mailAdmin.status.added'), value: facts.added, tone: 'success' },
|
||||
{ key: 'waiting', icon: Clock, label: t('mailAdmin.status.waiting'), value: facts.waiting, tone: 'warning' },
|
||||
{
|
||||
key: 'failed',
|
||||
icon: XCircle,
|
||||
label: t('mailAdmin.status.failed'),
|
||||
value: facts.failed,
|
||||
tone: facts.failed > 0 ? 'danger' : 'neutral',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** A problem in the admin's words; a switch of literal keys so the i18n check sees each one. */
|
||||
export function problemText(problem: MailAdminProblem, t: TranslationFn): string {
|
||||
switch (problem) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { journeyApi } from '../../api/client';
|
||||
import { useCopied } from '../../hooks/useCopied';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { copyText } from '../../utils/clipboard';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
export interface JourneyShareLink {
|
||||
@@ -41,7 +41,7 @@ export function useJourneyShareLink(journeyId: number) {
|
||||
const toast = useToast();
|
||||
const [link, setLink] = useState<JourneyShareLink | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { copied, copy } = useCopied();
|
||||
/** The share token is the owner's to manage; a contributor is refused. */
|
||||
const [manageable, setManageable] = useState(true);
|
||||
|
||||
@@ -103,11 +103,7 @@ export function useJourneyShareLink(journeyId: number) {
|
||||
|
||||
const shareUrl = journeyShareUrl(link, window.location.origin);
|
||||
|
||||
const copyLink = async () => {
|
||||
if (!(await copyText(shareUrl))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
const copyLink = () => copy(shareUrl);
|
||||
|
||||
return { link, loading, manageable, copied, shareUrl, createLink, togglePerm, deleteLink, copyLink };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { copyText } from '../../utils/clipboard'
|
||||
import { useCopied } from '../../hooks/useCopied'
|
||||
import { refusedOffline } from '../../utils/needsConnection'
|
||||
|
||||
// The state behind the share dialog, shared by the desktop dialog and the one the
|
||||
@@ -37,20 +37,6 @@ export const SHARE_OPTIONS: { key: SharePerm; label: string }[] = [
|
||||
{ key: 'share_hide_images', label: 'share.optHideImages' },
|
||||
]
|
||||
|
||||
/** Copy text and show "Copied" for two seconds, the timer restarting on every copy. */
|
||||
function useCopied() {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
|
||||
const copy = async (text: string | null) => {
|
||||
if (!text || !(await copyText(text))) return
|
||||
setCopied(true)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
return { copied, copy }
|
||||
}
|
||||
|
||||
/** The public read-only link and what it may show. */
|
||||
export function useShareLink(tripId: number) {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// FE-HOOK-COPIED-001 to FE-HOOK-COPIED-003
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../utils/clipboard', () => ({ copyText: vi.fn(async () => true) }));
|
||||
|
||||
import { copyText } from '../utils/clipboard';
|
||||
import { useCopied } from './useCopied';
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('useCopied', () => {
|
||||
it('FE-HOOK-COPIED-001: shows copied for two seconds, a second copy starting the time over', async () => {
|
||||
const { result } = renderHook(() => useCopied());
|
||||
await act(() => result.current.copy('https://x'));
|
||||
expect(result.current.copied).toBe(true);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
await act(() => result.current.copy('https://x'));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(result.current.copied).toBe(true);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
expect(result.current.copied).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-COPIED-002: nothing to copy or a refused clipboard shows nothing', async () => {
|
||||
const { result } = renderHook(() => useCopied());
|
||||
await act(() => result.current.copy(null));
|
||||
expect(copyText).not.toHaveBeenCalledWith(null);
|
||||
vi.mocked(copyText).mockResolvedValueOnce(false);
|
||||
await act(() => result.current.copy('https://x'));
|
||||
expect(result.current.copied).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-COPIED-003: unmounting clears the timer, so no state is set afterwards', async () => {
|
||||
const { result, unmount } = renderHook(() => useCopied());
|
||||
await act(() => result.current.copy('https://x'));
|
||||
unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { copyText } from '../utils/clipboard';
|
||||
|
||||
/**
|
||||
* Copy text and show "Copied" for two seconds, the timer restarting on every
|
||||
* copy. The timer is cleared on unmount, so a dialog closed right after a copy
|
||||
* sets no state on a component that is gone.
|
||||
*/
|
||||
export function useCopied(ms = 2000) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const copy = async (text: string | null) => {
|
||||
if (!text || !(await copyText(text))) return;
|
||||
setCopied(true);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), ms);
|
||||
};
|
||||
return { copied, copy };
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { CheckCircle, Clock, Inbox, Mail, Upload, Webhook, XCircle } from 'lucide-react';
|
||||
import { Inbox, Upload, Webhook, type LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
TRANSPORTS,
|
||||
problemText,
|
||||
statTiles,
|
||||
statusFacts,
|
||||
transportInfo,
|
||||
transportLabel,
|
||||
type MailAdminForm,
|
||||
type MailAdminProblem,
|
||||
type StatTone,
|
||||
} from '../../../components/Admin/mailIngest/mailIngestAdminModel';
|
||||
import { useMailIngestAdmin, type MailIngestAdmin } from '../../../components/Admin/mailIngest/useMailIngestAdmin';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -111,8 +113,6 @@ function WebhookFields({ admin, form, t }: PartProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type StatTone = 'neutral' | 'success' | 'warning' | 'danger';
|
||||
|
||||
const STAT_COLOR: Record<StatTone, string> = {
|
||||
neutral: 'var(--m-muted)',
|
||||
success: 'var(--m-st-confirmed)',
|
||||
@@ -127,7 +127,7 @@ function StatTile({
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
icon: typeof Mail;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: number;
|
||||
tone: StatTone;
|
||||
@@ -173,15 +173,9 @@ function Status({ admin, t, locale }: { admin: MailIngestAdmin; t: TranslationFn
|
||||
{t('mailAdmin.status.lastRead', { time: lastRead })}
|
||||
</p>
|
||||
<div className="mt-[7px] grid grid-cols-2 gap-[5px]">
|
||||
<StatTile icon={Mail} label={t('mailAdmin.status.mails')} value={facts.mails} tone="neutral" />
|
||||
<StatTile icon={CheckCircle} label={t('mailAdmin.status.added')} value={facts.added} tone="success" />
|
||||
<StatTile icon={Clock} label={t('mailAdmin.status.waiting')} value={facts.waiting} tone="warning" />
|
||||
<StatTile
|
||||
icon={XCircle}
|
||||
label={t('mailAdmin.status.failed')}
|
||||
value={facts.failed}
|
||||
tone={facts.failed > 0 ? 'danger' : 'neutral'}
|
||||
/>
|
||||
{statTiles(facts, t).map(({ key, ...tile }) => (
|
||||
<StatTile key={key} {...tile} />
|
||||
))}
|
||||
</div>
|
||||
{status.last_error && (
|
||||
<p className="mt-[9px] rounded-[10px] bg-[color-mix(in_srgb,var(--m-st-pending)_14%,transparent)] px-[10px] py-[7px] text-caption leading-snug text-[color:var(--m-st-pending)]">
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface ApiLlmConfig {
|
||||
* so it has no address or key here and is read through `read`. Never stored in
|
||||
* the addon config, only resolved from the shared connection.
|
||||
*/
|
||||
export interface PlanLlmConfig {
|
||||
interface PlanLlmConfig {
|
||||
provider: 'chatgpt';
|
||||
model: string;
|
||||
read: StructuredReader;
|
||||
|
||||
Reference in New Issue
Block a user