github-issue-tower-defence-management 1.142.2 → 1.143.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/umino-project.yml +2 -8
- package/CHANGELOG.md +14 -0
- package/README.md +2 -2
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-BaGbcbYI.css → index-Bp-p-XOs.css} +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-C1KKZ_a1.js +81 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/bin/domain/usecases/OauthTokenSelectUseCase.js +2 -2
- package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleCopyCodeButton.stories.tsx +28 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleCopyCodeButton.test.tsx +65 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleCopyCodeButton.tsx +19 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.test.tsx +103 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.tsx +41 -5
- package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCopyUrlButton.tsx +10 -42
- package/src/adapter/entry-points/console/ui/src/features/console/components/shared/ConsoleClipboardCopyButton.stories.tsx +32 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/shared/ConsoleClipboardCopyButton.test.tsx +67 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/shared/ConsoleClipboardCopyButton.tsx +57 -0
- package/src/adapter/entry-points/console/ui/src/index.css +31 -0
- package/src/adapter/entry-points/console/ui-dist/assets/{index-BaGbcbYI.css → index-Bp-p-XOs.css} +1 -1
- package/src/adapter/entry-points/console/ui-dist/assets/index-C1KKZ_a1.js +81 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +2 -2
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +33 -11
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +2 -2
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +2 -2
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-2c5E3tdu.js +0 -81
- package/src/adapter/entry-points/console/ui-dist/assets/index-2c5E3tdu.js +0 -81
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-C1KKZ_a1.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Bp-p-XOs.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -9,8 +9,8 @@ const selectionWeightOf = (candidate) => {
|
|
|
9
9
|
exports.selectionWeightOf = selectionWeightOf;
|
|
10
10
|
const SECONDS_PER_DAY = 86400;
|
|
11
11
|
const SEVEN_DAYS_IN_SECONDS = 7 * SECONDS_PER_DAY;
|
|
12
|
-
exports.FIVE_HOUR_MIN_FREE_RATIO = 0.
|
|
13
|
-
exports.SEVEN_DAY_MIN_FREE_RATIO = 0.
|
|
12
|
+
exports.FIVE_HOUR_MIN_FREE_RATIO = 0.25;
|
|
13
|
+
exports.SEVEN_DAY_MIN_FREE_RATIO = 0.03;
|
|
14
14
|
const selectWeightedCandidate = (eligible, candidateOf, deterministicBest, random) => {
|
|
15
15
|
if (eligible.length <= 1) {
|
|
16
16
|
return deterministicBest;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OauthTokenSelectUseCase.js","sourceRoot":"","sources":["../../../src/domain/usecases/OauthTokenSelectUseCase.ts"],"names":[],"mappings":";;;AAmBa,QAAA,wBAAwB,GAAG,CAAC,CAAC;AAEnC,MAAM,iBAAiB,GAAG,CAAC,SAA8B,EAAU,EAAE;IAC1E,MAAM,MAAM,GAAG,SAAS,CAAC,eAAe,IAAI,gCAAwB,CAAC;IACrE,OAAO,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;AACjC,CAAC,CAAC;AAHW,QAAA,iBAAiB,qBAG5B;AAgBF,MAAM,eAAe,GAAG,KAAK,CAAC;AAC9B,MAAM,qBAAqB,GAAG,CAAC,GAAG,eAAe,CAAC;AAErC,QAAA,wBAAwB,GAAG,
|
|
1
|
+
{"version":3,"file":"OauthTokenSelectUseCase.js","sourceRoot":"","sources":["../../../src/domain/usecases/OauthTokenSelectUseCase.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ConsoleCopyCodeButton } from './ConsoleCopyCodeButton';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ConsoleCopyCodeButton> = {
|
|
5
|
+
title: 'Console/ConsoleCopyCodeButton',
|
|
6
|
+
component: ConsoleCopyCodeButton,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof ConsoleCopyCodeButton>;
|
|
12
|
+
|
|
13
|
+
export const MultiLineTypeScript: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
code: `export const splitMarkdownSegments = (source: string): ConsoleMarkdownSegment[] => {
|
|
16
|
+
const lines = source.split('\\n');
|
|
17
|
+
return lines.length > 0 ? parse(lines) : [];
|
|
18
|
+
};
|
|
19
|
+
`,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const ShellCommand: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
code: 'npm run build:console-ui\nnode scripts/copyConsoleUiDist.mjs\n',
|
|
26
|
+
label: 'Copy command',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
|
2
|
+
import { ConsoleCopyCodeButton } from './ConsoleCopyCodeButton';
|
|
3
|
+
|
|
4
|
+
const code = 'const first = 1;\nconst second = first + 1;\n';
|
|
5
|
+
|
|
6
|
+
describe('ConsoleCopyCodeButton', () => {
|
|
7
|
+
const writeText = jest.fn(async () => {});
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
writeText.mockClear();
|
|
11
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
12
|
+
configurable: true,
|
|
13
|
+
value: { writeText },
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders the default copy label and accessible name', () => {
|
|
18
|
+
const { getByRole } = render(<ConsoleCopyCodeButton code={code} />);
|
|
19
|
+
expect(getByRole('button', { name: 'Copy code' })).toHaveTextContent(
|
|
20
|
+
'Copy code',
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('uses the provided label for the accessible name', () => {
|
|
25
|
+
const { getByRole } = render(
|
|
26
|
+
<ConsoleCopyCodeButton code={code} label="Copy snippet" />,
|
|
27
|
+
);
|
|
28
|
+
expect(getByRole('button', { name: 'Copy snippet' })).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('writes the multi-line code to the clipboard and shows the copied state', async () => {
|
|
32
|
+
const { getByRole, findByText } = render(
|
|
33
|
+
<ConsoleCopyCodeButton code={code} />,
|
|
34
|
+
);
|
|
35
|
+
await act(async () => {
|
|
36
|
+
fireEvent.click(getByRole('button'));
|
|
37
|
+
});
|
|
38
|
+
expect(writeText).toHaveBeenCalledWith(code);
|
|
39
|
+
expect(await findByText('Copied')).toBeInTheDocument();
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(
|
|
42
|
+
getByRole('button', { name: 'Code copied to clipboard' }),
|
|
43
|
+
).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('reverts the copied state after the feedback delay', async () => {
|
|
48
|
+
jest.useFakeTimers();
|
|
49
|
+
try {
|
|
50
|
+
const { getByRole, getByText, queryByText } = render(
|
|
51
|
+
<ConsoleCopyCodeButton code={code} />,
|
|
52
|
+
);
|
|
53
|
+
await act(async () => {
|
|
54
|
+
fireEvent.click(getByRole('button'));
|
|
55
|
+
});
|
|
56
|
+
expect(getByText('Copied')).toBeInTheDocument();
|
|
57
|
+
act(() => {
|
|
58
|
+
jest.advanceTimersByTime(1500);
|
|
59
|
+
});
|
|
60
|
+
expect(queryByText('Copied')).toBeNull();
|
|
61
|
+
} finally {
|
|
62
|
+
jest.useRealTimers();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ConsoleClipboardCopyButton } from '../shared/ConsoleClipboardCopyButton';
|
|
2
|
+
|
|
3
|
+
export type ConsoleCopyCodeButtonProps = {
|
|
4
|
+
code: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const ConsoleCopyCodeButton = ({
|
|
9
|
+
code,
|
|
10
|
+
label = 'Copy code',
|
|
11
|
+
}: ConsoleCopyCodeButtonProps) => (
|
|
12
|
+
<ConsoleClipboardCopyButton
|
|
13
|
+
value={code}
|
|
14
|
+
idleText={label}
|
|
15
|
+
idleAriaLabel={label}
|
|
16
|
+
copiedAriaLabel="Code copied to clipboard"
|
|
17
|
+
className="console-copy-code-button"
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, waitFor } from '@testing-library/react';
|
|
1
|
+
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
|
2
2
|
import { ConsoleMarkdownContent } from './ConsoleMarkdownContent';
|
|
3
3
|
|
|
4
4
|
jest.mock('../../lib/mermaidLoader', () => ({
|
|
@@ -7,6 +7,30 @@ jest.mock('../../lib/mermaidLoader', () => ({
|
|
|
7
7
|
),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
+
const multiLineCodeBody = [
|
|
11
|
+
'Intro paragraph.',
|
|
12
|
+
'',
|
|
13
|
+
'```ts',
|
|
14
|
+
'const first = 1;',
|
|
15
|
+
'const second = first + 1;',
|
|
16
|
+
'```',
|
|
17
|
+
].join('\n');
|
|
18
|
+
|
|
19
|
+
const multiLineCodeText = 'const first = 1;\nconst second = first + 1;\n';
|
|
20
|
+
|
|
21
|
+
const twoCodeBlocksBody = [
|
|
22
|
+
'```ts',
|
|
23
|
+
'const first = 1;',
|
|
24
|
+
'```',
|
|
25
|
+
'',
|
|
26
|
+
'Between the blocks.',
|
|
27
|
+
'',
|
|
28
|
+
'```sh',
|
|
29
|
+
'npm run build',
|
|
30
|
+
'npm test',
|
|
31
|
+
'```',
|
|
32
|
+
].join('\n');
|
|
33
|
+
|
|
10
34
|
describe('ConsoleMarkdownContent', () => {
|
|
11
35
|
it('renders markdown body content', () => {
|
|
12
36
|
const { container } = render(
|
|
@@ -80,6 +104,84 @@ describe('ConsoleMarkdownContent', () => {
|
|
|
80
104
|
expect(container.querySelector('.decorated-reference')).toBeNull();
|
|
81
105
|
});
|
|
82
106
|
|
|
107
|
+
describe('code block copy control', () => {
|
|
108
|
+
const writeText = jest.fn(async () => {});
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
writeText.mockClear();
|
|
112
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
113
|
+
configurable: true,
|
|
114
|
+
value: { writeText },
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('renders a copy control for a rendered code block', async () => {
|
|
119
|
+
const { findAllByRole } = render(
|
|
120
|
+
<ConsoleMarkdownContent body={multiLineCodeBody} />,
|
|
121
|
+
);
|
|
122
|
+
const buttons = await findAllByRole('button', { name: 'Copy code' });
|
|
123
|
+
expect(buttons).toHaveLength(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('copies the multi-line code block text with its line breaks intact', async () => {
|
|
127
|
+
const { findAllByRole } = render(
|
|
128
|
+
<ConsoleMarkdownContent body={multiLineCodeBody} />,
|
|
129
|
+
);
|
|
130
|
+
const [button] = await findAllByRole('button', { name: 'Copy code' });
|
|
131
|
+
await act(async () => {
|
|
132
|
+
fireEvent.click(button);
|
|
133
|
+
});
|
|
134
|
+
expect(writeText).toHaveBeenCalledWith(multiLineCodeText);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('copies only the text content of the code block without the control label', async () => {
|
|
138
|
+
const { container, findAllByRole } = render(
|
|
139
|
+
<ConsoleMarkdownContent body={multiLineCodeBody} />,
|
|
140
|
+
);
|
|
141
|
+
const [button] = await findAllByRole('button', { name: 'Copy code' });
|
|
142
|
+
expect(container.textContent).toContain('Copy code');
|
|
143
|
+
await act(async () => {
|
|
144
|
+
fireEvent.click(button);
|
|
145
|
+
});
|
|
146
|
+
const codeElement = container.querySelector('pre > code');
|
|
147
|
+
expect(codeElement?.textContent).toBe(multiLineCodeText);
|
|
148
|
+
expect(writeText).toHaveBeenCalledWith(multiLineCodeText);
|
|
149
|
+
expect(writeText).not.toHaveBeenCalledWith(
|
|
150
|
+
expect.stringContaining('Copy code'),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('renders one copy control per code block and copies only that block', async () => {
|
|
155
|
+
const { findAllByRole } = render(
|
|
156
|
+
<ConsoleMarkdownContent body={twoCodeBlocksBody} />,
|
|
157
|
+
);
|
|
158
|
+
const buttons = await findAllByRole('button', { name: 'Copy code' });
|
|
159
|
+
expect(buttons).toHaveLength(2);
|
|
160
|
+
await act(async () => {
|
|
161
|
+
fireEvent.click(buttons[0]);
|
|
162
|
+
});
|
|
163
|
+
expect(writeText).toHaveBeenNthCalledWith(1, 'const first = 1;\n');
|
|
164
|
+
await act(async () => {
|
|
165
|
+
fireEvent.click(buttons[1]);
|
|
166
|
+
});
|
|
167
|
+
expect(writeText).toHaveBeenNthCalledWith(2, 'npm run build\nnpm test\n');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('leaves the mermaid diagram path without a code copy control', async () => {
|
|
171
|
+
const { container, queryAllByRole } = render(
|
|
172
|
+
<ConsoleMarkdownContent
|
|
173
|
+
body={'intro\n\n```mermaid\ngraph TD; A-->B;\n```'}
|
|
174
|
+
/>,
|
|
175
|
+
);
|
|
176
|
+
await waitFor(() => {
|
|
177
|
+
expect(
|
|
178
|
+
container.querySelector('.console-mermaid-rendered'),
|
|
179
|
+
).not.toBeNull();
|
|
180
|
+
});
|
|
181
|
+
expect(queryAllByRole('button', { name: 'Copy code' })).toHaveLength(0);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
83
185
|
it('keeps reference links as plain anchors when no renderer is provided', () => {
|
|
84
186
|
const { container } = render(
|
|
85
187
|
<ConsoleMarkdownContent
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
splitMarkdownSegments,
|
|
11
11
|
} from '../../lib/markdown';
|
|
12
12
|
import { parseGitHubReferenceUrl } from '../../logic/references';
|
|
13
|
+
import { ConsoleCopyCodeButton } from './ConsoleCopyCodeButton';
|
|
13
14
|
import { ConsoleMermaidDiagram } from './ConsoleMermaidDiagram';
|
|
14
15
|
|
|
15
16
|
export type ConsoleReferenceLinkRenderer = (
|
|
@@ -55,6 +56,32 @@ const collectReferenceMounts = (container: HTMLElement): ReferenceMount[] => {
|
|
|
55
56
|
return mounts;
|
|
56
57
|
};
|
|
57
58
|
|
|
59
|
+
type CodeBlockMount = {
|
|
60
|
+
key: string;
|
|
61
|
+
host: HTMLElement;
|
|
62
|
+
code: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const collectCodeBlockMounts = (container: HTMLElement): CodeBlockMount[] => {
|
|
66
|
+
const codeElements = container.querySelectorAll<HTMLElement>('pre > code');
|
|
67
|
+
const mounts: CodeBlockMount[] = [];
|
|
68
|
+
codeElements.forEach((codeElement, index) => {
|
|
69
|
+
const preElement = codeElement.parentElement;
|
|
70
|
+
if (preElement === null) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const code = codeElement.textContent ?? '';
|
|
74
|
+
const wrapper = document.createElement('div');
|
|
75
|
+
wrapper.className = 'console-markdown-code-block';
|
|
76
|
+
const host = document.createElement('div');
|
|
77
|
+
host.className = 'console-markdown-code-copy-host';
|
|
78
|
+
preElement.replaceWith(wrapper);
|
|
79
|
+
wrapper.append(host, preElement);
|
|
80
|
+
mounts.push({ key: `code:${index}`, host, code });
|
|
81
|
+
});
|
|
82
|
+
return mounts;
|
|
83
|
+
};
|
|
84
|
+
|
|
58
85
|
const ConsoleMarkdownHtmlBlock = ({
|
|
59
86
|
source,
|
|
60
87
|
buildImageProxyUrl,
|
|
@@ -70,6 +97,7 @@ const ConsoleMarkdownHtmlBlock = ({
|
|
|
70
97
|
}, [source, buildImageProxyUrl, repoContext]);
|
|
71
98
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
72
99
|
const [referenceMounts, setReferenceMounts] = useState<ReferenceMount[]>([]);
|
|
100
|
+
const [codeBlockMounts, setCodeBlockMounts] = useState<CodeBlockMount[]>([]);
|
|
73
101
|
|
|
74
102
|
useEffect(() => {
|
|
75
103
|
const container = containerRef.current;
|
|
@@ -77,15 +105,23 @@ const ConsoleMarkdownHtmlBlock = ({
|
|
|
77
105
|
return;
|
|
78
106
|
}
|
|
79
107
|
container.innerHTML = html;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
setCodeBlockMounts(collectCodeBlockMounts(container));
|
|
109
|
+
setReferenceMounts(
|
|
110
|
+
renderReferenceLink === undefined
|
|
111
|
+
? []
|
|
112
|
+
: collectReferenceMounts(container),
|
|
113
|
+
);
|
|
85
114
|
}, [html, renderReferenceLink]);
|
|
86
115
|
|
|
87
116
|
return (
|
|
88
117
|
<div ref={containerRef} className="console-markdown">
|
|
118
|
+
{codeBlockMounts.map((mount) =>
|
|
119
|
+
createPortal(
|
|
120
|
+
<ConsoleCopyCodeButton code={mount.code} />,
|
|
121
|
+
mount.host,
|
|
122
|
+
mount.key,
|
|
123
|
+
),
|
|
124
|
+
)}
|
|
89
125
|
{renderReferenceLink !== undefined &&
|
|
90
126
|
referenceMounts.map((mount) =>
|
|
91
127
|
createPortal(
|
|
@@ -1,51 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Button } from '@/components/ui/button';
|
|
1
|
+
import { ConsoleClipboardCopyButton } from '../shared/ConsoleClipboardCopyButton';
|
|
3
2
|
|
|
4
3
|
export type ConsoleCopyUrlButtonProps = {
|
|
5
4
|
url: string;
|
|
6
5
|
label?: string;
|
|
7
6
|
};
|
|
8
7
|
|
|
9
|
-
const COPIED_FEEDBACK_MS = 1500;
|
|
10
|
-
|
|
11
8
|
export const ConsoleCopyUrlButton = ({
|
|
12
9
|
url,
|
|
13
10
|
label = 'Copy URL',
|
|
14
|
-
}: ConsoleCopyUrlButtonProps) =>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
[],
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
const handleCopy = async () => {
|
|
28
|
-
await navigator.clipboard.writeText(url);
|
|
29
|
-
setCopied(true);
|
|
30
|
-
if (resetTimerRef.current !== null) {
|
|
31
|
-
clearTimeout(resetTimerRef.current);
|
|
32
|
-
}
|
|
33
|
-
resetTimerRef.current = setTimeout(() => {
|
|
34
|
-
setCopied(false);
|
|
35
|
-
resetTimerRef.current = null;
|
|
36
|
-
}, COPIED_FEEDBACK_MS);
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<Button
|
|
41
|
-
type="button"
|
|
42
|
-
variant="ghost"
|
|
43
|
-
size="sm"
|
|
44
|
-
className="console-copy-url-button"
|
|
45
|
-
aria-label={copied ? 'URL copied to clipboard' : label}
|
|
46
|
-
onClick={handleCopy}
|
|
47
|
-
>
|
|
48
|
-
{copied ? 'Copied' : 'Copy URL'}
|
|
49
|
-
</Button>
|
|
50
|
-
);
|
|
51
|
-
};
|
|
11
|
+
}: ConsoleCopyUrlButtonProps) => (
|
|
12
|
+
<ConsoleClipboardCopyButton
|
|
13
|
+
value={url}
|
|
14
|
+
idleText="Copy URL"
|
|
15
|
+
idleAriaLabel={label}
|
|
16
|
+
copiedAriaLabel="URL copied to clipboard"
|
|
17
|
+
className="console-copy-url-button"
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ConsoleClipboardCopyButton } from './ConsoleClipboardCopyButton';
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof ConsoleClipboardCopyButton> = {
|
|
5
|
+
title: 'Console/ConsoleClipboardCopyButton',
|
|
6
|
+
component: ConsoleClipboardCopyButton,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof ConsoleClipboardCopyButton>;
|
|
12
|
+
|
|
13
|
+
export const UrlValue: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
value:
|
|
16
|
+
'https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/845',
|
|
17
|
+
idleText: 'Copy URL',
|
|
18
|
+
idleAriaLabel: 'Copy URL',
|
|
19
|
+
copiedAriaLabel: 'URL copied to clipboard',
|
|
20
|
+
className: 'console-copy-url-button',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const CodeValue: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
value: 'npm run build:console-ui\nnode scripts/copyConsoleUiDist.mjs\n',
|
|
27
|
+
idleText: 'Copy code',
|
|
28
|
+
idleAriaLabel: 'Copy code',
|
|
29
|
+
copiedAriaLabel: 'Code copied to clipboard',
|
|
30
|
+
className: 'console-copy-code-button',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { act, fireEvent, render } from '@testing-library/react';
|
|
2
|
+
import { ConsoleClipboardCopyButton } from './ConsoleClipboardCopyButton';
|
|
3
|
+
|
|
4
|
+
const value = 'first line\nsecond line\n';
|
|
5
|
+
|
|
6
|
+
const renderButton = () =>
|
|
7
|
+
render(
|
|
8
|
+
<ConsoleClipboardCopyButton
|
|
9
|
+
value={value}
|
|
10
|
+
idleText="Copy code"
|
|
11
|
+
idleAriaLabel="Copy code"
|
|
12
|
+
copiedAriaLabel="Code copied to clipboard"
|
|
13
|
+
className="console-copy-code-button"
|
|
14
|
+
/>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe('ConsoleClipboardCopyButton', () => {
|
|
18
|
+
const writeText = jest.fn(async () => {});
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
writeText.mockClear();
|
|
22
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
23
|
+
configurable: true,
|
|
24
|
+
value: { writeText },
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders the idle text and idle accessible name', () => {
|
|
29
|
+
const { getByRole } = renderButton();
|
|
30
|
+
expect(getByRole('button', { name: 'Copy code' })).toHaveTextContent(
|
|
31
|
+
'Copy code',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('writes the value verbatim and switches to the copied accessible name', async () => {
|
|
36
|
+
const { getByRole } = renderButton();
|
|
37
|
+
await act(async () => {
|
|
38
|
+
fireEvent.click(getByRole('button'));
|
|
39
|
+
});
|
|
40
|
+
expect(writeText).toHaveBeenCalledWith(value);
|
|
41
|
+
expect(
|
|
42
|
+
getByRole('button', { name: 'Code copied to clipboard' }),
|
|
43
|
+
).toHaveTextContent('Copied');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('reverts to the idle state after the feedback delay', async () => {
|
|
47
|
+
jest.useFakeTimers();
|
|
48
|
+
try {
|
|
49
|
+
const { getByRole, queryByText } = renderButton();
|
|
50
|
+
await act(async () => {
|
|
51
|
+
fireEvent.click(getByRole('button'));
|
|
52
|
+
});
|
|
53
|
+
act(() => {
|
|
54
|
+
jest.advanceTimersByTime(1500);
|
|
55
|
+
});
|
|
56
|
+
expect(queryByText('Copied')).toBeNull();
|
|
57
|
+
expect(getByRole('button', { name: 'Copy code' })).toBeInTheDocument();
|
|
58
|
+
} finally {
|
|
59
|
+
jest.useRealTimers();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('applies the given class name to the rendered button', () => {
|
|
64
|
+
const { getByRole } = renderButton();
|
|
65
|
+
expect(getByRole('button')).toHaveClass('console-copy-code-button');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
|
|
4
|
+
export type ConsoleClipboardCopyButtonProps = {
|
|
5
|
+
value: string;
|
|
6
|
+
idleText: string;
|
|
7
|
+
idleAriaLabel: string;
|
|
8
|
+
copiedAriaLabel: string;
|
|
9
|
+
className: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const COPIED_FEEDBACK_MS = 1500;
|
|
13
|
+
|
|
14
|
+
export const ConsoleClipboardCopyButton = ({
|
|
15
|
+
value,
|
|
16
|
+
idleText,
|
|
17
|
+
idleAriaLabel,
|
|
18
|
+
copiedAriaLabel,
|
|
19
|
+
className,
|
|
20
|
+
}: ConsoleClipboardCopyButtonProps) => {
|
|
21
|
+
const [copied, setCopied] = useState(false);
|
|
22
|
+
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(
|
|
25
|
+
() => () => {
|
|
26
|
+
if (resetTimerRef.current !== null) {
|
|
27
|
+
clearTimeout(resetTimerRef.current);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
[],
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const handleCopy = async () => {
|
|
34
|
+
await navigator.clipboard.writeText(value);
|
|
35
|
+
setCopied(true);
|
|
36
|
+
if (resetTimerRef.current !== null) {
|
|
37
|
+
clearTimeout(resetTimerRef.current);
|
|
38
|
+
}
|
|
39
|
+
resetTimerRef.current = setTimeout(() => {
|
|
40
|
+
setCopied(false);
|
|
41
|
+
resetTimerRef.current = null;
|
|
42
|
+
}, COPIED_FEEDBACK_MS);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Button
|
|
47
|
+
type="button"
|
|
48
|
+
variant="ghost"
|
|
49
|
+
size="sm"
|
|
50
|
+
className={className}
|
|
51
|
+
aria-label={copied ? copiedAriaLabel : idleAriaLabel}
|
|
52
|
+
onClick={handleCopy}
|
|
53
|
+
>
|
|
54
|
+
{copied ? 'Copied' : idleText}
|
|
55
|
+
</Button>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
@@ -511,6 +511,37 @@ body {
|
|
|
511
511
|
font-size: inherit;
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
.console-markdown-code-block {
|
|
515
|
+
display: flex;
|
|
516
|
+
flex-direction: column;
|
|
517
|
+
margin: 0.5em 0;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.console-markdown-code-block pre {
|
|
521
|
+
margin: 0;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.console-markdown-code-copy-host {
|
|
525
|
+
display: flex;
|
|
526
|
+
justify-content: flex-end;
|
|
527
|
+
margin-bottom: 4px;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.console-copy-code-button {
|
|
531
|
+
height: 24px;
|
|
532
|
+
padding: 0 8px;
|
|
533
|
+
border: 1px solid #30363d;
|
|
534
|
+
border-radius: 6px;
|
|
535
|
+
background-color: #161b22;
|
|
536
|
+
color: #c9d1d9;
|
|
537
|
+
font-size: 0.75rem;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
.console-copy-code-button:hover {
|
|
541
|
+
background-color: #21262d;
|
|
542
|
+
color: #e6edf3;
|
|
543
|
+
}
|
|
544
|
+
|
|
514
545
|
.console-markdown blockquote {
|
|
515
546
|
border-left: 3px solid #30363d;
|
|
516
547
|
padding: 0 1em;
|