@clicktap/state 0.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.
package/.eslintrc.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "extends": [
3
+ "../../.eslintrc.json"
4
+ ],
5
+ "ignorePatterns": [
6
+ "!**/*",
7
+ "jest.config.ts",
8
+ "*.spec.ts"
9
+ ],
10
+ "overrides": [
11
+ {
12
+ "files": [
13
+ "*.ts",
14
+ "*.tsx",
15
+ "*.js",
16
+ "*.jsx"
17
+ ],
18
+ "rules": {}
19
+ },
20
+ {
21
+ "files": [
22
+ "*.ts",
23
+ "*.tsx"
24
+ ],
25
+ "rules": {},
26
+ "parserOptions": {
27
+ "project": [
28
+ "libs/state/tsconfig.lib.json"
29
+ ]
30
+ }
31
+ },
32
+ {
33
+ "files": [
34
+ "*.js",
35
+ "*.jsx"
36
+ ],
37
+ "rules": {}
38
+ }
39
+ ]
40
+ }
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # state
2
+
3
+ This library was generated with [Nx](https://nx.dev).
4
+
5
+ ## Building
6
+
7
+ Run `nx build state` to build the library.
8
+
9
+ ## Running unit tests
10
+
11
+ Run `nx test state` to execute the unit tests via [Jest](https://jestjs.io).
package/jest.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ /* eslint-disable */
2
+ export default {
3
+ displayName: 'state',
4
+ preset: '../../jest.preset.js',
5
+ transform: {
6
+ '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
7
+ },
8
+ moduleFileExtensions: ['ts', 'js', 'html'],
9
+ coverageDirectory: '../../coverage/libs/state',
10
+ };
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@clicktap/state",
3
+ "version": "0.1.0",
4
+ "type": "commonjs",
5
+ "dependencies": {
6
+ "cookie": "^0.5.0"
7
+ },
8
+ "devDependencies": {
9
+ "@types/cookie": "^0.5.1",
10
+ "@types/react": "18.0.28"
11
+ },
12
+ "peerDependencies": {
13
+ "next": "^13.4.6",
14
+ "react": "^18.2.0",
15
+ "xstate": "^4.38.1",
16
+ "@xstate/immer": "^0.3.3",
17
+ "@xstate/inspect": "^0.8.0",
18
+ "@xstate/react": "^3.2.2"
19
+ }
20
+ }
package/project.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "state",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "libs/state/src",
5
+ "projectType": "library",
6
+ "targets": {
7
+ "build": {
8
+ "executor": "@nx/js:tsc",
9
+ "outputs": ["{options.outputPath}"],
10
+ "options": {
11
+ "outputPath": "dist/libs/state",
12
+ "main": "libs/state/src/index.ts",
13
+ "tsConfig": "libs/state/tsconfig.lib.json",
14
+ "assets": ["libs/state/*.md"]
15
+ }
16
+ },
17
+ "lint": {
18
+ "executor": "@nx/linter:eslint",
19
+ "outputs": ["{options.outputFile}"],
20
+ "options": {
21
+ "lintFilePatterns": ["libs/state/**/*.ts"]
22
+ }
23
+ },
24
+ "test": {
25
+ "executor": "@nx/jest:jest",
26
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
27
+ "options": {
28
+ "jestConfig": "libs/state/jest.config.ts",
29
+ "passWithNoTests": true
30
+ },
31
+ "configurations": {
32
+ "ci": {
33
+ "ci": true,
34
+ "codeCoverage": true
35
+ }
36
+ }
37
+ }
38
+ },
39
+ "tags": []
40
+ }
@@ -0,0 +1,97 @@
1
+ import { createContext, useContext } from 'react';
2
+ import { interpret } from 'xstate';
3
+ import type { InterpreterFrom } from 'xstate';
4
+ import cookie from 'cookie';
5
+ import type { ReactNode } from 'react';
6
+ import type { NextPageContext } from 'next';
7
+ import { authMachine } from './auth';
8
+
9
+ // removing dependencies on next, this should work regardless of platform
10
+ // import type { NextPageContext } from 'next';
11
+ // import { useRouter } from 'next/router';
12
+
13
+ export const AuthContext = createContext(
14
+ {} as InterpreterFrom<typeof authMachine>
15
+ );
16
+
17
+ export const useAuth = () => {
18
+ // console.log('useAuth');
19
+ // console.log(useContext(AuthContext).getSnapshot().context);
20
+ return useContext(AuthContext);
21
+ };
22
+
23
+ export const useUser = () => {
24
+ return useContext(AuthContext).getSnapshot().context.user;
25
+ };
26
+
27
+ type AuthOptions = {
28
+ devTools: boolean;
29
+ endpoints: {
30
+ login: string;
31
+ logout: string;
32
+ refresh: string;
33
+ ssrRefresh: string;
34
+ };
35
+ };
36
+
37
+ /**
38
+ * this will run on the server side as part of app.getInitialProps
39
+ * @todo is it possible to share state from client to server? headers? cookies?
40
+ */
41
+ export const getAuth = async (
42
+ context: NextPageContext,
43
+ options: AuthOptions
44
+ // eslint-disable-next-line @typescript-eslint/require-await
45
+ ) => {
46
+ if (typeof window !== 'undefined') {
47
+ // eslint-disable-next-line no-console
48
+ console.warn(
49
+ 'App.getInitialProps::getAuth should not be run on the frontend. You are probably missing getServerSideProps in your page.'
50
+ );
51
+ return interpret(authMachine, { devTools: options.devTools }).start();
52
+ }
53
+
54
+ const cookies = cookie.parse(
55
+ context?.req?.headers && context.req.headers.cookie
56
+ ? context.req.headers.cookie
57
+ : ''
58
+ );
59
+ if (typeof cookies['refresh_token'] !== 'undefined') {
60
+ const authContext = {
61
+ ...authMachine.initialState.context,
62
+ refreshToken: cookies['refresh_token'],
63
+ endpoints: { ...options.endpoints },
64
+ };
65
+ return interpret(authMachine.withContext(authContext), {
66
+ devTools: options.devTools,
67
+ }).start();
68
+ }
69
+
70
+ return interpret(authMachine, { devTools: options.devTools }).start();
71
+ };
72
+
73
+ type Props = {
74
+ children: ReactNode;
75
+ service: InterpreterFrom<typeof authMachine>;
76
+ // options: {
77
+ // devTools: boolean;
78
+ // };
79
+ // state: StateConfig<AuthMachineContext, AuthMachineEvents>;
80
+ };
81
+
82
+ export function AuthProvider({
83
+ children,
84
+ service,
85
+ }: // options = { devTools: false },
86
+ // state
87
+ Props) {
88
+ // console.log('AuthProvider::state');
89
+ // console.log(state.context);
90
+ // let authService = interpret(authMachine, { devTools: options.devTools }).start(state);
91
+ // console.log('AuthProvider::authService');
92
+ // console.log(authService.getSnapshot().context);
93
+
94
+ return (
95
+ <AuthContext.Provider value={service}>{children}</AuthContext.Provider>
96
+ );
97
+ }
@@ -0,0 +1,7 @@
1
+ import { authMachine } from './auth';
2
+
3
+ describe('state', () => {
4
+ it('should work', () => {
5
+ expect(authMachine).toEqual('auth');
6
+ });
7
+ });
@@ -0,0 +1,408 @@
1
+ import { createMachine } from 'xstate';
2
+ import { assign } from '@xstate/immer';
3
+ import type { DoneInvokeEvent } from 'xstate';
4
+
5
+ export interface AuthMachineContext {
6
+ user: {
7
+ email: string;
8
+ username: string;
9
+ firstName: string;
10
+ lastName: string;
11
+ birthdate?: Date;
12
+ gender?: string /** @todo use enum? */;
13
+ createdAt: Date;
14
+ updatedAt: Date;
15
+ role?: string /** @todo use enum? */;
16
+ locked: boolean;
17
+ affiliateId: string;
18
+ stripeId: string;
19
+ } | null;
20
+ accessToken: string;
21
+ refreshToken: string;
22
+ ignoreRefreshToken: boolean;
23
+ endpoints: {
24
+ login: string;
25
+ logout: string;
26
+ refresh: string;
27
+ ssrRefresh: string;
28
+ };
29
+ }
30
+
31
+ export interface RefreshTokenEvent {
32
+ type: 'REFRESH_TOKEN';
33
+ accessToken: AuthMachineContext['accessToken'];
34
+ }
35
+
36
+ export interface LoginEvent {
37
+ type: 'LOGIN';
38
+ username: string;
39
+ password: string;
40
+ // grant_type: 'password';
41
+ // client_id: string;
42
+ // client_secret: string;
43
+ // scope: 'default';
44
+ }
45
+
46
+ export interface LogoutEvent {
47
+ type: 'LOGOUT';
48
+ }
49
+
50
+ export interface AuthenticateSuccessEvent {
51
+ type: 'AUTHENTICATE_SUCCESS';
52
+ user: AuthMachineContext['user'];
53
+ accessToken: AuthMachineContext['accessToken'];
54
+ }
55
+
56
+ export interface RefreshTokenSuccessEvent {
57
+ type: 'REFRESH_TOKEN_SUCCESS';
58
+ accessToken: AuthMachineContext['accessToken'];
59
+ }
60
+
61
+ // type AuthEvents = RefreshTokenEvent | AnotherEvent;
62
+ export type AuthMachineEvents =
63
+ | RefreshTokenEvent
64
+ | LoginEvent
65
+ | LogoutEvent
66
+ | DoneInvokeEvent<AuthenticateSuccessEvent>
67
+ | DoneInvokeEvent<RefreshTokenSuccessEvent>;
68
+
69
+ type RefreshTokenResponse = {
70
+ accessToken?: string;
71
+ };
72
+ type AuthenticateResponse = {
73
+ accessToken?: string;
74
+ message?: string;
75
+ success: boolean;
76
+ user?: AuthenticateSuccessEvent['user'];
77
+ };
78
+ async function request<T>(url: string, options: object): Promise<T> {
79
+ const response = await fetch(url, options);
80
+ if (response.status === 200) {
81
+ return response.json() as Promise<T>;
82
+ }
83
+
84
+ return Promise.resolve({
85
+ message: response.statusText,
86
+ success: false,
87
+ }) as Promise<T>;
88
+ }
89
+
90
+ export const authMachine =
91
+ /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgOjVsAdugJYDGyJBUAxBAPYFjbEEBudA1k3jj4SeUpQELdoOIMA2gAYAujNmJQABzqxiJBkpAAPRAEZ9Adn3YAbABYATAGYr1gKwWLDoxYA0IAJ6JbATmwADkCXPyszK0CzPyijAF84zx5cDEx+MgoWGjAAJxy6HOxlABsKADMCgFsUrBq0ogyhETY6cSk5BW1VdU0CbT0EfWkncysHYelba0DXTx8EBzNTCxDXPzMbaTCjQISk1OxUAj4G8SpaBiZRTm4Do5OBTKpmsUz2+TkutQ0JPqRdRAAWkMAUiNkCxjMO02+gsNjmvkCVmw+hsRnRjiM0hMexAyXuqXSZ2yeQKRVK6AqOWq+OOhNOT2E1zaBAUnX+3R+Wn+A2R0RsmwhRjMUKsRgcSIRCCR2Fs6MCYSmDn0wVxyRyYDKGtgmCyF0YzBaXDq2A1WrguuezLerI6nw5316-UQRhi2BiQ2kk2VkylUSCfnB9iWwSxYrVBzN2stJPyhRK5SqJqjFqyL1aNrZ9pUjt+zoQFn0Zmw0gs6Ks0n0gZMMSlwOx2ELTj80jMTisYvCEdqxToUBgEAA8hhqAAZQcAcQAkgA5dk5np5nm+KElsyBcWWQzhMZSizSQLYVzbhyB8F+Pz6bs4Xv9yBTghjyeDgCqABV5yBOU7l9KjDYghmQJhgVPwHArKw6yrYswKWf8HE2cYJWvbBbwHB9qB0WB0AobgynQXIAAozC9L0AEpqGSND71tRQHUXblQAGKJkVdZwqwlMIokCKCjGwYUYThf8SLRCwEkSEACDoCA4G0HgvgYv4mKBIs+LBIUoUCQT4W8IFIgsRtFmMMskMvKwUIeRosgUrklIBQZKwA1soWccJXU2PwpVEktjBmdcIhiZwUIJfAGSEGyf2UhBAQM0N1gPJYoVbHYoJCHyTCiLYYnXcMJPVTVo2s+jbPzawAkMSZwmxBUJX0KUxUPOV3LYy8tjEvKDmoocMAipcopsAJTzRNY0VhWEpVcaQSy0sVxjGFs0RQrqH16xj7MMOFGxWRYBXBRYeN0hA3XsINnBCfcLyvcSgA */
92
+ createMachine<AuthMachineContext, AuthMachineEvents>(
93
+ {
94
+ context: {
95
+ // id: '',
96
+ user: null,
97
+ accessToken: '',
98
+ refreshToken: '', // will only be on the request from the server side for initial check, does not get passed to the client
99
+ /**
100
+ * If unauthenticating fails, we need a way to ignore the refresh token on subsequent access token refresh.
101
+ * This would be set to true done/error of unauthenticate service, and false on done of authenticate service
102
+ */
103
+ ignoreRefreshToken: false,
104
+ endpoints: {
105
+ login: '',
106
+ logout: '',
107
+ refresh: '',
108
+ ssrRefresh: '',
109
+ },
110
+ },
111
+ predictableActionArguments: true,
112
+ id: 'auth',
113
+ initial: 'refreshing',
114
+ states: {
115
+ authenticating: {
116
+ invoke: {
117
+ src: 'authenticate',
118
+ onDone: [
119
+ {
120
+ actions: 'setUserContext',
121
+ target: 'loggedIn',
122
+ },
123
+ ],
124
+ onError: [
125
+ {
126
+ target: 'loggedOut',
127
+ },
128
+ ],
129
+ },
130
+ },
131
+ unauthenticating: {
132
+ invoke: {
133
+ src: 'unauthenticate',
134
+ onDone: [
135
+ {
136
+ actions: 'unsetUserContext',
137
+ target: 'loggedOut',
138
+ },
139
+ ],
140
+ onError: [
141
+ {
142
+ actions: ['unsetUserContext', 'setIgnoreRefreshToken'],
143
+ target: 'loggedOut',
144
+ },
145
+ ],
146
+ },
147
+ },
148
+ refreshing: {
149
+ invoke: {
150
+ src: 'refreshAccessToken',
151
+ onDone: [
152
+ {
153
+ actions: ['unsetRefreshToken', 'setAccessToken'],
154
+ target: 'loggedIn',
155
+ },
156
+ ],
157
+ onError: [
158
+ {
159
+ actions: 'unsetRefreshToken',
160
+ target: 'unauthenticating',
161
+ },
162
+ ],
163
+ },
164
+ },
165
+ loggedOut: {
166
+ on: {
167
+ LOGIN: {
168
+ target: 'authenticating',
169
+ },
170
+ },
171
+ },
172
+ loggedIn: {
173
+ after: {
174
+ '60000': {
175
+ cond: () => typeof window !== 'undefined',
176
+ target: 'refreshing',
177
+ },
178
+ },
179
+ // entry: ['unsetUserContext'],
180
+ on: {
181
+ LOGOUT: {
182
+ target: 'unauthenticating',
183
+ },
184
+ },
185
+ },
186
+ },
187
+ },
188
+ {
189
+ actions: {
190
+ // setAccessToken: assign<UserContext, RefreshTokenEvent>((context, event) => {
191
+ // context.accessToken = event.accessToken;
192
+ // }),
193
+ setUserContext: assign(
194
+ (
195
+ context: AuthMachineContext,
196
+ event: DoneInvokeEvent<AuthenticateSuccessEvent>
197
+ ) => {
198
+ // event.type - done.invoke.auth.authenticating:invocation[0]
199
+ if (event.data.type !== 'AUTHENTICATE_SUCCESS') return;
200
+
201
+ // context.user = event.data.user;
202
+ context.accessToken = event.data.accessToken;
203
+ context.ignoreRefreshToken = false;
204
+ }
205
+ ),
206
+ unsetUserContext: assign((context) => {
207
+ Object.assign(context, authMachine.initialState.context);
208
+ // context = authMachine.initialState.context;
209
+ }),
210
+ setAccessToken: assign(
211
+ (
212
+ context: AuthMachineContext,
213
+ event: DoneInvokeEvent<RefreshTokenSuccessEvent>
214
+ ) => {
215
+ if (event.data.type !== 'REFRESH_TOKEN_SUCCESS') return; // is this really necessary?
216
+
217
+ context.accessToken = event.data.accessToken;
218
+ context.ignoreRefreshToken = false;
219
+ }
220
+ ),
221
+ unsetRefreshToken: assign(
222
+ (
223
+ context: AuthMachineContext,
224
+ event: DoneInvokeEvent<RefreshTokenSuccessEvent>
225
+ ) => {
226
+ if (event.data.type !== 'REFRESH_TOKEN_SUCCESS') return; // is this really necessary?
227
+
228
+ context.refreshToken = '';
229
+ }
230
+ ),
231
+ setIgnoreRefreshToken: assign((context) => {
232
+ context.ignoreRefreshToken = true;
233
+ }),
234
+ },
235
+ services: {
236
+ refreshAccessToken: (context) => async () => {
237
+ // eslint-disable-next-line no-console
238
+ // console.log(
239
+ // 'refresh access token (%s)',
240
+ // typeof window === 'undefined' ? 'server' : 'client'
241
+ // );
242
+ // eslint-disable-next-line no-console
243
+ // console.log('--------------------');
244
+
245
+ let response: RefreshTokenResponse;
246
+ if (typeof window === 'undefined') {
247
+ // server
248
+ // eslint-disable-next-line no-console
249
+ // console.log('in server refreshAccessToken');
250
+ // eslint-disable-next-line no-console
251
+ // console.log(context.refreshToken);
252
+ if (context.refreshToken === '') throw new Error('Unauthorized.');
253
+
254
+ try {
255
+ response = await request<RefreshTokenResponse>(
256
+ 'http://nginx:5210/auth/refresh',
257
+ {
258
+ method: 'POST',
259
+ mode: 'cors',
260
+ credentials: 'include',
261
+ headers: {
262
+ Cookie: `refresh_token=${context.refreshToken}`,
263
+ 'Content-Type': 'application/json',
264
+ },
265
+ body: JSON.stringify({
266
+ grant_type: 'refresh_token',
267
+ // client_id: 'default',
268
+ // client_secret: 'Password123!',
269
+ // scope: 'default',
270
+ }).toString(),
271
+ }
272
+ );
273
+
274
+ const data = response;
275
+
276
+ if (typeof data.accessToken === 'undefined') {
277
+ throw new Error('Unauthorized.');
278
+ }
279
+
280
+ return {
281
+ type: 'REFRESH_TOKEN_SUCCESS',
282
+ accessToken: data.accessToken,
283
+ } as RefreshTokenSuccessEvent;
284
+ } catch (err) {
285
+ // eslint-disable-next-line no-console
286
+ // console.log(err);
287
+ throw new Error('Could not complete refresh request (server)');
288
+ }
289
+ } else {
290
+ // client
291
+ response = await request<RefreshTokenResponse>(
292
+ 'https://middleware-clicktap.local-rmgmedia.com/auth/refresh',
293
+ {
294
+ method: 'POST',
295
+ mode: 'cors',
296
+ credentials: 'include',
297
+ headers: {
298
+ 'Content-Type': 'application/json',
299
+ },
300
+ body: JSON.stringify({
301
+ grant_type: 'refresh_token',
302
+ // client_id: 'default',
303
+ // client_secret: 'Password123!',
304
+ // scope: 'default',
305
+ }).toString(),
306
+ }
307
+ );
308
+
309
+ const data = response;
310
+
311
+ if (typeof data.accessToken === 'undefined') {
312
+ throw new Error('Unauthorized.');
313
+ }
314
+
315
+ return {
316
+ type: 'REFRESH_TOKEN_SUCCESS',
317
+ accessToken: data.accessToken,
318
+ };
319
+ }
320
+ },
321
+ authenticate: (context, event) => async () => {
322
+ const e = event as LoginEvent; // workaround to typescript not allowing casting in parameter
323
+ if (e.type !== 'LOGIN') {
324
+ throw new Error(
325
+ `Authenticate can only be called for "LOGIN" event type. Event caller: ${String(
326
+ e.type
327
+ )}`
328
+ );
329
+ }
330
+
331
+ const response = await request<AuthenticateResponse>(
332
+ 'https://middleware-clicktap.local-rmgmedia.com/auth/login',
333
+ {
334
+ method: 'POST',
335
+ mode: 'cors',
336
+ credentials: 'include',
337
+ headers: {
338
+ 'Content-Type': 'application/json',
339
+ },
340
+ body: JSON.stringify({
341
+ username: e.username,
342
+ password: e.password,
343
+ // grant_type: 'password',
344
+ // client_id: 'default',
345
+ // client_secret: 'Password123!',
346
+ // scope: 'default',
347
+ }).toString(),
348
+ }
349
+ );
350
+ /** @todo do we need to handle different status codes here...like what if the service is down? */
351
+ const data = response;
352
+
353
+ if (typeof data.accessToken === 'undefined') {
354
+ throw new Error(
355
+ data.message?.toString() ?? 'Sign in failed. Please try again.'
356
+ );
357
+ }
358
+
359
+ return {
360
+ type: 'AUTHENTICATE_SUCCESS',
361
+ // user: data.user,
362
+ accessToken: data.accessToken,
363
+ } as AuthenticateSuccessEvent;
364
+ // /** @todo how to call immer assign from inside invoke here to update context? */
365
+
366
+ // console.log(data);
367
+ // console.log(event);
368
+ },
369
+ unauthenticate: (context) => async () => {
370
+ // const response = await fetch(
371
+ await fetch(
372
+ 'https://middleware-clicktap.local-rmgmedia.com/auth/logout',
373
+ {
374
+ method: 'POST',
375
+ mode: 'cors',
376
+ credentials: 'include',
377
+ // headers: {
378
+ // 'Access-Control-Allow-Origin': 'https://middleware-clicktap.local-rmgmedia.com',
379
+ // 'Access-Control-Allow-Credentials': 'true',
380
+ // },
381
+ }
382
+ );
383
+ /** @todo do we need to handle different status codes here...like what if the service is down? */
384
+ // const data = await response.json();
385
+
386
+ return { type: 'UNAUTHENTICATE' };
387
+
388
+ // if (!data.success) {
389
+ // throw new Error(data.message ?? 'Sign in failed. Please try again.');
390
+ // }
391
+
392
+ // return {
393
+ // type: 'AUTHENTICATE_SUCCESS',
394
+ // user: data.user,
395
+ // accessToken: data.accessToken,
396
+ // };
397
+ },
398
+ },
399
+ // doesn't make sense in the case where you have an access token that is about to expire
400
+ // and the loggedIn state won't fire another refresh for 15 min
401
+ //
402
+ // guards: {
403
+ // checkIfLoggedIn: (context, _event) => {
404
+ // if (context.user) return true;
405
+ // },
406
+ // },
407
+ }
408
+ );
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './auth/auth';
2
+ export * from './auth/AuthProvider';
3
+ export * from './toast/toast';
4
+ export * from './toast/ToastProvider';
5
+ export * from './toast/timer';
@@ -0,0 +1,44 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type { InterpreterFrom } from 'xstate';
3
+ import type { ReactNode } from 'react';
4
+ import { toastMachine } from './toast';
5
+
6
+ export const ToastContext = createContext(
7
+ {} as InterpreterFrom<typeof toastMachine>
8
+ );
9
+
10
+ export const useToast = () => {
11
+ return useContext(ToastContext);
12
+ };
13
+
14
+ type Props = {
15
+ children: ReactNode;
16
+ // options: {
17
+ // devTools: boolean;
18
+ // };
19
+ // state: StateConfig<ToastMachineContext, ToastMachineEvents>;
20
+ // setState: Dispatch<
21
+ // SetStateAction<StateConfig<ToastMachineContext, ToastMachineEvents>>
22
+ // >;
23
+ service: InterpreterFrom<typeof toastMachine>;
24
+ };
25
+
26
+ export function ToastProvider({
27
+ children,
28
+ service,
29
+ }: // options = { devTools: false },
30
+ // state,
31
+ // setState,
32
+ Props) {
33
+ // const toastService = interpret(toastMachine, {
34
+ // devTools: options.devTools,
35
+ // }).start(state);
36
+
37
+ // toastService.subscribe((s) => {
38
+ // setState(s);
39
+ // });
40
+
41
+ return (
42
+ <ToastContext.Provider value={service}>{children}</ToastContext.Provider>
43
+ );
44
+ }
@@ -0,0 +1,109 @@
1
+ import { createMachine } from 'xstate';
2
+ import { assign } from '@xstate/immer';
3
+
4
+ export interface TimerContext {
5
+ // The elapsed time (in ms)
6
+ elapsed: number;
7
+ // The maximum time (in ms)
8
+ duration: number;
9
+ // The interval to send TICK events (in ms)
10
+ interval: number;
11
+ // The interval timer
12
+ intervalId: NodeJS.Timer | null;
13
+ }
14
+
15
+ export type TimerEvents =
16
+ | {
17
+ // User intent to start the timer
18
+ type: 'START_TIMER';
19
+ }
20
+ | {
21
+ // User intent to resume the timer
22
+ type: 'RESUME_TIMER';
23
+ }
24
+ | {
25
+ // User intent to pause the timer
26
+ type: 'PAUSE_TIMER';
27
+ }
28
+ | {
29
+ // The TICK event sent by the spawned interval service
30
+ type: 'TICK';
31
+ }
32
+ | {
33
+ // User intent to update the duration
34
+ type: 'DURATION.UPDATE';
35
+ value: number;
36
+ }
37
+ | {
38
+ // User intent to reset the elapsed time to 0
39
+ type: 'RESET_TIMER';
40
+ };
41
+
42
+ export const timerMachine =
43
+ /** @xstate-layout N4IgpgJg5mDOIC5QBcCWBbMAnAdABwEMBXWSAYgCUBRAZQFUBZKgfQBUBJJigbQAYBdRKDwB7WKjQiAdkJAAPRAFoAjADYATDmUBOXQA5tAZmV69AFgDsq1QBoQAT0SHehnAFYAvh7tpMuLERSUqhSUGQcAMIA0nyCSCCi4pIy8QoIJqo4qnoWbnaOCGZmmaq8Zbyqyi5uym6qXj4Y2DgBQSFhAAoAgnQ0LBxcsbKJEqjSsmlVbjimFmZ6agZmbsbq+U4u7g0gvs2twaFkQ-EjyRNObrxZOXkOiGbKFjjq5WbqFurFvNpmXt4gUhEEDgsl2WGGYlG41SSnUVS0um0BmMpks1nW6Tcmk8-zB+GIpAgEKSYxSoDSvAxn142zx+3axKhZPkiEpdwQhjmW1xTVwAGMROg8AAbMDISCMs4whBsgpuOp-DxAA */
44
+ createMachine<TimerContext, TimerEvents>({
45
+ id: 'timer',
46
+ initial: 'running',
47
+ context: {
48
+ elapsed: 0,
49
+ duration: 3000,
50
+ interval: 100,
51
+ intervalId: null,
52
+ },
53
+ states: {
54
+ paused: {
55
+ on: {
56
+ // START_TIMER: {
57
+ // target: 'running',
58
+ // cond: (context) => context.elapsed < context.duration,
59
+ // },
60
+ RESUME_TIMER: {
61
+ target: 'running',
62
+ cond: (context) => context.elapsed < context.duration,
63
+ },
64
+ },
65
+ },
66
+ running: {
67
+ invoke: {
68
+ src: (context) => (send) => {
69
+ // eslint-disable-next-line no-console
70
+ // console.log('context.interval: ', context.interval);
71
+ const interval = setInterval(() => {
72
+ send('TICK');
73
+ }, context.interval);
74
+ return () => {
75
+ clearInterval(interval);
76
+ };
77
+ },
78
+ },
79
+ always: [
80
+ {
81
+ target: 'completed',
82
+ cond: (context) => {
83
+ return context.elapsed >= context.duration;
84
+ },
85
+ },
86
+ ],
87
+ on: {
88
+ TICK: {
89
+ actions: assign((context, event) => {
90
+ if (event.type !== 'TICK') return;
91
+ context.elapsed += context.interval;
92
+ // eslint-disable-next-line no-console
93
+ // console.log(
94
+ // 'elapsed: %d | interval: %d',
95
+ // context.elapsed,
96
+ // context.interval
97
+ // );
98
+ }),
99
+ },
100
+ PAUSE_TIMER: { target: 'paused' },
101
+ },
102
+ },
103
+ completed: {
104
+ type: 'final',
105
+ },
106
+ },
107
+ });
108
+
109
+ export default timerMachine;
@@ -0,0 +1,7 @@
1
+ import { toastMachine } from './toast';
2
+
3
+ describe('state', () => {
4
+ it('should work', () => {
5
+ expect(toastMachine).toEqual('toast');
6
+ });
7
+ });
@@ -0,0 +1,222 @@
1
+ import { createMachine, sendTo } from 'xstate';
2
+ import { assign } from '@xstate/immer';
3
+ import { cloneElement, ReactNode } from 'react';
4
+ import crypto from 'crypto';
5
+ import type { ReactElement } from 'react';
6
+ import { timerMachine } from './timer';
7
+
8
+ // circular reference, don't want library depending on UI component specific to one app
9
+ // import { Notification } from "@waytrade/components/app/Toast/Notification";
10
+
11
+ /** @todo figure out TS here */
12
+ export type ToastItem = {
13
+ id: string | null;
14
+ duration: number;
15
+ element: ReactNode;
16
+ };
17
+
18
+ export interface ToastMachineContext {
19
+ /** @todo create proper type for Array items */
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ items: Array<ToastItem>;
22
+ order: 'asc' | 'desc';
23
+ duration: number;
24
+ activeItem: string | null;
25
+ }
26
+
27
+ export interface AddItemEvent {
28
+ type: 'ADD_ITEM';
29
+ item: ReactElement;
30
+ duration?: number;
31
+ }
32
+
33
+ export interface RemoveItemEvent {
34
+ type: 'REMOVE_ITEM';
35
+ }
36
+
37
+ export interface SetIdleEvent {
38
+ type: 'SET_IDLE';
39
+ }
40
+
41
+ export interface SetActiveEvent {
42
+ type: 'SET_ACTIVE';
43
+ }
44
+
45
+ export interface ResumeTimerEvent {
46
+ type: 'RESUME_TIMER';
47
+ }
48
+
49
+ export interface PauseTimerEvent {
50
+ type: 'PAUSE_TIMER';
51
+ }
52
+
53
+ export interface TimeoutEvent {
54
+ type: 'xstate.after(ITEM_TIMEOUT)#toast.active';
55
+ }
56
+
57
+ export interface TimerDoneEvent {
58
+ type: 'done.invoke.timer';
59
+ }
60
+
61
+ export type ToastMachineEvents =
62
+ | AddItemEvent
63
+ | RemoveItemEvent
64
+ | SetIdleEvent
65
+ | SetActiveEvent
66
+ | TimeoutEvent
67
+ | ResumeTimerEvent
68
+ | PauseTimerEvent
69
+ | TimerDoneEvent;
70
+
71
+ export const toastMachine =
72
+ /** @xstate-layout N4IgpgJg5mDOIC5QBcD2BDWyB0BLCANmAMQCCAIuQPoCSAKgKICyA2gAwC6ioADqrLmS5UAO24gAHogCMAZgCs2AGzS2SgBxKA7AE4lO2ToAsAGhABPRAFoj6gL52zaTDnQBjIQDcSEUWDwinqgA1v5CALZgAE7sXEggfAJCouJSCEpsOspaOQay0kqyAExKZpYIReps2LY6mmxG+kUKWkYOThhY2O5eJBTU9Myx4omCwmLxabKF2NKN8kpFbPls8gpl1vLqykXyasaNhdJFWu0gzl09uN7EAErMAPIAagy0jKycI-xjKZOIi9sDPI9EZgYV1LItBsEIYlNg9NIdGxKki1jkzhdXB5riQAMoMOi0cgAGQYw3io2SE1AaTkWS0RQOSKWEK0c2hzWkNUWJy0Skah1sGM6WN6dwYuIAqkxXnQaDLbuTeN8qakZPIuXylhktAppgUitDilpsJpQeo9KsVOoCsKXN1sTcAAqkSX4qhyhVKhIq8ZqirSbaZeTyLRbVnqVqyaHSaQm45mtirKo6Eo6O2XCAQXAiKDEfGEmgksmfCm+340xC2bCh3XGrTLfR8+TQqxFJbYTIoow6HStBshjOuLM5vMFqikADCcpe3spfr+MKMmsZbDmFvU6jBRtW8I3IcMBT7Q+wUTA4VQnlH+YJRNJc-L1MkiHURiKps0DIWKhyIZjehqfc1h0I8GyUE8zwvK9cxvQkpxnEs4mVJIF0rBBU1kWZVw1WtgSMUwLHVIxsEqIxjVDIF5CFM4RFQCA4HETEvhQitnwQKxYxNOoDFjbQ2RtfRW1kfJO1THjA1sVRZBPfAiGYn4nzSKx9D3cS+MDI9-zhWot2A0C1BPK5vHk1VF1kIwuOZPICmKYEtMAuoDxAptlkMkdcxM1C2MMYjKhKXUlH5DUimkVsijImsxMMcLpjDUMIPPS9R081i0i3OE10afI42XcKW0ImFgRrYE5FBWx1F2cyHAcIA */
73
+ createMachine<ToastMachineContext, ToastMachineEvents>(
74
+ {
75
+ context: { items: [], order: 'desc', duration: 0, activeItem: null },
76
+ predictableActionArguments: true,
77
+ id: 'toast',
78
+ initial: 'idle',
79
+ states: {
80
+ idle: {
81
+ on: {
82
+ ADD_ITEM: {
83
+ target: 'adding',
84
+ },
85
+ },
86
+ },
87
+ active: {
88
+ invoke: {
89
+ id: 'timer',
90
+ src: timerMachine,
91
+ onDone: {
92
+ target: 'removing',
93
+ cond: 'itemHasTimeout',
94
+ },
95
+ data: Object.assign(timerMachine.context, {
96
+ duration: (context: ToastMachineContext) =>
97
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return
98
+ context.items[context.items.length - 1].duration ??
99
+ context.duration,
100
+ }),
101
+ },
102
+ on: {
103
+ ADD_ITEM: {
104
+ // actions: sendTo('timer', 'PAUSE_TIMER'),
105
+ target: 'adding',
106
+ },
107
+ REMOVE_ITEM: {
108
+ target: 'removing',
109
+ },
110
+ SET_IDLE: {
111
+ target: 'idle',
112
+ },
113
+ RESUME_TIMER: {
114
+ actions: sendTo('timer', 'RESUME_TIMER'),
115
+ // cond: (context) => context.duration > 0,
116
+ },
117
+ PAUSE_TIMER: {
118
+ actions: sendTo('timer', 'PAUSE_TIMER'),
119
+ // cond: (context) => context.duration > 0,
120
+ },
121
+ },
122
+ // after: {
123
+ // ITEM_TIMEOUT: {
124
+ // target: 'removing',
125
+ // cond: 'itemHasTimeout',
126
+ // },
127
+ // },
128
+ },
129
+ adding: {
130
+ entry: ['addItem'],
131
+ on: {
132
+ SET_IDLE: {
133
+ target: 'idle',
134
+ },
135
+ SET_ACTIVE: {
136
+ target: 'active',
137
+ },
138
+ },
139
+ },
140
+ removing: {
141
+ entry: 'removeItem',
142
+ on: {
143
+ SET_IDLE: {
144
+ target: 'idle',
145
+ },
146
+ SET_ACTIVE: {
147
+ target: 'active',
148
+ },
149
+ },
150
+ },
151
+ },
152
+ },
153
+ {
154
+ actions: {
155
+ addItem: assign((context, event) => {
156
+ if (event.type !== 'ADD_ITEM') return;
157
+
158
+ const id = `notification-${crypto.randomBytes(16).toString('hex')}`;
159
+ const duration = event.duration ?? context.duration;
160
+
161
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
162
+ const notification = cloneElement(event.item, {
163
+ duration,
164
+ id,
165
+ key: event.item.key ?? id,
166
+ ...event.item.props,
167
+ });
168
+ const item = {
169
+ duration,
170
+ id,
171
+ element: notification,
172
+ };
173
+
174
+ if (context.order === 'desc') {
175
+ context.items.push(item);
176
+ } else {
177
+ context.items.unshift(item);
178
+ }
179
+ context.activeItem = id;
180
+ }),
181
+ removeItem: assign((context, event) => {
182
+ if (
183
+ event.type !== 'REMOVE_ITEM' &&
184
+ event.type !== 'xstate.after(ITEM_TIMEOUT)#toast.active' &&
185
+ event.type !== 'done.invoke.timer'
186
+ )
187
+ return;
188
+
189
+ if (context.order === 'desc') {
190
+ context.items.pop();
191
+ context.activeItem =
192
+ context.items.length > 0
193
+ ? context.items[context.items.length - 1].id
194
+ : null;
195
+ } else {
196
+ context.items.shift();
197
+ context.activeItem =
198
+ context.items.length > 0 ? context.items[0].id : null;
199
+ }
200
+ }),
201
+ // startTimer: sendTo('timer', { type: 'START' }),
202
+ },
203
+ delays: {
204
+ ITEM_TIMEOUT: (context) => {
205
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
206
+ return (
207
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
208
+ context.items[context.items.length - 1].duration ?? context.duration
209
+ );
210
+ },
211
+ },
212
+ guards: {
213
+ itemHasTimeout: (context) => {
214
+ return (
215
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
216
+ context.items[context.items.length - 1].duration > 0 ||
217
+ context.duration > 0
218
+ );
219
+ },
220
+ },
221
+ }
222
+ );
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "esModuleInterop": true,
6
+ "jsx": "preserve",
7
+ "forceConsistentCasingInFileNames": true,
8
+ "strict": true,
9
+ "noImplicitOverride": true,
10
+ "noPropertyAccessFromIndexSignature": true,
11
+ "noImplicitReturns": true,
12
+ "noFallthroughCasesInSwitch": true
13
+ },
14
+ "files": [],
15
+ "include": [],
16
+ "references": [
17
+ {
18
+ "path": "./tsconfig.lib.json"
19
+ },
20
+ {
21
+ "path": "./tsconfig.spec.json"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "declaration": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
10
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "module": "commonjs",
6
+ "types": ["jest", "node"]
7
+ },
8
+ "include": [
9
+ "jest.config.ts",
10
+ "src/**/*.test.ts",
11
+ "src/**/*.spec.ts",
12
+ "src/**/*.d.ts"
13
+ ]
14
+ }