@inploi/plugin-chatbot 1.0.7 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.env +0 -1
  2. package/.env.example +0 -1
  3. package/.env.test +2 -0
  4. package/CHANGELOG.md +17 -0
  5. package/index.html +2 -1
  6. package/package.json +21 -8
  7. package/playwright.config.ts +82 -0
  8. package/public/mockServiceWorker.js +4 -9
  9. package/src/chatbot.api.ts +14 -14
  10. package/src/chatbot.constants.ts +3 -0
  11. package/src/chatbot.css +8 -15
  12. package/src/chatbot.dom.ts +10 -5
  13. package/src/chatbot.idb.ts +17 -0
  14. package/src/chatbot.state.ts +78 -144
  15. package/src/chatbot.ts +25 -35
  16. package/src/chatbot.utils.ts +27 -9
  17. package/src/index.dev.ts +7 -6
  18. package/src/interpreter/interpreter.ts +28 -20
  19. package/src/mocks/browser.ts +2 -2
  20. package/src/mocks/example.flows.ts +56 -18
  21. package/src/mocks/handlers.ts +37 -8
  22. package/src/style/palette.test.ts +20 -0
  23. package/src/style/palette.ts +69 -0
  24. package/src/ui/chat-bubble.tsx +20 -5
  25. package/src/ui/chat-input/chat-input.boolean.tsx +10 -5
  26. package/src/ui/chat-input/chat-input.file.tsx +8 -6
  27. package/src/ui/chat-input/chat-input.multiple-choice.tsx +52 -27
  28. package/src/ui/chat-input/chat-input.text.tsx +23 -17
  29. package/src/ui/chat-input/chat-input.tsx +47 -23
  30. package/src/ui/chatbot-header.tsx +34 -28
  31. package/src/ui/chatbot.tsx +83 -42
  32. package/src/ui/input-error.tsx +25 -31
  33. package/src/ui/job-application-content.tsx +68 -46
  34. package/src/ui/job-application-messages.tsx +42 -34
  35. package/src/ui/send-button.tsx +1 -1
  36. package/src/ui/typing-indicator.tsx +1 -1
  37. package/src/ui/useChatService.ts +18 -33
  38. package/src/ui/useFocus.ts +10 -0
  39. package/tests/integration.spec.ts +19 -0
  40. package/tests/test.ts +22 -0
  41. package/tsconfig.json +1 -1
package/.env CHANGED
@@ -1,3 +1,2 @@
1
- VITE_BASE_URL='https://preview.api.inploi.com'
2
1
  VITE_PUBLISHABLE_KEY='pk_edce29b92c2773898c482851'
3
2
  VITE_MOCKS=true
package/.env.example CHANGED
@@ -1,3 +1,2 @@
1
- VITE_BASE_URL='https://preview.api.inploi.com'
2
1
  VITE_PUBLISHABLE_KEY=''
3
2
  VITE_MOCKS=true
package/.env.test ADDED
@@ -0,0 +1,2 @@
1
+ VITE_PUBLISHABLE_KEY='fake_key'
2
+ VITE_MOCKS=false # mocking is handled by the spec
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @inploi/plugin-chatbot
2
2
 
3
+ ## 2.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Support hues for colouring the chatbot
8
+
9
+ ## 2.0.0
10
+
11
+ ### Patch Changes
12
+
13
+ - 2975c09: Replace `zustand` with `idb-keyval` and `@preact/signals` to better utilise indexeddb and reduce bundle size
14
+ - cc46781: Track started applications via the new `analytics` service exposed by sdk
15
+ - Updated dependencies [d8dc36f]
16
+ - Updated dependencies [d8dc36f]
17
+ - @inploi/sdk@1.5.0
18
+ - @inploi/core@1.5.6
19
+
3
20
  ## 1.0.7
4
21
 
5
22
  ### Patch Changes
package/index.html CHANGED
@@ -19,8 +19,9 @@
19
19
  <h1 style="font-size: 2rem; letter-spacing: -0.02em; text-align: center">Super legit careers hub</h1>
20
20
  <p>Welcome, start applying by clicking on one of the buttons below</p>
21
21
 
22
+ <button onclick="chatbot.startApplication({ jobId: '150153' })">Apply for Compass job</button>
22
23
  <button onclick="chatbot.startApplication({ jobId: '1' })">Apply for Wagamama job</button>
23
- <button onclick="chatbot.startApplication({ jobId: '2' })">Apply for job Test flow</button>
24
+ <button onclick="chatbot.startApplication({ jobId: 'test' })">Apply for job Test flow</button>
24
25
  </div>
25
26
 
26
27
  <script type="module" src="/src/index.dev.ts"></script>
package/package.json CHANGED
@@ -1,39 +1,49 @@
1
1
  {
2
2
  "name": "@inploi/plugin-chatbot",
3
- "version": "1.0.7",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@hookform/resolvers": "^3.3.2",
7
+ "@preact/signals": "^1.2.2",
8
+ "@radix-ui/react-dialog": "^1.0.5",
9
+ "@radix-ui/react-focus-guards": "^1.0.1",
10
+ "@radix-ui/react-focus-scope": "^1.0.4",
11
+ "@radix-ui/react-slot": "^1.0.2",
7
12
  "class-variance-authority": "^0.7.0",
8
13
  "clsx": "^2.0.0",
14
+ "culori": "^3.3.0",
15
+ "framer-motion": "^10.16.5",
9
16
  "idb-keyval": "^6.2.1",
10
- "immer": "^10.0.3",
11
17
  "preact": "^10.16.0",
12
18
  "react": "npm:@preact/compat",
13
19
  "react-dom": "npm:@preact/compat",
14
20
  "react-hook-form": "^7.48.2",
21
+ "react-remove-scroll": "^2.5.7",
15
22
  "react-transition-group": "^4.4.5",
16
23
  "swr": "^2.2.4",
17
24
  "tailwindcss-touch": "^1.0.1",
18
25
  "ts-pattern": "^5.0.5",
19
- "vaul": "^0.7.8",
20
- "zod": "^3.22.0",
21
- "zustand": "^4.4.4",
22
- "@inploi/core": "1.5.5"
26
+ "valibot": "^0.21.0",
27
+ "@inploi/core": "1.5.6"
23
28
  },
24
29
  "peerDependencies": {
25
30
  "@inploi/sdk": "*"
26
31
  },
27
32
  "devDependencies": {
28
33
  "@happy-dom/global-registrator": "^12.6.0",
34
+ "@playwright/test": "^1.40.1",
29
35
  "@preact/preset-vite": "^2.5.0",
30
36
  "@total-typescript/ts-reset": "^0.5.1",
37
+ "@types/culori": "^2.0.4",
38
+ "@types/node": "^20.10.0",
31
39
  "@types/react-transition-group": "^4.4.9",
32
40
  "autoprefixer": "^10.4.16",
41
+ "dotenv": "^16.3.1",
33
42
  "eslint": "^7.32.0",
34
43
  "eslint-plugin-react-hooks": "^4.6.0",
35
44
  "happy-dom": "^12.6.0",
36
- "msw": "^2.0.5",
45
+ "msw": "^2.0.10",
46
+ "playwright-msw": "^3.0.0",
37
47
  "postcss": "^8.4.31",
38
48
  "postcss-nesting": "^12.0.1",
39
49
  "rollup-plugin-visualizer": "^5.9.2",
@@ -42,7 +52,7 @@
42
52
  "typescript": "^5.3.2",
43
53
  "vite": "^4.4.5",
44
54
  "vite-tsconfig-paths": "^4.2.1",
45
- "@inploi/sdk": "1.4.6",
55
+ "@inploi/sdk": "1.5.1",
46
56
  "eslint-config-custom": "0.1.0",
47
57
  "tsconfig": "0.1.0"
48
58
  },
@@ -54,6 +64,9 @@
54
64
  "build": "tsc && vite build",
55
65
  "setup-local": "cp -n .env.example .env || true",
56
66
  "check": "eslint src --fix --max-warnings 0 && tsc",
67
+ "test:unit": "bun test",
68
+ "test:int": "playwright test",
69
+ "test:ui": "playwright test --ui",
57
70
  "preview": "vite preview"
58
71
  }
59
72
  }
@@ -0,0 +1,82 @@
1
+ import { defineConfig, devices } from '@playwright/test';
2
+ import dotenv from 'dotenv';
3
+ import path from 'path';
4
+
5
+ /**
6
+ * Read environment variables from file.
7
+ * https://github.com/motdotla/dotenv
8
+ */
9
+ dotenv.config({ path: path.resolve(process.cwd(), '.env.test') });
10
+
11
+ const PORT = 3333;
12
+
13
+ /**
14
+ * See https://playwright.dev/docs/test-configuration.
15
+ */
16
+ export default defineConfig({
17
+ testDir: './tests',
18
+ /* Run tests in files in parallel */
19
+ fullyParallel: true,
20
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
21
+ forbidOnly: !!process.env.CI,
22
+ /* Retry on CI only */
23
+ retries: process.env.CI ? 2 : 0,
24
+ /* Opt out of parallel tests on CI. */
25
+ workers: process.env.CI ? 1 : undefined,
26
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
27
+ reporter: 'html',
28
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
29
+ use: {
30
+ /* Base URL to use in actions like `await page.goto('/')`. */
31
+ baseURL: process.env.BASE_URL || `http://localhost:${PORT}`,
32
+
33
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
34
+ trace: 'on-first-retry',
35
+ },
36
+
37
+ /* Configure projects for major browsers */
38
+ projects: [
39
+ {
40
+ name: 'chromium',
41
+ use: { ...devices['Desktop Chrome'] },
42
+ },
43
+
44
+ // {
45
+ // name: 'firefox',
46
+ // use: { ...devices['Desktop Firefox'] },
47
+ // },
48
+
49
+ // {
50
+ // name: 'webkit',
51
+ // use: { ...devices['Desktop Safari'] },
52
+ // },
53
+
54
+ /* Test against mobile viewports. */
55
+ {
56
+ name: 'Mobile Chrome',
57
+ use: { ...devices['Pixel 5'] },
58
+ },
59
+ {
60
+ name: 'Mobile Safari',
61
+ use: { ...devices['iPhone 12'] },
62
+ },
63
+
64
+ /* Test against branded browsers. */
65
+ // {
66
+ // name: 'Microsoft Edge',
67
+ // use: { ...devices['Desktop Edge'], channel: 'msedge' },
68
+ // },
69
+ // {
70
+ // name: 'Google Chrome',
71
+ // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
72
+ // },
73
+ ],
74
+
75
+ /* Run your local dev server before starting the tests */
76
+ webServer: process.env.BASE_URL
77
+ ? undefined
78
+ : {
79
+ command: `pnpm dev --port ${PORT} --mode test`,
80
+ port: PORT,
81
+ },
82
+ });
@@ -2,13 +2,13 @@
2
2
  /* tslint:disable */
3
3
 
4
4
  /**
5
- * Mock Service Worker (2.0.5).
5
+ * Mock Service Worker (2.0.10).
6
6
  * @see https://github.com/mswjs/msw
7
7
  * - Please do NOT modify this file.
8
8
  * - Please do NOT serve this file on production.
9
9
  */
10
10
 
11
- const INTEGRITY_CHECKSUM = '0877fcdc026242810f5bfde0d7178db4'
11
+ const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39'
12
12
  const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
13
13
  const activeClientIds = new Set()
14
14
 
@@ -121,11 +121,6 @@ async function handleRequest(event, requestId) {
121
121
  if (client && activeClientIds.has(client.id)) {
122
122
  ;(async function () {
123
123
  const responseClone = response.clone()
124
- // When performing original requests, response body will
125
- // always be a ReadableStream, even for 204 responses.
126
- // But when creating a new Response instance on the client,
127
- // the body for a 204 response must be null.
128
- const responseBody = response.status === 204 ? null : responseClone.body
129
124
 
130
125
  sendToClient(
131
126
  client,
@@ -137,11 +132,11 @@ async function handleRequest(event, requestId) {
137
132
  type: responseClone.type,
138
133
  status: responseClone.status,
139
134
  statusText: responseClone.statusText,
140
- body: responseBody,
135
+ body: responseClone.body,
141
136
  headers: Object.fromEntries(responseClone.headers.entries()),
142
137
  },
143
138
  },
144
- [responseBody],
139
+ [responseClone.body],
145
140
  )
146
141
  })()
147
142
  }
@@ -1,6 +1,6 @@
1
- import { FlowNode, FlowNodeSchema } from '@inploi/core/flows';
1
+ import { FlowNode } from '@inploi/core/flows';
2
2
  import { ApiClient } from '@inploi/sdk';
3
- import z from 'zod';
3
+ import { any, array, coerce, number, object, optional, parse, string } from 'valibot';
4
4
 
5
5
  export type JobApplication = {
6
6
  job: {
@@ -18,19 +18,19 @@ export type JobApplication = {
18
18
  };
19
19
  };
20
20
 
21
- const ApplicationFlowSchema = z.object({
22
- job: z.object({
23
- id: z.coerce.string(),
24
- title: z.string(),
21
+ const ApplicationSchema = object({
22
+ job: object({
23
+ id: coerce(string(), String),
24
+ title: string(),
25
25
  }),
26
- company: z.object({
27
- name: z.string(),
28
- logo: z.string().optional(),
26
+ company: object({
27
+ name: string(),
28
+ logo: optional(string()),
29
29
  }),
30
- flow: z.object({
31
- id: z.coerce.string(),
32
- nodes: z.array(FlowNodeSchema),
33
- version: z.number(),
30
+ flow: object({
31
+ id: coerce(string(), String),
32
+ nodes: array(any()),
33
+ version: number(),
34
34
  }),
35
35
  });
36
36
 
@@ -42,5 +42,5 @@ export async function getApplicationData({
42
42
  apiClient: ApiClient;
43
43
  }): Promise<JobApplication> {
44
44
  const rawData = await apiClient.fetch(`/flow/job/${jobId}`);
45
- return ApplicationFlowSchema.parse(rawData);
45
+ return parse(ApplicationSchema, rawData);
46
46
  }
@@ -1,6 +1,9 @@
1
1
  export const CHATBOT_ELEMENT_ID = 'isdk';
2
2
 
3
+ export const HEADER_HEIGHT = 44;
4
+
3
5
  export const ERROR_MESSAGES = {
6
+ not_in_local_storage: 'Application not found in local storage',
4
7
  invalid_end_node: 'Unexpected node type to finish flow',
5
8
  no_submissions: 'Application ended without any fields submitted',
6
9
  };
package/src/chatbot.css CHANGED
@@ -19,20 +19,6 @@
19
19
  --i-n-11: 206 6% 43.5%;
20
20
  --i-n-12: 206 24% 9%;
21
21
 
22
- /* Accent colors */
23
- --i-a-1: 240 33% 99%;
24
- --i-a-2: 225 100% 98%;
25
- --i-a-3: 222 89% 96%;
26
- --i-a-4: 224 100% 94%;
27
- --i-a-5: 224 100% 91%;
28
- --i-a-6: 225 100% 88%;
29
- --i-a-7: 226 87% 82%;
30
- --i-a-8: 226 75% 75%;
31
- --i-a-9: 226 70% 55%;
32
- --i-a-10: 226 65% 52%;
33
- --i-a-11: 226 56% 50%;
34
- --i-a-12: 226 50% 24%;
35
-
36
22
  /** Error colours */
37
23
  --i-e-1: 340 100% 99%;
38
24
  --i-e-2: 353 100% 98%;
@@ -79,7 +65,7 @@
79
65
  box-sizing: border-box;
80
66
  }
81
67
 
82
- :is(ul) {
68
+ :is(ul, ol) {
83
69
  list-style: none;
84
70
  padding: 0;
85
71
  margin: 0;
@@ -90,11 +76,18 @@
90
76
  padding: 0;
91
77
  }
92
78
 
79
+ :is(li) {
80
+ margin: 0;
81
+ padding: 0;
82
+ list-style: none;
83
+ }
84
+
93
85
  :is(button) {
94
86
  margin: 0;
95
87
  padding: 0;
96
88
  border: unset;
97
89
  background: unset;
90
+ text-align: unset;
98
91
  }
99
92
  }
100
93
  }
@@ -1,10 +1,9 @@
1
1
  import { CHATBOT_ELEMENT_ID } from './chatbot.constants';
2
2
 
3
- export const overlayClassNames =
4
- 'data-[transition=entering]:opacity-0 data-[transition=exiting]:opacity-0 ease-expo-out duration-500 transition-opacity bg-neutral-12/60 fixed inset-0';
5
-
6
3
  export const createChatbotDomManager = () => {
7
4
  let chatbotElement: HTMLDivElement | null = null;
5
+ const styleElements: HTMLStyleElement[] = [];
6
+
8
7
  return {
9
8
  getOrCreateChatbotElement: () => {
10
9
  if (chatbotElement) return chatbotElement;
@@ -15,8 +14,14 @@ export const createChatbotDomManager = () => {
15
14
  chatbotElement = newElement;
16
15
  return newElement;
17
16
  },
18
- renderLoading: (element: HTMLElement) => {
19
- element.innerHTML = `<div class="${overlayClassNames}" />`;
17
+ addStyle: (css: string, id: string) => {
18
+ const head = document.head;
19
+ const element = document.createElement('style');
20
+ element.id = id;
21
+ element.innerHTML = css;
22
+
23
+ styleElements.push(element);
24
+ head.appendChild(element);
20
25
  },
21
26
  };
22
27
  };
@@ -0,0 +1,17 @@
1
+ import { createStore, get, set } from 'idb-keyval';
2
+
3
+ import { JobApplication } from './chatbot.api';
4
+ import { ApplicationData, getCacheKey } from './chatbot.state';
5
+
6
+ const store = createStore('inploi', 'applications');
7
+
8
+ export const idb = {
9
+ getApplicationData: async (application: JobApplication) => {
10
+ const key = getCacheKey(application);
11
+ return await get<ApplicationData>(key, store);
12
+ },
13
+ setApplicationData: async (params: { application: JobApplication; data: ApplicationData }) => {
14
+ const key = getCacheKey(params.application);
15
+ return await set(key, params.data, store);
16
+ },
17
+ };
@@ -1,52 +1,84 @@
1
- import { del, get, set } from 'idb-keyval';
2
- import { produce } from 'immer';
3
- import { create } from 'zustand';
4
- import { StateStorage, createJSONStorage, persist } from 'zustand/middleware';
1
+ import { invariant } from '@inploi/core/common';
2
+ import { batch, signal } from '@preact/signals';
5
3
 
6
4
  import { JobApplication } from './chatbot.api';
5
+ import { idb } from './chatbot.idb';
7
6
  import { DistributivePick, getHeadOrThrow } from './chatbot.utils';
8
7
  import { ChatInput } from './ui/chat-input/chat-input';
9
8
  import { ChatbotInput } from './ui/chat-input/chat-input';
10
9
 
11
- const storage: StateStorage = {
12
- getItem: async (name: string): Promise<string | null> => {
13
- return (await get(name)) || null;
14
- },
15
- setItem: async (name: string, value: string): Promise<void> => {
16
- await set(name, value);
17
- },
18
- removeItem: async (name: string): Promise<void> => {
19
- await del(name);
20
- },
21
- };
10
+ export const getCacheKey = (application: JobApplication) =>
11
+ [application.job.id, application.flow.id, application.flow.version].join('/');
22
12
 
23
13
  export type ViewState = 'maximised' | 'minimised';
14
+ export const viewState = signal<ViewState>('maximised');
24
15
 
25
- type ChatbotStore = {
26
- currentApplication: (JobApplication & { startedAt: string }) | null;
27
- viewState: ViewState;
28
- startApplication: (application: JobApplication) => void;
29
- cancelCurrentApplication: () => void;
30
- setViewState: (newViewState: ViewState) => void;
16
+ export const inputHeight = signal(53);
17
+
18
+ export type StartedJobApplication = JobApplication & { data: ApplicationData };
19
+
20
+ type CurrentApplication =
21
+ | { state: 'idle'; application?: never }
22
+ | { state: 'loading'; application?: never }
23
+ | { state: 'loaded'; application: StartedJobApplication }
24
+ | { state: 'error'; application?: never; error: string };
25
+
26
+ const currentApplication = signal<CurrentApplication>({ state: 'idle' });
27
+
28
+ const updateApplicationData = async (updateFn: (data: ApplicationData) => ApplicationData) => {
29
+ const { state, application } = currentApplication.value;
30
+ if (state !== 'loaded') return;
31
+ invariant(application, 'No application to update');
32
+ const newData = updateFn(application.data);
33
+ const newApplication = { ...application, data: newData };
34
+ currentApplication.value = { state, application: newApplication };
35
+ await idb.setApplicationData({ application, data: newData });
31
36
  };
32
37
 
33
- export const useChatbotStore = create<ChatbotStore>((set, get) => ({
34
- currentApplication: null,
35
- viewState: 'maximised',
36
- startApplication: application => {
37
- // if the application is already running, just maximise it
38
- if (get().currentApplication?.job.id === application.job.id) return set({ viewState: 'maximised' });
39
- return set({ currentApplication: { ...application, startedAt: new Date().toISOString() }, viewState: 'maximised' });
38
+ export const application = {
39
+ current$: currentApplication,
40
+ start: async (application: JobApplication) => {
41
+ const data = (await idb.getApplicationData(application)) ?? createNewApplicationData(application);
42
+ batch(() => {
43
+ viewState.value = 'maximised';
44
+ currentApplication.value = { state: 'loaded', application: { ...application, data } };
45
+ });
46
+ data.isFinished = false;
47
+ idb.setApplicationData({ application, data });
48
+ },
49
+ cancel: () => {
50
+ currentApplication.value = { state: 'idle' };
40
51
  },
41
- cancelCurrentApplication: () => set({ currentApplication: null }),
42
- setViewState: viewState => set({ viewState }),
43
- }));
44
-
45
- /** Returns the current application or throws if no application has been set. */
46
- export const useCurrentJobApplication = () => {
47
- const application = useChatbotStore(s => s.currentApplication);
48
- if (!application) throw new Error('No current application found');
49
- return application;
52
+ markAsFinished: () => updateApplicationData(data => ({ ...data, isFinished: true })),
53
+ setCurrentNodeId: (currentNodeId: string) => updateApplicationData(data => ({ ...data, currentNodeId })),
54
+ restart: () => {
55
+ const { state, application } = currentApplication.value;
56
+ if (state !== 'loaded') throw new Error('Application cannot be restarted: not in valid state');
57
+ const data = createNewApplicationData(application);
58
+ currentApplication.value = { state, application: { ...application, data } };
59
+ idb.setApplicationData({ application, data });
60
+ },
61
+ addMessage: (message: ChatMessage, groupId?: string) => {
62
+ const newMessage = { ...message, groupId };
63
+ return updateApplicationData(data => ({ ...data, messages: [...data.messages, newMessage] }));
64
+ },
65
+ /** Removes from the last message backwards, all the messages that have the groupId passed, until it reaches one that doesn't */
66
+ removeLastGroupMessagesById: (groupId: string) => {
67
+ const { state, application } = currentApplication.value;
68
+ if (state !== 'loaded') throw new Error('Application cannot be restarted: not in valid state');
69
+
70
+ const messages = [...application.data.messages];
71
+ let i = messages.length - 1;
72
+ while (i >= 0 && messages[i] && messages[i]?.groupId === groupId) {
73
+ messages.pop();
74
+ i--;
75
+ }
76
+ application.data.messages = messages;
77
+ idb.setApplicationData({ application, data: application.data });
78
+ },
79
+ setSubmission: (fieldKey: string, submission: ApplicationSubmission) =>
80
+ updateApplicationData(data => ({ ...data, submissions: { ...data.submissions, [fieldKey]: submission } })),
81
+ setInput: (input: ChatInput | undefined) => updateApplicationData(data => ({ ...data, currentInput: input })),
50
82
  };
51
83
 
52
84
  export type MessageAuthor = 'bot' | 'user';
@@ -63,10 +95,10 @@ export type KeyToSubmissionMap = {
63
95
  [key: string]: ApplicationSubmission;
64
96
  };
65
97
 
66
- /** Local state saved for an application */
67
- export type ApplicationLocalState = {
98
+ /** Dynamic part of an application */
99
+ export type ApplicationData = {
68
100
  /** History of messages left in the chat */
69
- messages: ChatMessage[];
101
+ messages: (ChatMessage & { groupId?: string })[];
70
102
  submissions: KeyToSubmissionMap;
71
103
  currentNodeId: string;
72
104
  /** Needs to be separate because a node can have many inputs */
@@ -74,107 +106,9 @@ export type ApplicationLocalState = {
74
106
  isFinished: boolean;
75
107
  };
76
108
 
77
- type ApplicationsStore = {
78
- applicationsLocalState: Record<string, ApplicationLocalState>;
79
- getApplicationLocalState: (application: JobApplication) => ApplicationLocalState | undefined;
80
- updateApplicationLocalState: (params: { application: JobApplication; data: Partial<ApplicationLocalState> }) => void;
81
- updateApplicationCurrentNode: (params: { application: JobApplication; currentNodeId: string }) => void;
82
- resetApplicationState: (application: JobApplication) => void;
83
- updateSubmission: (params: { application: JobApplication; fieldKey: string; data: ApplicationSubmission }) => void;
84
- addMessage: (params: { application: JobApplication; data: ChatMessage }) => void;
85
- resetLocalState: () => void;
86
- setInput: (params: { application: JobApplication; data: ChatInput | null }) => void;
87
- resetInput: (params: { application: JobApplication }) => void;
88
- };
89
-
90
- const getOrCreateApplication = (params: { application: JobApplication }, state: ApplicationsStore) => {
91
- const existingApplication = state.applicationsLocalState[getCacheKey(params.application)];
92
- if (existingApplication) return existingApplication;
93
- const newApplication: ApplicationLocalState = {
94
- messages: [],
95
- submissions: {},
96
- currentNodeId: getHeadOrThrow(params.application.flow.nodes).id,
97
- isFinished: false,
98
- };
99
- state.applicationsLocalState[getCacheKey(params.application)] = newApplication;
100
- return newApplication;
101
- };
102
-
103
- export const getCacheKey = (application: JobApplication) =>
104
- [application.job.id, application.flow.id, application.flow.version].join('/');
105
-
106
- export const useLocalState = create<ApplicationsStore>()(
107
- persist(
108
- (set, get) => ({
109
- applicationsLocalState: {},
110
- getApplicationLocalState: application => get().applicationsLocalState[getCacheKey(application)],
111
- updateApplicationLocalState: params =>
112
- set(
113
- produce((state: ApplicationsStore) => {
114
- const existingApplication = state.applicationsLocalState[getCacheKey(params.application)];
115
- if (!existingApplication) throw new Error('Application not found');
116
- Object.assign(existingApplication, params.data);
117
- }),
118
- ),
119
- updateApplicationCurrentNode: params =>
120
- set(
121
- produce((state: ApplicationsStore) => {
122
- const application = getOrCreateApplication(params, state);
123
- application.currentNodeId = params.currentNodeId;
124
- }),
125
- ),
126
- resetApplicationState: application =>
127
- set(
128
- produce((state: ApplicationsStore) => {
129
- const cacheKey = getCacheKey(application);
130
- delete state.applicationsLocalState[cacheKey];
131
- }),
132
- ),
133
- updateSubmission: params =>
134
- set(
135
- produce((state: ApplicationsStore) => {
136
- const application = getOrCreateApplication(params, state);
137
- application.submissions[params.fieldKey] = params.data;
138
- }),
139
- ),
140
- addMessage: params =>
141
- set(
142
- produce((state: ApplicationsStore) => {
143
- const application = getOrCreateApplication(params, state);
144
- application.messages.push(params.data);
145
- state.applicationsLocalState[getCacheKey(params.application)] = application;
146
- }),
147
- ),
148
- resetLocalState: () => set({ applicationsLocalState: {} }),
149
- resetInput: params =>
150
- set(
151
- produce((state: ApplicationsStore) => {
152
- const application = getOrCreateApplication(params, state);
153
- application.currentInput = undefined;
154
- }),
155
- ),
156
- setInput: params =>
157
- set(
158
- produce((state: ApplicationsStore) => {
159
- const application = getOrCreateApplication(params, state);
160
- application.currentInput = params.data ?? undefined;
161
- }),
162
- ),
163
- }),
164
- {
165
- name: 'inploi-chatbot-application',
166
- storage: createJSONStorage(() => storage),
167
- // only store the jobApplications part of the state
168
- partialize: state => ({ applicationsLocalState: state.applicationsLocalState }),
169
- },
170
- ),
171
- );
172
-
173
- export const useApplicationLocalState = (application: JobApplication) =>
174
- useLocalState(s => s.applicationsLocalState[getCacheKey(application)]);
175
-
176
- export const useApplicationInput = (application: JobApplication) =>
177
- useLocalState(s => s.applicationsLocalState[getCacheKey(application)]?.currentInput);
178
-
179
- export const useApplicationSubmission = (application: JobApplication, key: string) =>
180
- useLocalState(s => s.applicationsLocalState[getCacheKey(application)]?.submissions[key]);
109
+ const createNewApplicationData = (application: JobApplication): ApplicationData => ({
110
+ messages: [],
111
+ submissions: {},
112
+ currentNodeId: getHeadOrThrow(application.flow.nodes).id,
113
+ isFinished: false,
114
+ });