AnyChat is a newly released Conversational Commerce Platform by AnyMind Group on 3 March 2022. With that said, today we are here to share some insights on how we build AnyChat from Front-End prospective.
- Some Backgrounds…
- Why Nx?
- Auth0 makes authentication and authorization easy and safe
- We love GraphQL
- Using GraphQL Code Generator
- Optimist UI with Apollo Client
Table of Contents
Some Backgrounds…
- Language: React + TypeScript
- Build System: Nx + CircleCI
- Authentication: Auth0
- Data Fetching: GraphQL + Apollo Client
- Router: React Location
- State Management: Zustand(Immer) + Apollo Client
- Form Management: React Hook Form + Vest (validation)
- Rich Text Editor: Draft.js
- Styling: tailwindcss + Sass
- Headless UI Library: Radix UI + Headless UI
- Testing: Jest + React Testing Library
- Documentation: Storybook
Why Nx?
As AnyChat needs to integrate with multiple third-party platforms like Auth0, LINE, Shopify and more to come (Instagram, WhatsApp etc..). Some of the platforms require to publish an isolated app for connections. To manage different apps in single codebase. Nx provides first-class monorepo support like caching, code sharing, code boundary and more. Also, it allows us to visualised what will be affected by its affected command
in commits to commits, which can prevent unexpected changes.
We have four apps so far, console
, login
, reset-password
and liff-app
.
Like auth-feature-login
, is only used by login
. auth-shared
is used by both reset-password
and login
. shared-ui
is used by all apps. In this way, we can setup a good code boundary and allows the developer to aware the relation of each libraries.
Dependency graph
Auth0 makes authentication and authorization easy and safe
Auth0 is an identity management platform which mainly provides identity authentication and authorization services.
It provides different kinds of SDK base on your stacks and languages. Auth0 is suitable for some of following scenarios:
- You would like your app could accept different login connections like Google, Facebook …etc
- You have many apps and you would like to centralized the user management and authorized application
- You prefer not spend too much effort on handling things such like login, logout, reset password…etc.
There are still many advantages like security and modern industry standard, the things we mentioned up there are just some of them.
Connect the project with Auth0
Let’s have a look how to connect with Auth0.
{
import { Auth0Provider } from '@auth0/auth0-react'
<Auth0Provider
domain='YOUR_APP_DOMAIN' // Get it from Auth0 console
clientId='YOUR_CLIENT_ID' // Get it from Auth0 console
redirectUri={window.location.origin}
>
...
</Auth0Provider>
}
import { useEffect } from 'react'
import { useAuth0 } from '@auth0/auth0-react'
const ProtectedRoute = () => {
const {
user,
isAuthenticated,
isLoading,
loginWithRedirect,
logout,
getIdTokenClaims,
} = useAuth0()
useEffect(() => {
if(!isAuthenticated) {
loginWithRedirect();
}
}, [isAuthenticated, loginWithRedirect])
...
}
1. On index.tsx
, wrap your root component with an Auth0Provider
that you can import from the auth0 react SDK.
domain
and clientId
: The values of these properties correspond to the “Domain” and “Client ID” values present under the “Settings” of the single-page application that you registered with Auth0.
redirectUri
: The URL to where you’d like to redirect your users after they authenticate with Auth0.
2. Create ProtectedRoute.tsx
, wrap the routes that need to be authenticated. Check the isAuthenticated
boolean flag, if the user is not authenticated, we will call the method loginWithRedirect
to redirect the user to login page.
Customize Login / Reset-Password Page on Auth0
To use customize version UI of login or reset-password, Auth0 allows us to upload a html file. To access the auth0 setting during runtime, there are some configurations need to be added inside our index.html
Login
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>AnyChat</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>AnyChat</title>
<script>
var config = JSON.parse(
decodeURIComponent(escape(window.atob('@@config@@')))
);
window.__AUTH0_CONFIG__ = config;
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>
The @@config@@
construct is a placeholder that will be replaced by the runtime.
The config object contains the details of auth0 settings that allows you to login a user.
Reset Password
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>AnyChat</title>
<script>
const config = {
email: '{{email | escape}}',
csrf_token: '{{csrf_token}}',
ticket: '{{ticket}}',
password_policy: '{{password_policy}}',
password_complexity_options: JSON.parse(
'{{password_complexity_options}}'
),
};
window.__RESET_PASSWORD_CONFIG__ = config;
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>
Similar to login, reset password will also get the config during runtime. We could use the config object to identify the user and make followup action base on it.
We love GraphQL
In AnyMind, most of our products use GraphQL for data fetching, of course AnyChat is not an exception. Like we mentioned above, we use Apollo Client to consume the graph. It also comes with different tools to make our life easier, like the devtools and code generator.
Using GraphQL Code Generator
The code generator can generate various of types, react hooks base on the schema, operations and configuration. It saves our time to write the boilerplate code and keep us in sync with the remote schema. Run graphql-codegen
codegen.yml
, then it will generate files we defined under the generates section.
overwrite: true
schema:
- {{graphql_remote_endpoint}}
- './src/graphql/_client/extend.graphql'
documents: "src/graphql/**"
generates:
src/generated/graphql.ts:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-react-apollo'
config:
enumsAsConst: true
skipTypeNameForRoot: true
immutableTypes: true
exportFragmentSpreadSubTypes: true
./graphql.schema.json:
plugins:
- 'introspection'
src/generated/possibleTypes.ts:
plugins:
- 'fragment-matcher'
Sample code generator configuration
Optimist UI with Apollo Client When a user tries to send a chat message, we wants to update the UI before the server response. To do so, we rely on using Apollo Cache and adding client-specific field to our schema.
Demo
export const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies: {
BotTextMessageEvent: {
fields: {
// This field is client only.
messageState: {
read(messageState: MessageState = 'SENT', { args }) {
return messageState;
},
},
},
},
ChatEvents: {
keyFields: ['chatId'],
merge(existing, incoming, { args, mergeObjects }) {
return mergeObjects(existing, incoming);
},
fields: {
chatEvents: {
merge(
existing: Reference[] = [],
incoming: Reference[] = [],
{ variables, readField, mergeObjects, cache }
) {
const merged: Reference[] = [];
const messageIdToIndex: Record =
Object.create(null);
existing.forEach((message) => {
const id = readField('id', message);
const messageState = readField(
'messageState',
message
);
if (id) {
if (
!Object.prototype.hasOwnProperty.call(
messageIdToIndex,
id
) &&
messageState !== 'WAIT_FOR_DISCARD'
) {
const count = merged.push(message);
messageIdToIndex[id] = count - 1;
}
}
});
const olderMessages: Reference[] = [];
incoming.forEach((message) => {
const id = readField('id', message);
if (id) {
const index = messageIdToIndex[id];
if (typeof index === 'number') {
merged[index] = mergeObjects(merged[index], message);
} else {
if (variables?.olderThan) {
olderMessages.push(message);
} else {
merged.push(message);
}
}
}
});
return [...olderMessages, ...merged];
},
},
},
},
},
}),
});
enum MessageState {
SENDING
SENT
FAIL
WAIT_FOR_DISCARD
}
type BotTextMessageEvent {
messageState: MessageState!
}
function useSendCMessage({ chatId }: { chatId?: string }) {
const [sendMessageMutation, { client }] = useChatMessageSendMutation();
const sendChatMessage = useCallback(
async (message = '') => {
const chatEventsRef = client.cache.identify({
chatId,
__typename: 'ChatEvents',
});
// Shape of optimistic response
const newMessage: BotTextBubbleEventFragment = {
id: randomId,
message,
messageState: MessageState.Sending,
__typename: 'BotTextMessageEvent',
};
// Write to cache
const newMessageRef = client.cache.writeFragment({
data: newMessage,
fragment: BotTextBubbleEventFragmentDoc,
fragmentName: 'BotTextBubbleEvent',
});
// Push to chatEvents
client.cache.modify({
id: chatEventsRef,
fields: {
chatEvents(existingChatEvents = [], { readField }) {
return [...existingChatEvents, newMessageRef];
},
},
});
try {
await sendMessageMutation({
variables: {
input: { message, chatId },
},
update: (cache, { data }) => {
if (data?.ChatMessageSend && newMessageRef) {
// Message sent succesfully
// Set the mock message state to wait for discard
// apollo merge policy will remove this optimistic response
cache.modify({
id: cache.identify(newMessageRef),
fields: {
messageState() {
return MessageState.WaitForDiscard;
},
},
});
// Set the response from BE to cache
cache.writeFragment({
id: chatEventsRef,
fragment: gql`
${ChatBubbleEventFragmentDoc}
fragment NewChatEvents on ChatEvents {
chatEvents {
...ChatBubbleEvent
}
}
`,
fragmentName: 'NewChatEvents',
data: {
chatEvents: [data.ChatMessageSend],
},
});
}
},
});
} catch (error) {
if (newMessageRef) {
// Update cache - mark as failed to send
client.cache.modify({
id: client.cache.identify(newMessageRef),
fields: {
messageState() {
return MessageState.Fail;
},
},
});
}
}
},
[chatId, sendMessageMutation, client]
);
...
}
export default useSendMessage;
1. On extend.graphql
, extend BotTextMesageEvent
, add a client side only field messageState:MessageState
2. Setup field policy on apllo.ts
- Default state of
messageState
is Sent - Create merge function on
chatEvents
. We will remove ifmessageState === 'WAIT_FOR_DISCARD'
. Also to keep single list of data, we will merge existing data and incoming data into one array.
3. On useSendMessage.ts
, we will handle the cache update during mutation.
- Add the new message to cache, set
messageState
toSending
- Execute
sendMessage
mutation - If message sent successfully, we will replace the optimistic message to the response from the server. Set
messageState
toWAIT_FOR_DISCARD
on the optimistic message, where it will be removed on Apollo field merge function. - If message sent unsuccessfully, simply update the optimistic message
messageState
toFail
Conclusion
By now, I hope you will have some basic understanding on how we build AnyChat and organize the code. If you wish to know more, leave us comment and hopefully we can cover more topics later. Thank you.