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