Skip to content

Commit fede761

Browse files
refactor(RelatedPrompt): component logic to be reusable (#1696)
* refactor(RelatedPrompt): component logic to be reusable * feat(related-prompts-tag-list): Staggering animation * refactor(RelatedPrompt): naming and typing * refactor(RelatedPrompt): transition css false * feat(related-prompts-tag-list): width animation * fix(pnpm-lock): undo * fix(doc): undo default * fix(doc): add public tag * fix(type): remove vueuse type * fix(related-prompts-tag-list): fix ts types * text(related-prompt): unit test * test(related-prompts-tag-list): partial unit tested * fix(related-prompt): remove tailwind usages * refactor(related-prompts-tag): code improvement * refactor(related-prompt-tag-list): code improvement * doc(related-prompts-tag-list): update js doc --------- Co-authored-by: Carlos <[email protected]>
1 parent c0f4da5 commit fede761

File tree

9 files changed

+848
-235
lines changed

9 files changed

+848
-235
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { mount } from '@vue/test-utils';
2+
import typingDirective, { TypingOptions } from '../typing';
3+
4+
function render(typingOptions: TypingOptions) {
5+
const wrapper = mount(
6+
{
7+
template: `<div v-typing="{text, speed}"></div>`,
8+
data: () => ({
9+
text: typingOptions.text,
10+
speed: typingOptions.speed
11+
})
12+
},
13+
{
14+
global: {
15+
directives: {
16+
typing: typingDirective
17+
}
18+
}
19+
}
20+
);
21+
return wrapper;
22+
}
23+
24+
describe('typingHtmlDirective', () => {
25+
beforeEach(() => {
26+
jest.useFakeTimers();
27+
});
28+
29+
afterEach(() => {
30+
jest.useRealTimers();
31+
jest.clearAllMocks();
32+
});
33+
34+
it('should write the text character by character', () => {
35+
const mockHtml = 'Hello, World!';
36+
const wrapper = render({ text: mockHtml });
37+
const el = wrapper.find('div').element;
38+
39+
expect(el.innerHTML).toBe(mockHtml[0]);
40+
41+
jest.runAllTimers();
42+
43+
expect(el.innerHTML).toBe(mockHtml);
44+
});
45+
46+
it('should show a console.error if a text is not send', () => {
47+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => jest.fn());
48+
49+
render({ text: '' });
50+
51+
expect(consoleSpy).toHaveBeenCalledWith('v-typing: "text" is required.');
52+
53+
consoleSpy.mockRestore();
54+
});
55+
56+
it('should call clearTimeout when typing finished', () => {
57+
const mockClearTimeout = jest.spyOn(globalThis, 'clearTimeout');
58+
render({ text: 'Hello, World!' });
59+
60+
jest.runAllTimers();
61+
62+
expect(mockClearTimeout).toHaveBeenCalled();
63+
expect(mockClearTimeout.mock.calls.length).toBeGreaterThanOrEqual(1);
64+
65+
mockClearTimeout.mockRestore();
66+
});
67+
68+
it('should call clearTimeout after unmounting directive', () => {
69+
const mockClearTimeout = jest.spyOn(globalThis, 'clearTimeout');
70+
const sut = render({ text: 'Hello, World!' });
71+
72+
sut.unmount();
73+
74+
expect(mockClearTimeout).toHaveBeenCalled();
75+
expect(mockClearTimeout.mock.calls.length).toBeGreaterThanOrEqual(1);
76+
77+
mockClearTimeout.mockRestore();
78+
});
79+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './infinite-scroll';
2+
export * from './typing';
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { Directive } from 'vue';
2+
3+
/**
4+
* TypingOptions interface.
5+
*
6+
* @public
7+
*/
8+
export interface TypingOptions {
9+
/**
10+
* The text (plain or html) that will be typed into the target element.
11+
*/
12+
text: string;
13+
/**
14+
* The typing speed in milliseconds per character.
15+
*
16+
*/
17+
speed?: number;
18+
/**
19+
* The attribute of the HTML element where the typed text will be placed.
20+
* If not specified, the text will be set as content (innerHTML).
21+
*
22+
* @example 'placeholder'
23+
*/
24+
targetAttr?: string;
25+
}
26+
27+
interface TypingHTMLElement extends HTMLElement {
28+
__timeoutId?: number;
29+
}
30+
31+
const typingDirective: Directive<TypingHTMLElement, TypingOptions> = {
32+
mounted(el, binding) {
33+
execute(el, binding.value);
34+
},
35+
36+
updated(el, binding) {
37+
if (binding.value.text !== binding.oldValue?.text) {
38+
clearTimeout(el.__timeoutId);
39+
execute(el, binding.value);
40+
}
41+
},
42+
43+
unmounted(el) {
44+
clearTimeout(el.__timeoutId);
45+
}
46+
};
47+
48+
/**
49+
* Execute a typing animation in an HTML element.
50+
*
51+
* @param el - The HTML element where the typing animation will be displayed.
52+
* @param options - Options for the behavior of the animation.
53+
*/
54+
function execute(el: TypingHTMLElement, options: TypingOptions) {
55+
const { text, speed = 1, targetAttr = '' } = options;
56+
57+
if (!text) {
58+
console.error('v-typing: "text" is required.');
59+
return;
60+
}
61+
62+
let index = 0;
63+
64+
const updateContent = (value: string) => {
65+
if (targetAttr) {
66+
el.setAttribute(targetAttr, value);
67+
} else {
68+
el.innerHTML = value;
69+
}
70+
};
71+
72+
const type = () => {
73+
if (index < text.length) {
74+
updateContent(text.slice(0, index + 1));
75+
index++;
76+
el.__timeoutId = setTimeout(type, speed) as unknown as number;
77+
} else {
78+
updateContent(text);
79+
clearTimeout(el.__timeoutId);
80+
el.__timeoutId = undefined;
81+
}
82+
};
83+
84+
type();
85+
}
86+
87+
export default typingDirective;

packages/x-components/src/views/adapter.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ const experienceControlsAdapter = platformAdapter.experienceControls.extends({
99
endpoint: 'https://config-service.internal.test.empathy.co/public/configs'
1010
});
1111

12+
const relatedPromptsAdapter = platformAdapter.relatedPrompts.extends({
13+
endpoint: 'https://api.empathy.co/relatedprompts/mymotivemarketplace?store=Labstore+London'
14+
});
15+
1216
platformAdapter.experienceControls = experienceControlsAdapter;
17+
platformAdapter.relatedPrompts = relatedPromptsAdapter;
1318

1419
export const adapter = new Proxy(platformAdapter, {
1520
get: (obj: PlatformAdapter, prop: keyof PlatformAdapter) =>

packages/x-components/src/views/home/Home.vue

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -391,8 +391,21 @@
391391
<template #related-prompts-group>
392392
<RelatedPromptsTagList
393393
:button-class="'x-button-lead x-button-circle x-button-ghost x-p-0'"
394-
class="-x-mb-1 x-mt-24 desktop:x-mt-0 x-p-0"
395-
/>
394+
class="-x-mb-1 x-mt-24 desktop:x-mt-0 x-p-0 x-h-[70px]"
395+
tag-class="x-rounded-xl x-gap-8 x-w-[300px]
396+
x-max-w-[400px]"
397+
:tag-colors="['x-bg-amber-300', 'x-bg-amber-400', 'x-bg-amber-500']"
398+
>
399+
<template #default="{ relatedPrompt, isSelected, onSelect }">
400+
<RelatedPrompt
401+
@click="onSelect"
402+
:related-prompt="relatedPrompt"
403+
:selected="isSelected"
404+
data-wysiwyg="related-prompt"
405+
:data-wysiwyg-id="relatedPrompt.suggestionText"
406+
/>
407+
</template>
408+
</RelatedPromptsTagList>
396409
<QueryPreviewList
397410
v-if="selectedPrompt !== ''"
398411
:queries-preview-info="relatedPromptsQueriesPreviewInfo"
@@ -519,7 +532,7 @@
519532
<script lang="ts">
520533
/* eslint-disable max-len */
521534
import { computed, ComputedRef, defineComponent, provide, ref } from 'vue';
522-
import { RelatedPrompt } from '@empathyco/x-types';
535+
import { RelatedPrompt as RelatedPromptModel } from '@empathyco/x-types';
523536
import { animateClipPath } from '../../components/animations/animate-clip-path/animate-clip-path.factory';
524537
import StaggeredFadeAndSlide from '../../components/animations/staggered-fade-and-slide.vue';
525538
import AutoProgressBar from '../../components/auto-progress-bar.vue';
@@ -582,6 +595,7 @@
582595
import { QueryPreviewInfo } from '../../x-modules/queries-preview/store/types';
583596
import QueryPreviewButton from '../../x-modules/queries-preview/components/query-preview-button.vue';
584597
import DisplayEmitter from '../../components/display-emitter.vue';
598+
import RelatedPrompt from '../../x-modules/related-prompts/components/related-prompt.vue';
585599
import RelatedPromptsList from '../../x-modules/related-prompts/components/related-prompts-list.vue';
586600
import RelatedPromptsTagList from '../../x-modules/related-prompts/components/related-prompts-tag-list.vue';
587601
import ArrowRightIcon from '../../components/icons/arrow-right.vue';
@@ -625,6 +639,7 @@
625639
NextQueriesList,
626640
NextQuery,
627641
NextQueryPreview,
642+
RelatedPrompt,
628643
RelatedPromptsList,
629644
RelatedPromptsTagList,
630645
OpenMainModal,
@@ -715,15 +730,15 @@
715730
const { relatedPrompts } = useState('relatedPrompts', ['relatedPrompts']);
716731
717732
const relatedPromptsProducts = computed(
718-
(): RelatedPrompt[] => relatedPrompts.value[x.query.search]?.relatedPromptsProducts
733+
(): RelatedPromptModel[] => relatedPrompts.value[x.query.search]?.relatedPromptsProducts
719734
);
720735
721736
const selectedPrompt = computed(() => relatedPrompts.value[x.query.search]?.selectedPrompt);
722737
723738
const relatedPromptsQueriesPreviewInfo = computed(() => {
724739
if (relatedPromptsProducts.value) {
725740
const relatedPromptQueries = relatedPromptsProducts.value.find(
726-
(relatedPrompt: RelatedPrompt) => relatedPrompt.id === selectedPrompt.value
741+
(relatedPrompt: RelatedPromptModel) => relatedPrompt.id === selectedPrompt.value
727742
);
728743
const queries = relatedPromptQueries?.nextQueries as string[];
729744
return queries.map(query => ({ query }));
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ComponentMountingOptions, mount } from '@vue/test-utils';
2+
import RelatedPrompt from '../related-prompt.vue';
3+
import CrossTinyIcon from '../../../../components/icons/cross-tiny.vue';
4+
import PlusIcon from '../../../../components/icons/plus.vue';
5+
import { createRelatedPromptStub } from '../../../../__stubs__';
6+
7+
const relatedPromptStub = createRelatedPromptStub('Related Prompt 1');
8+
9+
const typingMock = jest.fn();
10+
11+
function render(options: ComponentMountingOptions<typeof RelatedPrompt> = {}) {
12+
const wrapper = mount(RelatedPrompt, {
13+
...options,
14+
props: { relatedPrompt: relatedPromptStub, ...options.props },
15+
directives: {
16+
typing: typingMock
17+
}
18+
});
19+
20+
return {
21+
wrapper,
22+
crossIcon: wrapper.findComponent(CrossTinyIcon),
23+
plusIcon: wrapper.findComponent(PlusIcon)
24+
};
25+
}
26+
27+
describe('relatedPrompt component', () => {
28+
it('should render correctly', () => {
29+
const sut = render();
30+
31+
const [el, binding] = typingMock.mock.calls[0];
32+
33+
expect(typingMock).toHaveBeenCalled();
34+
expect(el.tagName).toBe('SPAN');
35+
expect(binding.value).toStrictEqual({ text: 'Related Prompt 1', speed: 50 });
36+
37+
expect(sut.crossIcon.exists()).toBeFalsy();
38+
expect(sut.plusIcon.exists()).toBeTruthy();
39+
});
40+
41+
it('should render cross icon when selected prop is true', () => {
42+
const sut = render({ props: { relatedPrompt: relatedPromptStub, selected: true } });
43+
44+
expect(sut.crossIcon.exists()).toBeTruthy();
45+
expect(sut.plusIcon.exists()).toBeFalsy();
46+
});
47+
});

0 commit comments

Comments
 (0)