Skip to content

Commit 261cc59

Browse files
[Remove Vuetify from Studio] Copy token input in Settings - Account
1 parent 3348c89 commit 261cc59

File tree

3 files changed

+230
-3
lines changed

3 files changed

+230
-3
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<template>
2+
3+
<div :class="{ 'token-input-wrapper-small': isSmall() }">
4+
<div
5+
v-if="show('loader', loading, 500)"
6+
class="loader"
7+
>
8+
<KCircularLoader />
9+
</div>
10+
<KTextbox
11+
v-else
12+
:value="displayToken"
13+
readonly
14+
class="notranslate token-input"
15+
:label="$tr('token')"
16+
:floatingLabel="false"
17+
:appearanceOverrides="{ maxWidth: 'none !important', width: '100% !important' }"
18+
>
19+
<template #innerAfter>
20+
<KIconButton
21+
icon="copy"
22+
:tooltip="$tr('tooltipText')"
23+
class="copy-button"
24+
:disabled="!token.trim()"
25+
@click="copyToClipboard"
26+
/>
27+
</template>
28+
</KTextbox>
29+
</div>
30+
31+
</template>
32+
33+
34+
<script>
35+
36+
import useKShow from 'kolibri-design-system/lib/composables/useKShow';
37+
import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow';
38+
39+
export default {
40+
name: 'StudioCopyToken',
41+
setup() {
42+
const { show } = useKShow();
43+
const { windowBreakpoint } = useKResponsiveWindow();
44+
45+
return { show, windowBreakpoint };
46+
},
47+
props: {
48+
token: {
49+
type: String,
50+
required: true,
51+
},
52+
hyphenate: {
53+
type: Boolean,
54+
default: true,
55+
},
56+
successText: {
57+
type: String,
58+
required: false,
59+
default: null,
60+
},
61+
loading: {
62+
type: Boolean,
63+
default: false,
64+
},
65+
},
66+
computed: {
67+
displayToken() {
68+
return this.hyphenate ? this.token.slice(0, 5) + '-' + this.token.slice(5) : this.token;
69+
},
70+
clipboardAvailable() {
71+
return Boolean(navigator.clipboard);
72+
},
73+
},
74+
methods: {
75+
isSmall() {
76+
return this.windowBreakpoint <= 1;
77+
},
78+
copyToClipboard() {
79+
if (this.clipboardAvailable) {
80+
navigator.clipboard
81+
.writeText(this.displayToken)
82+
.then(() => {
83+
const text = this.successText || this.$tr('copiedTokenId');
84+
this.$store.dispatch('showSnackbar', { text });
85+
this.$analytics.trackEvent('copy_token');
86+
this.$emit('copied');
87+
})
88+
.catch(() => {
89+
this.$store.dispatch('showSnackbar', { text: this.$tr('copyFailed') });
90+
});
91+
}
92+
},
93+
},
94+
$trs: {
95+
copiedTokenId: 'Token copied',
96+
copyFailed: 'Copy failed',
97+
token: 'Token',
98+
tooltipText: 'Copy token to import channel into Kolibri',
99+
},
100+
};
101+
102+
</script>
103+
104+
105+
<style scoped>
106+
107+
.loader {
108+
display: flex;
109+
align-items: center;
110+
justify-content: center;
111+
height: 70px;
112+
}
113+
114+
.copy-button {
115+
margin: 0 0.5rem 0.5rem 0;
116+
opacity: 0.8;
117+
}
118+
119+
.copy-button:hover {
120+
opacity: 1;
121+
}
122+
123+
.token-input-wrapper-small {
124+
min-width: 100%;
125+
margin-right: 0.5rem;
126+
}
127+
128+
</style>

contentcuration/contentcuration/frontend/settings/pages/Account/index.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
rel="noopener noreferrer"
7676
/>
7777
</p>
78-
<CopyToken
78+
<StudioCopyToken
7979
class="copy-token"
8080
:token="user.api_token || ' '"
8181
:loading="!user.api_token"
@@ -158,15 +158,15 @@
158158
import FullNameForm from './FullNameForm';
159159
import ChangePasswordForm from './ChangePasswordForm';
160160
import DeleteAccountForm from './DeleteAccountForm';
161-
import CopyToken from 'shared/views/CopyToken';
161+
import StudioCopyToken from './StudioCopyToken.vue';
162162
163163
export default {
164164
name: 'Account',
165165
components: {
166166
ChangePasswordForm,
167-
CopyToken,
168167
FullNameForm,
169168
DeleteAccountForm,
169+
StudioCopyToken,
170170
},
171171
data() {
172172
return {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { render, fireEvent, screen } from '@testing-library/vue';
2+
import VueRouter from 'vue-router';
3+
import StudioCopyToken from '../Account/StudioCopyToken.vue';
4+
5+
function makeWrapper(props = {}) {
6+
const mockStore = {
7+
dispatch: jest.fn(),
8+
};
9+
10+
return {
11+
...render(StudioCopyToken, {
12+
props: {
13+
token: 'testtoken',
14+
...props,
15+
},
16+
routes: new VueRouter({}),
17+
mocks: {
18+
$store: mockStore,
19+
$tr: key => key,
20+
},
21+
}),
22+
mockStore,
23+
};
24+
}
25+
26+
describe('StudioCopyToken', () => {
27+
let originalClipboard;
28+
29+
beforeAll(() => {
30+
originalClipboard = navigator.clipboard;
31+
});
32+
33+
afterAll(() => {
34+
navigator.clipboard = originalClipboard;
35+
});
36+
37+
it('displays hyphenated token by default', () => {
38+
makeWrapper();
39+
const input = screen.getByDisplayValue('testt-oken');
40+
expect(input).toBeInTheDocument();
41+
});
42+
43+
it('displays token without hyphen if hyphenate is false', () => {
44+
makeWrapper({ hyphenate: false });
45+
const input = screen.getByDisplayValue('testtoken');
46+
expect(input).toBeInTheDocument();
47+
});
48+
49+
it('shows loader when loading is true', () => {
50+
const { container } = makeWrapper({ loading: true });
51+
expect(container.querySelector('.ui-progress-circular')).toBeInTheDocument();
52+
expect(screen.queryByDisplayValue(/test/)).not.toBeInTheDocument();
53+
});
54+
55+
it('should fire a copy operation on button click', async () => {
56+
const writeText = jest.fn().mockResolvedValue();
57+
Object.assign(navigator, {
58+
clipboard: { writeText },
59+
});
60+
makeWrapper();
61+
const button = screen.getByRole('button');
62+
await fireEvent.click(button);
63+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('testt-oken');
64+
});
65+
66+
it('dispatches snackbar on successful copy', async () => {
67+
const writeText = jest.fn().mockResolvedValue();
68+
Object.assign(navigator, {
69+
clipboard: { writeText },
70+
});
71+
const { mockStore } = makeWrapper();
72+
const button = screen.getByRole('button');
73+
await fireEvent.click(button);
74+
expect(mockStore.dispatch).toHaveBeenCalledWith('showSnackbar', { text: 'copiedTokenId' });
75+
});
76+
77+
it('dispatches snackbar on failed copy', async () => {
78+
const writeText = jest.fn().mockRejectedValue();
79+
Object.assign(navigator, {
80+
clipboard: { writeText },
81+
});
82+
const { mockStore } = makeWrapper();
83+
const button = screen.getByRole('button');
84+
await fireEvent.click(button);
85+
expect(mockStore.dispatch).toHaveBeenCalledWith('showSnackbar', { text: 'copyFailed' });
86+
});
87+
88+
it('renders the copy button', () => {
89+
const { container } = makeWrapper();
90+
const button = container.querySelector('.copy-button');
91+
expect(button).toBeInTheDocument();
92+
});
93+
94+
it('disables the copy button if token is empty', () => {
95+
const { container } = makeWrapper({ token: ' ' });
96+
const button = container.querySelector('.copy-button');
97+
expect(button).toBeDisabled();
98+
});
99+
});

0 commit comments

Comments
 (0)