Skip to content
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

feat(display-emitter): support Vue3 to the component #1555

Merged
merged 6 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/_vue3-migration-test/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export { default as TestSearch } from './test-search.vue';
export { default as TestTagging } from './tagging/test-tagging.vue';
export { default as TestRenderlessExtraParam } from './extra-params/test-renderless-extra-param.vue';
export { default as TestIcons } from './icons/test-icons.vue';
export { default as TestDisplayEmitter } from './test-display-emitter.vue';
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<template>
<ul class="list">
<DisplayEmitter
v-for="item in items"
:key="item.id"
:payload="{
url: 'tagging/url',
params: { displayId: item.id, totalHits: item.index }
}"
:eventMetadata="semanticFeature"
>
<li>{{ item.id }}</li>
</DisplayEmitter>
</ul>
</template>

<script setup lang="ts">
import DisplayEmitter from '../../../x-components/src/components/display-emitter.vue';
import { useXBus } from '../../../x-components/src/composables/use-x-bus';
import { DisplayWireMetadata } from '../../../x-components/src/wiring/wiring.types';

const xBus = useXBus();

const items = Array.from({ length: 50 }, (_, index) => ({ id: `item-${index}`, index }));
const semanticFeature: Partial<DisplayWireMetadata> = {
feature: 'semantics',
displayOriginalQuery: 'mercedes',
location: 'low_results'
};

/* eslint-disable no-console */
xBus
.on('TrackableElementDisplayed', true)
.subscribe(args => console.log('TrackableElementDisplayed event ->', args));
/* eslint-enable no-console */
</script>

<style>
.list {
height: 50px;
overflow-y: auto;
}
</style>
3 changes: 2 additions & 1 deletion packages/_vue3-migration-test/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ const adapter = {
}
})
);
}
},
tagging: () => new Promise(resolve => resolve())
} as unknown as XComponentsAdapter;

const store = createStore({});
Expand Down
8 changes: 7 additions & 1 deletion packages/_vue3-migration-test/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ import {
TestTagging,
TestRenderlessExtraParam,
TestAnimationFactory,
TestIcons
TestIcons,
TestDisplayEmitter
} from './';

const routes = [
Expand Down Expand Up @@ -302,6 +303,11 @@ const routes = [
path: '/icons',
name: 'Icons',
component: TestIcons
},
{
path: '/display-emitter',
name: 'DisplayEmitter',
component: TestDisplayEmitter
}
];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,115 +1,78 @@
import { mount, Wrapper } from '@vue/test-utils';
import Vue, { ref, nextTick, Ref } from 'vue';
import { TaggingRequest } from '@empathyco/x-types';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { useEmitDisplayEvent } from '../../composables/use-on-display';
import DisplayEmitter from '../display-emitter.vue';
import { getDataTestSelector } from '../../__tests__/utils';

jest.mock('../../composables/use-on-display', () => ({
useEmitDisplayEvent: jest.fn()
}));

let emitDisplayEventElementSpy: Ref<Vue | null> = ref(null);
let emitDisplayEventPayloadSpy: TaggingRequest = { url: '', params: {} };
const unwatchDisplaySpy = jest.fn();
const refElementVisibility = ref(false);
(useEmitDisplayEvent as jest.Mock).mockImplementation(({ element, taggingRequest }) => {
// jest doesn't handle well evaluation of dynamic references with `toHaveBeenCalledWith`
// so we need a spy
emitDisplayEventElementSpy = element;
emitDisplayEventPayloadSpy = taggingRequest;

return {
isElementVisible: refElementVisibility,
unwatchDisplay: unwatchDisplaySpy
};
});

/**
* Renders the {@link DisplayEmitter} component, exposing a basic API for testing.
*
* @param options - The options to render the component with.
*
* @returns The API for testing the `DisplayEmitter` component.
*/
function renderDisplayEmitter(
{ payload }: RenderDisplayEmitterOptions = { payload: { url: '', params: {} } }
): RenderDisplayEmitterAPI {
const wrapper = mount(
{
components: {
DisplayEmitter
},
template: `
<DisplayEmitter :payload="payload">
<div data-test="child" />
</DisplayEmitter>`,
props: ['payload']
},
{
propsData: {
payload
}
}
);
(useEmitDisplayEvent as jest.Mock).mockReturnValue({ unwatchDisplay: unwatchDisplaySpy });

function render({
payload = { url: 'tagging/url', params: { test: 'param' } },
eventMetadata = { test: 'param' }
} = {}) {
const wrapper = mount({
components: { DisplayEmitter },
template: `
<DisplayEmitter :payload="payload" :eventMetadata="eventMetadata">
<div data-test="child" />
</DisplayEmitter>`,
data: () => ({ payload, eventMetadata })
});

return {
wrapper
wrapper: wrapper.findComponent(DisplayEmitter),
element: wrapper.find(getDataTestSelector('child')).element,
payload,
eventMetadata
};
}

describe('testing DisplayEmitter component', () => {
beforeEach(() => {
refElementVisibility.value = false;
(useEmitDisplayEvent as jest.Mock).mockClear();
unwatchDisplaySpy.mockClear();
});

it('renders everything passed to its default slot', () => {
const { wrapper } = renderDisplayEmitter();
const { wrapper } = render();

expect(wrapper.find(getDataTestSelector('child')).exists()).toBe(true);
expect(wrapper.find(getDataTestSelector('child')).exists()).toBeTruthy();
});

it('uses `useEmitDisplayEvent` underneath', () => {
renderDisplayEmitter();
it('executes `useEmitDisplayEvent` composable underneath', () => {
render();

expect(useEmitDisplayEvent).toHaveBeenCalled();
});

it('provides `useEmitDisplayEvent` with the element in the slot to watch', async () => {
renderDisplayEmitter();
const { element } = render();

await nextTick();

expect(emitDisplayEventElementSpy.value).not.toBe(null);
expect(emitDisplayEventElementSpy.value?.$el.getAttribute('data-test')).toBe('child');
expect(useEmitDisplayEvent).toHaveBeenCalledWith(expect.objectContaining({ element }));
});

// eslint-disable-next-line max-len
it('provides `useEmitDisplayEvent` with the payload to emit with the display event', () => {
const payload = { url: 'test-url', params: { test: 'param' } };
renderDisplayEmitter({
payload
});
it('provides `useEmitDisplayEvent` with the payload and metadata to emit with the display event', async () => {
const { payload, eventMetadata } = render();

expect(useEmitDisplayEvent).toHaveBeenCalled();
expect(emitDisplayEventPayloadSpy).toBe(payload);
await nextTick();

expect(useEmitDisplayEvent).toHaveBeenCalledWith(
expect.objectContaining({ taggingRequest: payload, eventMetadata })
);
});

it('removes the watcher on unmount', async () => {
const { wrapper } = renderDisplayEmitter();
const { wrapper } = render();

wrapper.destroy();
await nextTick();

expect(unwatchDisplaySpy).toHaveBeenCalled();
});
});

interface RenderDisplayEmitterOptions {
/** The payload to provide. */
payload?: TaggingRequest;
}

interface RenderDisplayEmitterAPI {
/** The wrapper testing component instance. */
wrapper: Wrapper<Vue>;
}
69 changes: 35 additions & 34 deletions packages/x-components/src/components/display-emitter.vue
Original file line number Diff line number Diff line change
@@ -1,55 +1,56 @@
<template>
<NoElement ref="root">
<slot />
</NoElement>
</template>

<script lang="ts">
import { defineComponent, onUnmounted, PropType, Ref, ref } from 'vue';
import {
defineComponent,
getCurrentInstance,
h,
onMounted,
onUnmounted,
PropType,
WatchStopHandle
} from 'vue';
import { TaggingRequest } from '@empathyco/x-types';
import { useEmitDisplayEvent } from '../composables/use-on-display';
import { WireMetadata } from '../wiring';
import { NoElement } from './no-element';

/**
* A component that emits a display event when it first appears in the viewport.
*/
/** A component that emits a display event when it first appears in the viewport. */
export default defineComponent({
components: {
NoElement
},
name: 'DisplayEmitter',
props: {
/**
* The payload for the display event emit.
*
* @public
*/
/** The payload for the display event emit. */
payload: {
type: Object as PropType<TaggingRequest>,
required: true
},
/**
* Optional event metadata.
*
* @public
*/
/** Optional event metadata. */
eventMetadata: {
type: Object as PropType<Omit<WireMetadata, 'moduleName' | 'origin' | 'location'>>
}
},
setup(props) {
const root = ref(null);
const { unwatchDisplay } = useEmitDisplayEvent({
element: root as Ref<HTMLElement | null>,
taggingRequest: props.payload,
...(props.eventMetadata && { eventMetadata: props.eventMetadata })
setup(props, { slots }) {
let unwatchDisplay: WatchStopHandle | undefined;

onMounted(() => {
const element = getCurrentInstance()?.proxy.$el as HTMLElement | undefined;
if (element) {
unwatchDisplay = useEmitDisplayEvent({
element,
taggingRequest: props.payload,
...(props.eventMetadata && { eventMetadata: props.eventMetadata })
}).unwatchDisplay;
}
});

onUnmounted(unwatchDisplay);
onUnmounted(() => {
unwatchDisplay?.();
});

return {
root
};
/*
* Obtains the vNodes array of the default slot and renders only the first one.
* It avoids to render a `Fragment` with the vNodes in Vue3 and the same behaviour in Vue2
* because Vue2 only allows a single root node. Then, `getCurrentInstance()?.proxy?.$el` to
* retrieve the HTML element in both versions.
*/
return () => slots.default?.()[0] ?? h();
}
});
</script>
Expand Down
Loading