-
Notifications
You must be signed in to change notification settings - Fork 43
feat(web): productivity tools - copy citation button and print styles #206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 16 commits
8cd8fef
6a95e3f
e09d339
f14eac6
d926835
aa7585b
458c4d0
17c8a25
b10aa22
05ff318
bc61c8d
f4b6351
b057aaf
9ba5636
b053051
828fab8
dd5a559
652476b
a560c88
eaff395
2ea523c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| // @vitest-environment jsdom | ||
| import { describe, it, expect, vi, afterEach } from 'vitest'; | ||
| import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; | ||
| import { CopyCitationButton } from './CopyCitationButton'; | ||
|
|
||
| const originalClipboard = navigator.clipboard; | ||
| const originalExecCommand = document.execCommand; | ||
|
|
||
| afterEach(() => { | ||
| cleanup(); | ||
| vi.restoreAllMocks(); | ||
| vi.useRealTimers(); | ||
| Object.assign(navigator, { clipboard: originalClipboard }); | ||
| Object.assign(document, { execCommand: originalExecCommand }); | ||
| }); | ||
|
|
||
| describe('CopyCitationButton', () => { | ||
| it('shows the copied state after a successful Clipboard API write', async () => { | ||
| const writeText = vi.fn().mockResolvedValue(undefined); | ||
| Object.assign(navigator, { clipboard: { writeText } }); | ||
|
|
||
| render(<CopyCitationButton textToCopy="hello" />); | ||
| fireEvent.click(screen.getByRole('button')); | ||
|
|
||
| await waitFor(() => expect(writeText).toHaveBeenCalledWith('hello')); | ||
| await screen.findByText('Копирано!'); | ||
| expect(screen.getByRole('button').className).toContain('is-copied'); | ||
| }); | ||
|
|
||
| it('falls back to execCommand when navigator.clipboard is unavailable', async () => { | ||
| Object.assign(navigator, { clipboard: undefined }); | ||
| const execCommand = vi.fn().mockReturnValue(true); | ||
| Object.assign(document, { execCommand }); | ||
|
|
||
| render(<CopyCitationButton textToCopy="hello" />); | ||
| fireEvent.click(screen.getByRole('button')); | ||
|
|
||
| await screen.findByText('Копирано!'); | ||
| expect(execCommand).toHaveBeenCalledWith('copy'); | ||
| expect(screen.getByRole('button').className).toContain('is-copied'); | ||
| }); | ||
|
|
||
| it('shows the failed state when both the Clipboard API and execCommand fail', async () => { | ||
| const writeText = vi.fn().mockRejectedValue(new Error('denied')); | ||
| Object.assign(navigator, { clipboard: { writeText } }); | ||
| const execCommand = vi.fn().mockReturnValue(false); | ||
| Object.assign(document, { execCommand }); | ||
| vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
|
|
||
| render(<CopyCitationButton textToCopy="hello" />); | ||
| fireEvent.click(screen.getByRole('button')); | ||
|
|
||
| await screen.findByText('Неуспешно копиране'); | ||
| expect(screen.getByRole('button').className).not.toContain('is-copied'); | ||
| expect(screen.getByRole('button').getAttribute('aria-label')).toBe('Копирането не бе успешно'); | ||
| }); | ||
|
|
||
| it('reports failed when execCommand throws while navigator.clipboard is unavailable', async () => { | ||
| Object.assign(navigator, { clipboard: undefined }); | ||
| Object.assign(document, { | ||
| execCommand: vi.fn(() => { | ||
| throw new Error('unsupported'); | ||
| }), | ||
| }); | ||
|
|
||
| render(<CopyCitationButton textToCopy="hello" />); | ||
| fireEvent.click(screen.getByRole('button')); | ||
|
|
||
| await screen.findByText('Неуспешно копиране'); | ||
| }); | ||
|
|
||
| it('clears the pending reset timeout on unmount', async () => { | ||
| const writeText = vi.fn().mockResolvedValue(undefined); | ||
| Object.assign(navigator, { clipboard: { writeText } }); | ||
| const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout'); | ||
|
|
||
| const { unmount } = render(<CopyCitationButton textToCopy="hello" />); | ||
| fireEvent.click(screen.getByRole('button')); | ||
| await screen.findByText('Копирано!'); | ||
|
|
||
| clearTimeoutSpy.mockClear(); | ||
| unmount(); | ||
|
|
||
| expect(clearTimeoutSpy).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('does not update state after unmount when the clipboard write resolves late', async () => { | ||
| let resolveWrite: () => void = () => {}; | ||
| const writeText = vi.fn( | ||
| () => | ||
| new Promise<void>((resolve) => { | ||
| resolveWrite = resolve; | ||
| }), | ||
| ); | ||
| Object.assign(navigator, { clipboard: { writeText } }); | ||
| const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
|
|
||
| const { unmount } = render(<CopyCitationButton textToCopy="hello" />); | ||
| fireEvent.click(screen.getByRole('button')); | ||
| await waitFor(() => expect(writeText).toHaveBeenCalled()); | ||
|
|
||
| unmount(); | ||
| resolveWrite(); | ||
| await Promise.resolve(); | ||
|
|
||
| expect(errorSpy).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import { useState, useCallback, useEffect, useRef } from 'react'; | ||
|
|
||
| function copyWithExecCommand(text: string): boolean { | ||
| const textarea = document.createElement('textarea'); | ||
| textarea.value = text; | ||
| textarea.style.position = 'fixed'; | ||
| textarea.style.opacity = '0'; | ||
| textarea.setAttribute('aria-hidden', 'true'); | ||
| textarea.tabIndex = -1; | ||
| document.body.appendChild(textarea); | ||
| textarea.focus(); | ||
| textarea.select(); | ||
| let ok = false; | ||
| try { | ||
| ok = document.execCommand('copy'); | ||
| } catch { | ||
| ok = false; | ||
| } | ||
| document.body.removeChild(textarea); | ||
| return ok; | ||
| } | ||
|
|
||
| export function CopyCitationButton({ textToCopy }: { textToCopy: string }) { | ||
|
StanislavBG marked this conversation as resolved.
|
||
| const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle'); | ||
| const timeoutRef = useRef<number | null>(null); | ||
| const mountedRef = useRef(true); | ||
|
|
||
| useEffect(() => { | ||
| mountedRef.current = true; | ||
| return () => { | ||
| mountedRef.current = false; | ||
| if (timeoutRef.current !== null) { | ||
| window.clearTimeout(timeoutRef.current); | ||
| } | ||
| }; | ||
| }, []); | ||
|
|
||
| const resetAfterDelay = useCallback(() => { | ||
| if (timeoutRef.current !== null) { | ||
| window.clearTimeout(timeoutRef.current); | ||
| } | ||
| timeoutRef.current = window.setTimeout(() => setStatus('idle'), 2000); | ||
| }, []); | ||
|
|
||
| const handleCopy = useCallback(() => { | ||
| if (typeof navigator !== 'undefined' && navigator.clipboard) { | ||
|
StanislavBG marked this conversation as resolved.
|
||
| navigator.clipboard | ||
| .writeText(textToCopy) | ||
| .then(() => { | ||
| if (!mountedRef.current) return; | ||
| setStatus('copied'); | ||
|
StanislavBG marked this conversation as resolved.
|
||
| resetAfterDelay(); | ||
| }) | ||
| .catch((err) => { | ||
| console.error('Failed to copy text:', err); | ||
| if (!mountedRef.current) return; | ||
| // execCommand('copy') here runs after an awaited promise rejection, so in some | ||
| // browsers the original click's user-gesture context may already be gone by this | ||
| // point, which can make execCommand return false even though a synchronous-first | ||
| // attempt would have succeeded. We still degrade correctly to the 'failed' UI state | ||
| // in that case, and writeText (tried first, above) is the modern/preferred path that | ||
| // succeeds in the overwhelming majority of real browsers — so this ordering is kept | ||
| // rather than reversing the priority to chase a rarer edge case. | ||
| setStatus(copyWithExecCommand(textToCopy) ? 'copied' : 'failed'); | ||
|
StanislavBG marked this conversation as resolved.
|
||
| resetAfterDelay(); | ||
| }); | ||
| return; | ||
| } | ||
| setStatus(copyWithExecCommand(textToCopy) ? 'copied' : 'failed'); | ||
| resetAfterDelay(); | ||
| }, [textToCopy, resetAfterDelay]); | ||
|
|
||
| const copied = status === 'copied'; | ||
| const failed = status === 'failed'; | ||
|
|
||
| return ( | ||
| <button | ||
| type="button" | ||
| onClick={handleCopy} | ||
| className={`copy-btn ${copied ? 'is-copied' : ''}`} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a11y забележка: бутонът има динамичен |
||
| aria-label={ | ||
| failed ? 'Копирането не бе успешно' : copied ? 'Копирано!' : 'Копирай данните като цитат' | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Дребно UX несъответствие: |
||
| title={failed ? 'Копирането не бе успешно — опитайте отново' : 'Копирай основните факти'} | ||
| > | ||
| {copied ? ( | ||
| <svg | ||
| width="16" | ||
| height="16" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| strokeWidth="2" | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| aria-hidden="true" | ||
| > | ||
| <polyline points="20 6 9 17 4 12"></polyline> | ||
| </svg> | ||
| ) : ( | ||
| <svg | ||
| width="16" | ||
| height="16" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| strokeWidth="2" | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| aria-hidden="true" | ||
| > | ||
| <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> | ||
| <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> | ||
| </svg> | ||
| )} | ||
| <span className="copy-btn-text" aria-live="polite"> | ||
| {copied ? 'Копирано!' : failed ? 'Неуспешно копиране' : 'Копирай'} | ||
| </span> | ||
| </button> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { buildContractCitation, buildCompanyCitation, buildAuthorityCitation } from './citation'; | ||
|
|
||
| describe('citation builders', () => { | ||
| it('builds a contract citation', () => { | ||
| const c = { | ||
| subject: 'Доставка на компютри', | ||
| authority: { name: 'Община Пловдив' }, | ||
| bidder: { displayName: 'Техно ООД' }, | ||
| value: { currentEur: 125000.5 }, | ||
| id: 'abc-123', | ||
| }; | ||
|
|
||
| const citation = buildContractCitation(c, 'https://sigma.test'); | ||
| expect(citation).toBe( | ||
| [ | ||
| 'Договор: Доставка на компютри', | ||
| 'Възложител: Община Пловдив', | ||
| 'Изпълнител: Техно ООД', | ||
| 'Стойност: 125 хил. €', | ||
| 'Връзка: https://sigma.test/contracts/abc-123', | ||
| ].join('\n'), | ||
| ); | ||
| }); | ||
|
|
||
| it('handles contract with null value', () => { | ||
| const c = { | ||
| subject: 'Одит', | ||
| authority: { name: 'Община Пловдив' }, | ||
| bidder: { displayName: 'Техно ООД' }, | ||
| value: { currentEur: null }, | ||
| id: 'abc-123', | ||
| }; | ||
|
|
||
| const citation = buildContractCitation(c, 'https://sigma.test'); | ||
| expect(citation).toBe( | ||
| [ | ||
| 'Договор: Одит', | ||
| 'Възложител: Община Пловдив', | ||
| 'Изпълнител: Техно ООД', | ||
| 'Стойност: —', | ||
| 'Връзка: https://sigma.test/contracts/abc-123', | ||
| ].join('\n'), | ||
| ); | ||
| }); | ||
|
|
||
| it('builds a company citation with EIK', () => { | ||
| const c = { | ||
| displayName: 'Техно ООД', | ||
| eik: '123456789', | ||
| wonEur: 5000000, | ||
| contracts: 42, | ||
| slug: 'techno-ood', | ||
| }; | ||
|
|
||
| const citation = buildCompanyCitation(c, 'https://sigma.test'); | ||
| expect(citation).toBe( | ||
| [ | ||
| 'Компания: Техно ООД', | ||
| 'ЕИК: 123456789', | ||
| 'Общо спечелено: 5 млн. €', | ||
| 'Брой договори: 42', | ||
| 'Връзка: https://sigma.test/companies/techno-ood', | ||
| ].join('\n'), | ||
| ); | ||
| }); | ||
|
|
||
| it('handles contract with no awarded bidder', () => { | ||
| const c = { | ||
| subject: 'Прекратена процедура', | ||
| authority: { name: 'Община Пловдив' }, | ||
| bidder: null, | ||
| value: { currentEur: null }, | ||
| id: 'abc-123', | ||
| }; | ||
|
|
||
| const citation = buildContractCitation(c, 'https://sigma.test'); | ||
| expect(citation).toBe( | ||
| [ | ||
| 'Договор: Прекратена процедура', | ||
| 'Възложител: Община Пловдив', | ||
| 'Изпълнител: —', | ||
| 'Стойност: —', | ||
| 'Връзка: https://sigma.test/contracts/abc-123', | ||
| ].join('\n'), | ||
| ); | ||
| }); | ||
|
|
||
| it('builds a company citation without EIK', () => { | ||
| const c = { | ||
| displayName: 'Чуждестранна фирма', | ||
| eik: null, | ||
| wonEur: 0, | ||
| contracts: 1, | ||
| slug: 'foreign-corp', | ||
| }; | ||
|
|
||
| const citation = buildCompanyCitation(c, 'https://sigma.test'); | ||
| expect(citation).toBe( | ||
| [ | ||
| 'Компания: Чуждестранна фирма', | ||
| 'ЕИК: Няма', | ||
| 'Общо спечелено: 0 €', | ||
| 'Брой договори: 1', | ||
| 'Връзка: https://sigma.test/companies/foreign-corp', | ||
| ].join('\n'), | ||
| ); | ||
| }); | ||
|
|
||
|
StanislavBG marked this conversation as resolved.
|
||
| it('shows the EIK when hasEik is absent from loaderData but eik is present', () => { | ||
| const c = { | ||
| displayName: 'Техно ООД', | ||
| eik: '123456789', | ||
| wonEur: 5000000, | ||
| contracts: 42, | ||
| slug: 'techno-ood', | ||
| }; | ||
|
|
||
| const citation = buildCompanyCitation(c, 'https://sigma.test'); | ||
| expect(citation).toBe( | ||
| [ | ||
| 'Компания: Техно ООД', | ||
| 'ЕИК: 123456789', | ||
| 'Общо спечелено: 5 млн. €', | ||
| 'Брой договори: 42', | ||
| 'Връзка: https://sigma.test/companies/techno-ood', | ||
| ].join('\n'), | ||
| ); | ||
| }); | ||
|
|
||
| it('builds an authority citation', () => { | ||
| const a = { | ||
| name: 'Община Варна', | ||
| spentEur: 1000000, | ||
| contracts: 5, | ||
| slug: 'obshtina-varna', | ||
| }; | ||
|
|
||
| const citation = buildAuthorityCitation(a, 'https://sigma.test'); | ||
| expect(citation).toBe( | ||
| [ | ||
| 'Институция: Община Варна', | ||
| 'Общо похарчено: 1 млн. €', | ||
| 'Брой договори: 5', | ||
| 'Връзка: https://sigma.test/authorities/obshtina-varna', | ||
| ].join('\n'), | ||
| ); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.