Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cd00893ad | |||
| 95d3af4930 | |||
| 24d6ecbedb |
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.1.2] - 2025-03-06
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
## [2.1.1] - 2025-03-06
|
## [2.1.1] - 2025-03-06
|
||||||
|
|
||||||
(No changes documented; release for version sync.)
|
(No changes documented; release for version sync.)
|
||||||
@@ -64,7 +68,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Input validation and initData hash verification for Miniapp access.
|
- Input validation and initData hash verification for Miniapp access.
|
||||||
- Optional CORS and init_data_max_age; use env for secrets.
|
- Optional CORS and init_data_max_age; use env for secrets.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.1.1...HEAD
|
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.1.2...HEAD
|
||||||
|
[2.1.2]: https://github.com/your-org/duty-teller/releases/tag/v2.1.2
|
||||||
[2.1.1]: https://github.com/your-org/duty-teller/releases/tag/v2.1.1 <!-- placeholder: set to your repo URL when publishing -->
|
[2.1.1]: https://github.com/your-org/duty-teller/releases/tag/v2.1.1 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[2.0.6]: https://github.com/your-org/duty-teller/releases/tag/v2.0.6 <!-- placeholder: set to your repo URL when publishing -->
|
[2.0.6]: https://github.com/your-org/duty-teller/releases/tag/v2.0.6 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
|
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "duty-teller"
|
name = "duty-teller"
|
||||||
version = "2.1.1"
|
version = "2.1.2"
|
||||||
description = "Telegram bot for team duty shift calendar and group reminder"
|
description = "Telegram bot for team duty shift calendar and group reminder"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -4,13 +4,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
import { ContactLinks } from "./ContactLinks";
|
import { ContactLinks } from "./ContactLinks";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
function renderWithTooltip(ui: React.ReactElement) {
|
||||||
|
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
const openPhoneLinkMock = vi.fn();
|
const openPhoneLinkMock = vi.fn();
|
||||||
const openTelegramProfileMock = vi.fn();
|
const openTelegramProfileMock = vi.fn();
|
||||||
const triggerHapticLightMock = vi.fn();
|
const triggerHapticLightMock = vi.fn();
|
||||||
|
const copyToClipboardMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/lib/open-phone-link", () => ({
|
vi.mock("@/lib/open-phone-link", () => ({
|
||||||
openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args),
|
openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args),
|
||||||
@@ -21,6 +27,9 @@ vi.mock("@/lib/telegram-link", () => ({
|
|||||||
vi.mock("@/lib/telegram-haptic", () => ({
|
vi.mock("@/lib/telegram-haptic", () => ({
|
||||||
triggerHapticLight: () => triggerHapticLightMock(),
|
triggerHapticLight: () => triggerHapticLightMock(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/lib/copy-to-clipboard", () => ({
|
||||||
|
copyToClipboard: (...args: unknown[]) => copyToClipboardMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("ContactLinks", () => {
|
describe("ContactLinks", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -28,6 +37,7 @@ describe("ContactLinks", () => {
|
|||||||
openPhoneLinkMock.mockClear();
|
openPhoneLinkMock.mockClear();
|
||||||
openTelegramProfileMock.mockClear();
|
openTelegramProfileMock.mockClear();
|
||||||
triggerHapticLightMock.mockClear();
|
triggerHapticLightMock.mockClear();
|
||||||
|
copyToClipboardMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when phone and username are missing", () => {
|
it("returns null when phone and username are missing", () => {
|
||||||
@@ -100,4 +110,118 @@ describe("ContactLinks", () => {
|
|||||||
expect(openTelegramProfileMock).toHaveBeenCalledWith("alice_dev");
|
expect(openTelegramProfileMock).toHaveBeenCalledWith("alice_dev");
|
||||||
expect(triggerHapticLightMock).toHaveBeenCalled();
|
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("showCopyButtons with layout block", () => {
|
||||||
|
it("renders copy phone button with aria-label when phone is present", () => {
|
||||||
|
renderWithTooltip(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79991234567"
|
||||||
|
username={null}
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders copy Telegram button with aria-label when username is present", () => {
|
||||||
|
renderWithTooltip(
|
||||||
|
<ContactLinks
|
||||||
|
phone={null}
|
||||||
|
username="alice_dev"
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||||
|
})
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls copyToClipboard with raw phone and triggerHapticLight when copy phone is clicked", async () => {
|
||||||
|
copyToClipboardMock.mockResolvedValue(true);
|
||||||
|
renderWithTooltip(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79991234567"
|
||||||
|
username={null}
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const copyBtn = screen.getByRole("button", {
|
||||||
|
name: /Copy phone number|Скопировать номер/i,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(copyBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(copyToClipboardMock).toHaveBeenCalledWith("+79991234567");
|
||||||
|
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls copyToClipboard with @username when copy Telegram is clicked", async () => {
|
||||||
|
copyToClipboardMock.mockResolvedValue(true);
|
||||||
|
renderWithTooltip(
|
||||||
|
<ContactLinks
|
||||||
|
phone={null}
|
||||||
|
username="alice_dev"
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const copyBtn = screen.getByRole("button", {
|
||||||
|
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(copyBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(copyToClipboardMock).toHaveBeenCalledWith("@alice_dev");
|
||||||
|
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Copied text in tooltip after successful copy", async () => {
|
||||||
|
copyToClipboardMock.mockResolvedValue(true);
|
||||||
|
renderWithTooltip(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79991234567"
|
||||||
|
username={null}
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const copyBtn = screen.getByRole("button", {
|
||||||
|
name: /Copy phone number|Скопировать номер/i,
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(copyBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltip = await screen.findByRole("tooltip", {
|
||||||
|
name: /Copied|Скопировано/i,
|
||||||
|
});
|
||||||
|
expect(tooltip).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show copy buttons when showCopyButtons is false", () => {
|
||||||
|
render(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79991234567"
|
||||||
|
username="bob"
|
||||||
|
layout="block"
|
||||||
|
showCopyButtons={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /Copy Telegram username|Скопировать логин Telegram/i })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,14 +5,23 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { formatPhoneDisplay } from "@/lib/phone-format";
|
import { formatPhoneDisplay } from "@/lib/phone-format";
|
||||||
import { openPhoneLink } from "@/lib/open-phone-link";
|
import { openPhoneLink } from "@/lib/open-phone-link";
|
||||||
import { openTelegramProfile } from "@/lib/telegram-link";
|
import { openTelegramProfile } from "@/lib/telegram-link";
|
||||||
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
|
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
|
import { Phone as PhoneIcon, Send as TelegramIcon, Copy } from "lucide-react";
|
||||||
|
|
||||||
|
const COPIED_RESET_MS = 1800;
|
||||||
|
|
||||||
export interface ContactLinksProps {
|
export interface ContactLinksProps {
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
@@ -21,6 +30,8 @@ export interface ContactLinksProps {
|
|||||||
showLabels?: boolean;
|
showLabels?: boolean;
|
||||||
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
||||||
contextLabel?: string;
|
contextLabel?: string;
|
||||||
|
/** When true and layout is "block", show copy buttons for phone and Telegram username. */
|
||||||
|
showCopyButtons?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,14 +47,38 @@ export function ContactLinks({
|
|||||||
layout = "inline",
|
layout = "inline",
|
||||||
showLabels = true,
|
showLabels = true,
|
||||||
contextLabel,
|
contextLabel,
|
||||||
|
showCopyButtons = false,
|
||||||
className,
|
className,
|
||||||
}: ContactLinksProps) {
|
}: ContactLinksProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [copiedKind, setCopiedKind] = useState<"phone" | "telegram" | null>(null);
|
||||||
|
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const hasPhone = Boolean(phone && String(phone).trim());
|
const hasPhone = Boolean(phone && String(phone).trim());
|
||||||
const rawUsername = username && String(username).trim();
|
const rawUsername = username && String(username).trim();
|
||||||
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
||||||
const hasUsername = Boolean(cleanUsername);
|
const hasUsername = Boolean(cleanUsername);
|
||||||
|
|
||||||
|
const showCopy = layout === "block" && showCopyButtons;
|
||||||
|
|
||||||
|
useEffect(() => () => clearCopiedTimeout(), []);
|
||||||
|
|
||||||
|
const clearCopiedTimeout = () => {
|
||||||
|
if (copiedTimeoutRef.current) {
|
||||||
|
clearTimeout(copiedTimeoutRef.current);
|
||||||
|
copiedTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCopiedFeedback = (kind: "phone" | "telegram") => {
|
||||||
|
clearCopiedTimeout();
|
||||||
|
setCopiedKind(kind);
|
||||||
|
copiedTimeoutRef.current = setTimeout(() => {
|
||||||
|
setCopiedKind(null);
|
||||||
|
copiedTimeoutRef.current = null;
|
||||||
|
}, COPIED_RESET_MS);
|
||||||
|
};
|
||||||
|
|
||||||
if (!hasPhone && !hasUsername) return null;
|
if (!hasPhone && !hasUsername) return null;
|
||||||
|
|
||||||
const handlePhoneClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
const handlePhoneClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
@@ -52,6 +87,22 @@ export function ContactLinks({
|
|||||||
triggerHapticLight();
|
triggerHapticLight();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyPhone = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerHapticLight();
|
||||||
|
const rawPhone = String(phone).trim();
|
||||||
|
const ok = await copyToClipboard(rawPhone);
|
||||||
|
if (ok) showCopiedFeedback("phone");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyTelegram = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerHapticLight();
|
||||||
|
const text = `@${cleanUsername}`;
|
||||||
|
const ok = await copyToClipboard(text);
|
||||||
|
if (ok) showCopiedFeedback("telegram");
|
||||||
|
};
|
||||||
|
|
||||||
const ariaCall = contextLabel
|
const ariaCall = contextLabel
|
||||||
? t("contact.aria_call", { name: contextLabel })
|
? t("contact.aria_call", { name: contextLabel })
|
||||||
: t("contact.phone");
|
: t("contact.phone");
|
||||||
@@ -60,32 +111,52 @@ export function ContactLinks({
|
|||||||
: t("contact.telegram");
|
: t("contact.telegram");
|
||||||
|
|
||||||
if (layout === "block") {
|
if (layout === "block") {
|
||||||
|
const rowClass =
|
||||||
|
"flex h-12 items-center gap-0 rounded-md border border-input bg-background shadow-xs text-accent hover:bg-accent/10 hover:text-accent dark:bg-input/30 dark:hover:bg-input/50";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-2", className)}>
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
{hasPhone && (
|
{hasPhone && (
|
||||||
<Button
|
<div className={rowClass}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
href={`tel:${String(phone).trim()}`}
|
href={`tel:${String(phone).trim()}`}
|
||||||
aria-label={ariaCall}
|
aria-label={ariaCall}
|
||||||
onClick={handlePhoneClick}
|
onClick={handlePhoneClick}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
|
||||||
>
|
>
|
||||||
<PhoneIcon className="size-5" aria-hidden />
|
<PhoneIcon className="size-5 shrink-0" aria-hidden />
|
||||||
<span>{formatPhoneDisplay(phone!)}</span>
|
<span className="truncate">{formatPhoneDisplay(phone!)}</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
{showCopy && (
|
||||||
|
<Tooltip
|
||||||
|
open={copiedKind === "phone"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setCopiedKind(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
|
||||||
|
aria-label={t("contact.copy_phone")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleCopyPhone(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="size-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" sideOffset={8}>
|
||||||
|
{t("contact.copied")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasUsername && (
|
{hasUsername && (
|
||||||
<Button
|
<div className={rowClass}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -96,11 +167,38 @@ export function ContactLinks({
|
|||||||
openTelegramProfile(cleanUsername);
|
openTelegramProfile(cleanUsername);
|
||||||
triggerHapticLight();
|
triggerHapticLight();
|
||||||
}}
|
}}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
|
||||||
>
|
>
|
||||||
<TelegramIcon className="size-5" aria-hidden />
|
<TelegramIcon className="size-5 shrink-0" aria-hidden />
|
||||||
<span>@{cleanUsername}</span>
|
<span className="truncate">@{cleanUsername}</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
{showCopy && (
|
||||||
|
<Tooltip
|
||||||
|
open={copiedKind === "telegram"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setCopiedKind(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
|
||||||
|
aria-label={t("contact.copy_telegram")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleCopyTelegram(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="size-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" sideOffset={8}>
|
||||||
|
{t("contact.copied")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { CurrentDutyView } from "./CurrentDutyView";
|
import { CurrentDutyView } from "./CurrentDutyView";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||||
@@ -148,4 +149,37 @@ describe("CurrentDutyView", () => {
|
|||||||
expect(onBack).toHaveBeenCalled();
|
expect(onBack).toHaveBeenCalled();
|
||||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows copy phone and copy Telegram buttons when duty has contacts", async () => {
|
||||||
|
const { fetchDuties } = await import("@/lib/api");
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
const end = new Date(now.getTime() + 60 * 60 * 1000);
|
||||||
|
const dutyWithContacts = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
start_at: start.toISOString(),
|
||||||
|
end_at: end.toISOString(),
|
||||||
|
event_type: "duty" as const,
|
||||||
|
full_name: "Test User",
|
||||||
|
phone: "+79991234567",
|
||||||
|
username: "testuser",
|
||||||
|
};
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([dutyWithContacts]);
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<CurrentDutyView onBack={vi.fn()} />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||||
|
})
|
||||||
|
).toBeInTheDocument();
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
layout="block"
|
layout="block"
|
||||||
showLabels={true}
|
showLabels={true}
|
||||||
contextLabel={duty.full_name ?? undefined}
|
contextLabel={duty.full_name ?? undefined}
|
||||||
|
showCopyButtons={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"contact.telegram": "Telegram",
|
"contact.telegram": "Telegram",
|
||||||
"contact.aria_call": "Call {name}",
|
"contact.aria_call": "Call {name}",
|
||||||
"contact.aria_telegram": "Message {name} on Telegram",
|
"contact.aria_telegram": "Message {name} on Telegram",
|
||||||
|
"contact.copy_phone": "Copy phone number",
|
||||||
|
"contact.copy_telegram": "Copy Telegram username",
|
||||||
|
"contact.copied": "Copied",
|
||||||
"current_duty.title": "Current Duty",
|
"current_duty.title": "Current Duty",
|
||||||
"current_duty.no_duty": "No one is on duty right now",
|
"current_duty.no_duty": "No one is on duty right now",
|
||||||
"current_duty.shift": "Shift",
|
"current_duty.shift": "Shift",
|
||||||
@@ -161,6 +164,9 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"contact.telegram": "Telegram",
|
"contact.telegram": "Telegram",
|
||||||
"contact.aria_call": "Позвонить {name}",
|
"contact.aria_call": "Позвонить {name}",
|
||||||
"contact.aria_telegram": "Написать {name} в Telegram",
|
"contact.aria_telegram": "Написать {name} в Telegram",
|
||||||
|
"contact.copy_phone": "Скопировать номер",
|
||||||
|
"contact.copy_telegram": "Скопировать логин Telegram",
|
||||||
|
"contact.copied": "Скопировано",
|
||||||
"current_duty.title": "Сейчас дежурит",
|
"current_duty.title": "Сейчас дежурит",
|
||||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||||
"current_duty.shift": "Смена",
|
"current_duty.shift": "Смена",
|
||||||
|
|||||||
64
webapp-next/src/lib/copy-to-clipboard.test.ts
Normal file
64
webapp-next/src/lib/copy-to-clipboard.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for copyToClipboard: Clipboard API and execCommand fallback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
describe("copyToClipboard", () => {
|
||||||
|
const writeTextMock = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
writeTextMock.mockReset();
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText: writeTextMock },
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when navigator.clipboard.writeText succeeds", async () => {
|
||||||
|
writeTextMock.mockResolvedValue(undefined);
|
||||||
|
const { copyToClipboard } = await import("./copy-to-clipboard");
|
||||||
|
|
||||||
|
const result = await copyToClipboard("+79991234567");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(writeTextMock).toHaveBeenCalledWith("+79991234567");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when text is empty", async () => {
|
||||||
|
const { copyToClipboard } = await import("./copy-to-clipboard");
|
||||||
|
|
||||||
|
expect(await copyToClipboard("")).toBe(false);
|
||||||
|
expect(await copyToClipboard(" ")).toBe(false);
|
||||||
|
expect(writeTextMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when clipboard.writeText rejects", async () => {
|
||||||
|
writeTextMock.mockRejectedValue(new Error("Permission denied"));
|
||||||
|
const { copyToClipboard } = await import("./copy-to-clipboard");
|
||||||
|
|
||||||
|
const result = await copyToClipboard("hello");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses execCommand fallback when clipboard is missing", async () => {
|
||||||
|
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
|
||||||
|
const execCommandMock = vi.fn().mockReturnValue(true);
|
||||||
|
(document as unknown as { execCommand?: ReturnType<typeof vi.fn> }).execCommand =
|
||||||
|
execCommandMock;
|
||||||
|
vi.resetModules();
|
||||||
|
const { copyToClipboard } = await import("./copy-to-clipboard");
|
||||||
|
|
||||||
|
const result = await copyToClipboard("fallback text");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(execCommandMock).toHaveBeenCalledWith("copy");
|
||||||
|
});
|
||||||
|
});
|
||||||
41
webapp-next/src/lib/copy-to-clipboard.ts
Normal file
41
webapp-next/src/lib/copy-to-clipboard.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Copy text to the clipboard. Uses Clipboard API with execCommand fallback.
|
||||||
|
* Used for copying phone/Telegram on the current duty view.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the given text to the clipboard.
|
||||||
|
*
|
||||||
|
* @param text - Raw text to copy (e.g. phone number or @username).
|
||||||
|
* @returns Promise resolving to true on success, false on failure. Errors are logged, not thrown.
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
if (typeof text !== "string" || text.trim() === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("clipboard.writeText failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute("readonly", "");
|
||||||
|
textarea.style.position = "absolute";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
return ok;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("execCommand copy fallback failed", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user