Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,19 @@
},
{
"path": "packages/vue-instantsearch/vue2/umd/index.js",
"maxSize": "75 kB"
"maxSize": "106 kB"
},
{
"path": "packages/vue-instantsearch/vue3/umd/index.js",
"maxSize": "75.5 kB"
"maxSize": "106 kB"
},
{
"path": "packages/vue-instantsearch/vue2/cjs/index.js",
"maxSize": "21 kB"
"maxSize": "23.5 kB"
},
{
"path": "packages/vue-instantsearch/vue3/cjs/index.js",
"maxSize": "21.75 kB"
"maxSize": "24 kB"
},
{
"path": "./packages/instantsearch.css/themes/algolia.css",
Expand Down
53 changes: 51 additions & 2 deletions packages/vue-instantsearch/src/__tests__/common-widgets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
AisMenuSelect,
AisDynamicWidgets,
AisRelatedProducts,
AisChat,
AisChatTrigger,
} from '../instantsearch';
import { renderCompat } from '../util/vue-compat';

Expand Down Expand Up @@ -603,8 +605,49 @@ const testSetups = {
document.body.appendChild(document.createElement('div'))
);
},
createChatWidgetTests() {
throw new Error('Chat is not supported in Vue InstantSearch');
async createChatWidgetTests({ instantSearchOptions, widgetParams }) {
const {
renderChat = true,
renderRefinements,
...chatWidgetParams
} = widgetParams;

mountApp(
{
render: renderCompat((h) =>
h(AisInstantSearch, { props: instantSearchOptions }, [
renderRefinements && h(AisSearchBox, { key: 'searchbox' }),
renderRefinements &&
h(AisRefinementList, {
key: 'brand',
props: { attribute: 'brand' },
}),
renderRefinements &&
h(AisRefinementList, {
key: 'category',
props: { attribute: 'category' },
}),
renderRefinements &&
h(AisHierarchicalMenu, {
key: 'hierarchy',
props: {
attributes: [
'hierarchicalCategories.lvl0',
'hierarchicalCategories.lvl1',
'hierarchicalCategories.lvl2',
],
},
}),
h(AisChatTrigger, { key: 'trigger' }),
renderChat && h(AisChat, { key: 'chat', props: chatWidgetParams }),
h(GlobalErrorSwallower, { key: 'errors' }),
])
),
},
document.body.appendChild(document.createElement('div'))
);

await nextTick();
},
createAutocompleteWidgetTests() {
throw new Error('Autocomplete is not supported in Vue InstantSearch');
Expand Down Expand Up @@ -661,6 +704,12 @@ const testOptions = {
},
createPoweredByWidgetTests: undefined,
createDynamicWidgetsWidgetTests: undefined,
// The AisChat/AisChatTrigger widgets are implemented and covered by
// component tests. The shared common suite stays skipped until its `vue`
// widgetParams variant is extended to carry a `chat` instance + options
// (it's currently `Record<string, never>`, and the tests drive a
// test-owned `chat` that only reaches the JS/React widgets). Tracked as a
// follow-up.
createChatWidgetTests: {
skippedTests: { 'Chat widget common tests': true },
},
Expand Down
14 changes: 12 additions & 2 deletions packages/vue-instantsearch/src/__tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function getAllComponents() {

try {
suitClass = mixins
.find(mixin => mixin.methods && mixin.methods.suit)
.find((mixin) => mixin.methods && mixin.methods.suit)
.methods.suit();
} catch (e) {
/* no suit class, so will fail the assertions */
Expand Down Expand Up @@ -82,12 +82,19 @@ function getAllComponents() {
props.isolated = false;
} else if (name === 'AisRelatedProducts') {
props.objectIDs = ['1'];
} else if (name === 'AisChat') {
// A transport avoids the agentId appId/apiKey requirement; the trigger
// validation is disabled since no ChatTrigger is mounted here.
props.transport = {};
props.disableTriggerValidation = true;
} else if (name === 'AisChatTrigger') {
// no required props
} else {
props.attribute = 'attr';
}

const Component = {
render: renderCompat(h =>
render: renderCompat((h) =>
h(
AisInstantSearch,
{
Expand Down Expand Up @@ -149,6 +156,9 @@ describe('DOM component', () => {
expect(suitClass).toBe(`ais-InstantSearch`);
} else if (name === 'AisExperimentalDynamicWidgets') {
expect(suitClass).toBe(`ais-DynamicWidgets`);
} else if (name === 'AisChatTrigger') {
// Renders the shared ChatToggleButton markup.
expect(suitClass).toBe(`ais-ChatToggleButton`);
} else {
expect(suitClass).toBe(`ais-${name.substr(3)}`);
}
Expand Down
26 changes: 26 additions & 0 deletions packages/vue-instantsearch/src/components/Carousel.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ export default {
required: false,
default: undefined,
},
// Optional overrides used by the chat tools (custom header, no built-in
// navigation, custom scroll icons).
headerComponent: {
type: Function,
required: false,
default: undefined,
},
showNavigation: {
type: Boolean,
required: false,
default: true,
},
previousIconComponent: {
type: Function,
required: false,
default: undefined,
},
nextIconComponent: {
type: Function,
required: false,
default: undefined,
},
},
created() {
this.hooksStore = createHooksStore(() => this.$forceUpdate());
Expand Down Expand Up @@ -81,6 +103,10 @@ export default {
itemComponent: this.itemComponent,
sendEvent: this.sendEvent,
translations: this.translations,
headerComponent: this.headerComponent,
showNavigation: this.showNavigation,
previousIconComponent: this.previousIconComponent,
nextIconComponent: this.nextIconComponent,
// The parent recommend widget forwards its own `list`/`item` classes.
classNames: this.classNames && {
list: this.classNames.list,
Expand Down
208 changes: 208 additions & 0 deletions packages/vue-instantsearch/src/components/Chat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import {
createChatComponent,
createStickToBottom,
} from 'instantsearch-ui-components';
import { connectChat } from 'instantsearch.js/es/connectors/index';

import { createSuitMixin } from '../mixins/suit';
import { createWidgetMixin } from '../mixins/widget';
import { createHooksStore } from '../util/hooks';
import { Fragment, isVue3, renderReactCompat } from '../util/vue-compat';

import { createDefaultTools } from './chat/tools';

export default {
name: 'AisChat',
mixins: [
createWidgetMixin({ connector: connectChat }, { $$widgetType: 'ais.chat' }),
createSuitMixin({ name: 'Chat' }),
],
props: {
// Transport (one of these is required by the connector)
agentId: { type: String, required: false, default: undefined },
transport: { type: Object, required: false, default: undefined },
chat: { type: Object, required: false, default: undefined },
// Connector options
tools: { type: Object, required: false, default: undefined },
initialMessages: { type: Array, required: false, default: undefined },
initialUserMessage: { type: String, required: false, default: undefined },
context: { type: [Object, Function], required: false, default: undefined },
persistence: { type: Boolean, required: false, default: undefined },
requiresSearch: { type: Boolean, required: false, default: undefined },
resume: { type: Boolean, required: false, default: undefined },
type: { type: String, required: false, default: undefined },
disableTriggerValidation: {
type: Boolean,
required: false,
default: undefined,
},
// Rendering
title: { type: String, required: false, default: undefined },
itemComponent: { type: Function, required: false, default: undefined },
getSearchPageURL: { type: Function, required: false, default: undefined },
layoutComponent: { type: Function, required: false, default: undefined },
classNames: { type: Object, required: false, default: undefined },
translations: { type: Object, required: false, default: undefined },
},
created() {
this.hooksStore = createHooksStore(() => this.$forceUpdate());
this.useStickToBottom = createStickToBottom(this.hooksStore.hooks);
},
mounted() {
this.hooksStore.flushEffects();
},
updated() {
this.hooksStore.flushEffects();
},
[isVue3 ? 'beforeUnmount' : 'beforeDestroy']() {
this.hooksStore.cleanup();
},
computed: {
widgetParams() {
const tools = Object.assign(
{},
createDefaultTools(this.itemComponent, this.getSearchPageURL),
this.tools
);

// Only include defined options; the connector requires exactly one of
// `chat` / `agentId` / `transport`.
const params = { tools };
const optional = {
agentId: this.agentId,
transport: this.transport,
chat: this.chat,
initialMessages: this.initialMessages,
initialUserMessage: this.initialUserMessage,
context: this.context,
persistence: this.persistence,
requiresSearch: this.requiresSearch,
resume: this.resume,
type: this.type,
disableTriggerValidation: this.disableTriggerValidation,
};
Object.keys(optional).forEach((key) => {
if (optional[key] !== undefined) {
params[key] = optional[key];
}
});
return params;
},
},
render: renderReactCompat(function (h) {
if (!this.state) {
return null;
}

const { useState, useRef, useEffect, memo } = this.hooksStore.hooks;

this.hooksStore.beginRender();

const ChatUiComponent = createChatComponent({
createElement: h,
Fragment,
memo,
useState,
});

const [maximized, setMaximized] = useState(false);
const promptRef = useRef(null);
const { scrollRef, contentRef, scrollToBottom, isAtBottom } =
this.useStickToBottom({ initial: 'smooth', resize: 'smooth' });

const state = this.state;

// Focus the prompt when the chat transitions from closed to open.
useEffect(() => {
if (!this.wasOpen && state.open) {
window.requestAnimationFrame(() => {
if (promptRef.current) {
promptRef.current.focus();
}
});
}
this.wasOpen = state.open;
}, [state.open]);

// Keep pinned to the bottom while streaming (carousels grow horizontally
// without changing height, so re-pin on every message/status change).
useEffect(() => {
if (state.status === 'streaming' || state.status === 'submitted') {
scrollToBottom({ preserveScrollPosition: true });
}
}, [state.messages, state.status, scrollToBottom]);

const headerTranslations = this.translations && this.translations.header;
const promptTranslations = this.translations && this.translations.prompt;
const messageTranslations = this.translations && this.translations.message;
const messagesTranslations =
this.translations && this.translations.messages;

const tree = h(ChatUiComponent, {
title: this.title,
open: state.open,
maximized,
sendMessage: state.sendMessage,
regenerate: state.regenerate,
stop: state.stop,
error: state.error,
layoutComponent: this.layoutComponent,
classNames: this.classNames,
headerProps: {
onClose: () => state.setOpen(false),
maximized,
onToggleMaximize: () => setMaximized(!maximized),
onClear: state.clearMessages,
canClear: Boolean(state.messages && state.messages.length),
translations: headerTranslations,
},
messagesProps: {
status: state.status,
onReload: (messageId) => state.regenerate({ messageId }),
onNewConversation: state.clearMessages,
onClose: () => state.setOpen(false),
sendMessage: state.sendMessage,
setInput: state.setInput,
onFeedback: state.sendChatMessageFeedback,
feedbackState: state.feedbackState,
messages: state.messages,
tools: state.tools,
indexUiState: state.indexUiState,
setIndexUiState: state.setIndexUiState,
isScrollAtBottom: isAtBottom,
scrollRef,
contentRef,
onScrollToBottom: scrollToBottom,
translations: messagesTranslations,
messageTranslations,
error: state.error,
},
promptProps: {
promptRef,
status: state.status,
value: state.input,
translations: promptTranslations,
onInput: (event) => {
state.setInput(event.currentTarget.value);
},
onSubmit: () => {
state.sendMessage({ text: state.input });
state.setInput('');
},
onStop: () => {
state.stop();
},
},
suggestionsProps: {
suggestions: state.suggestions,
onSuggestionClick: (suggestion) => {
state.sendMessage({ text: suggestion });
},
},
});

this.hooksStore.endRender();

return tree;
}),
};
Loading
Loading