diff --git a/lib/package-lock.json b/lib/package-lock.json index 68cddb52..233274c8 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -37,6 +37,7 @@ "react-leaflet-cluster": "^2.1.0", "react-markdown": "^9.0.1", "react-photo-album": "^3.0.2", + "react-qr-code": "^2.0.16", "react-router-dom": "^6.16.0", "react-toastify": "^9.1.3", "remark-breaks": "^4.0.0", @@ -11103,6 +11104,12 @@ "node": ">=6" } }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.13.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", @@ -11319,6 +11326,19 @@ } } }, + "node_modules/react-qr-code": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.16.tgz", + "integrity": "sha512-8f54aTOo7DxYr1LB47pMeclV5SL/zSbJxkXHIS2a+QnAIa4XDVIdmzYRC+CBCJeDLSCeFHn8gHtltwvwZGJD/w==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/lib/package.json b/lib/package.json index b65f9562..95c0c90c 100644 --- a/lib/package.json +++ b/lib/package.json @@ -125,6 +125,7 @@ "react-leaflet-cluster": "^2.1.0", "react-markdown": "^9.0.1", "react-photo-album": "^3.0.2", + "react-qr-code": "^2.0.16", "react-router-dom": "^6.16.0", "react-toastify": "^9.1.3", "remark-breaks": "^4.0.0", diff --git a/lib/rollup.config.js b/lib/rollup.config.js index a68ba830..25e90725 100644 --- a/lib/rollup.config.js +++ b/lib/rollup.config.js @@ -47,6 +47,7 @@ export default [ /node_modules\/tiptap-markdown/, /node_modules\/markdown-it-task-lists/, /node_modules\/classnames/, + /node_modules\/react-qr-code/, ], requireReturnsDefault: 'auto', }), diff --git a/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx new file mode 100644 index 00000000..f300ae62 --- /dev/null +++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx @@ -0,0 +1,75 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' + +import { InviteLinkView } from './InviteLinkView' + +import type { Item } from '#types/Item' + +const itemWithSecret: Item = { + secrets: [ + { + secret: 'secret1', + }, + ], + id: '1', + name: 'Test Item', +} + +const itemWithoutSecret: Item = { + secrets: [], + id: '2', + name: 'Test Item Without Secret', +} + +const itemWithUndefinedSecrets: Item = { + id: '3', + name: 'Test Item With Undefined Secrets', +} + +describe('', () => { + let wrapper: ReturnType + + const Wrapper = ({ item }: { item: Item }) => { + return render() + } + + describe('when item does not have secrets', () => { + it('does not render anything', () => { + wrapper = Wrapper({ item: itemWithoutSecret }) + expect(wrapper.container.firstChild).toBeNull() + }) + }) + + describe('when item has secrets undefined', () => { + it('does not render anything', () => { + wrapper = Wrapper({ item: itemWithUndefinedSecrets }) + expect(wrapper.container.firstChild).toBeNull() + }) + }) + + describe('when item has secrets', () => { + beforeEach(() => { + wrapper = Wrapper({ item: itemWithSecret }) + }) + + it('renders the secret', () => { + expect(wrapper.getByDisplayValue('secret1', { exact: false })).toBeInTheDocument() + }) + + it('matches the snapshot', () => { + expect(wrapper.container.firstChild).toMatchSnapshot() + }) + + it('copies the secret to clipboard when button is clicked', () => { + const copyButton = wrapper.getByRole('button') + expect(copyButton).toBeInTheDocument() + + const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText') + + fireEvent.click(copyButton) + + // TODO Implement in a way that the URL stays consistent on CI + expect(clipboardSpy).toHaveBeenCalledWith('http://localhost:3000/invite/secret1') + }) + }) +}) diff --git a/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx b/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx index 4f5cb8e2..34bf615b 100644 --- a/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx +++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx @@ -1,28 +1,37 @@ -import { TextView } from '#components/Map/Subcomponents/ItemPopupComponents' +import { ClipboardIcon } from '@heroicons/react/24/outline' +import QRCode from 'react-qr-code' +import { toast } from 'react-toastify' import type { Item } from '#types/Item' export const InviteLinkView = ({ item }: { item: Item }) => { - // TODO Only show if user has permission to view secrets. - // usePermission() seems to be useful. + // Only show if user has permission to view secrets. + if (!item.secrets || item.secrets.length === 0) return - if (!item.secrets || item.secrets.length === 0) { - console.log('No secrets found for item', item.id) - // Generate a new secret if none exists? - return - } const link = `${window.location.origin}/invite/${item.secrets[0].secret}` + const copyToClipboard = () => { + void navigator.clipboard + .writeText(link) + .then(() => toast.success('Invite link copied to clipboard!')) + } + return (

Invite

-
+
+ +
+
+
) diff --git a/lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap b/lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap new file mode 100644 index 00000000..0340551f --- /dev/null +++ b/lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap @@ -0,0 +1,62 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > when item has secrets > matches the snapshot 1`] = ` +
+

+ Invite +

+
+ + +
+
+ + + + +
+
+`;