@availity/mui-favorites 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.
@@ -0,0 +1,273 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { render, waitFor, fireEvent, act } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ // eslint-disable-next-line @nx/enforce-module-boundaries
5
+ import { server } from '@availity/mock/src/lib/server';
6
+ import avMessages from '@availity/message-core';
7
+ import { FavoritesProvider } from './Favorites';
8
+ import { FavoriteHeart } from './FavoriteHeart';
9
+
10
+ jest.mock('@availity/message-core');
11
+
12
+ const domain = () => {
13
+ if (window.location.origin) {
14
+ return window.location.origin;
15
+ }
16
+
17
+ if (window.location.hostname) {
18
+ return `${window.location.protocol}//${window.location.hostname}${
19
+ window.location.port ? `:${window.location.port}` : ''
20
+ }`;
21
+ }
22
+
23
+ return '*';
24
+ };
25
+
26
+ avMessages.subscribe = jest.fn((event, fn) => {
27
+ window.addEventListener('message', (event) => {
28
+ if (!event || !event.data) {
29
+ // check origin as trusted domain
30
+ return;
31
+ }
32
+
33
+ let { data } = event;
34
+
35
+ if (typeof data === 'string') {
36
+ try {
37
+ data = JSON.parse(data);
38
+ } catch {
39
+ // do nothing
40
+ }
41
+ }
42
+
43
+ event = data && data.event;
44
+
45
+ fn(data);
46
+ });
47
+ return () => jest.fn();
48
+ });
49
+
50
+ avMessages.send = jest.fn((payload, target) => {
51
+ try {
52
+ const message = typeof payload === 'string' ? payload : JSON.stringify(payload);
53
+ target?.postMessage(message, domain());
54
+ } catch (error) {
55
+ // eslint-disable-next-line no-console
56
+ console.warn('AvMessage.send()', error);
57
+ }
58
+ });
59
+
60
+ const queryClient = new QueryClient();
61
+
62
+ describe('Favorites', () => {
63
+ beforeAll(() => {
64
+ // Start the interception.
65
+ server.listen();
66
+ });
67
+
68
+ afterEach(() => {
69
+ // Remove any handlers you may have added
70
+ // in individual tests (runtime handlers).
71
+ server.resetHandlers();
72
+ jest.clearAllMocks();
73
+ queryClient.clear();
74
+ });
75
+ it('should render favorited', async () => {
76
+ const { container } = render(
77
+ <QueryClientProvider client={queryClient}>
78
+ <FavoritesProvider>
79
+ <FavoriteHeart id="123" name="App #1" />
80
+ </FavoritesProvider>
81
+ </QueryClientProvider>
82
+ );
83
+
84
+ const heart = container.querySelector('#av-favorite-heart-123');
85
+
86
+ await waitFor(() => expect(heart).toBeChecked());
87
+ });
88
+
89
+ it('should render unfavorited', async () => {
90
+ const { container } = render(
91
+ <QueryClientProvider client={queryClient}>
92
+ <FavoritesProvider>
93
+ <FavoriteHeart id="789" name="App #3" />
94
+ </FavoritesProvider>
95
+ </QueryClientProvider>
96
+ );
97
+
98
+ const heart = container.querySelector('#av-favorite-heart-789');
99
+
100
+ await waitFor(() => expect(heart).not.toBeChecked());
101
+ });
102
+
103
+ it('should render disabled', async () => {
104
+ const { container } = render(
105
+ <QueryClientProvider client={queryClient}>
106
+ <FavoritesProvider>
107
+ <FavoriteHeart id="789" name="App #3" disabled />
108
+ </FavoritesProvider>
109
+ </QueryClientProvider>
110
+ );
111
+
112
+ const heart = container.querySelector('#av-favorite-heart-789');
113
+
114
+ await waitFor(() => expect(heart).toBeDisabled());
115
+ });
116
+ it('should show tooltip with add message', async () => {
117
+ const { container, getByRole } = render(
118
+ <QueryClientProvider client={queryClient}>
119
+ <FavoritesProvider>
120
+ <FavoriteHeart id="789" name="App #3" />
121
+ </FavoritesProvider>
122
+ </QueryClientProvider>
123
+ );
124
+
125
+ const heart = container.querySelector('#av-favorite-heart-789');
126
+
127
+ act(() => {
128
+ if (heart) fireEvent.mouseOver(heart);
129
+ });
130
+
131
+ expect(heart).toBeDefined();
132
+
133
+ await waitFor(
134
+ () => {
135
+ const tooltip = getByRole('tooltip');
136
+
137
+ expect(tooltip.textContent).toContain('Add to My Favorites');
138
+ },
139
+ { timeout: 2000 }
140
+ );
141
+ });
142
+
143
+ it('should show tooltip with remove message', async () => {
144
+ const { container, getByRole } = render(
145
+ <QueryClientProvider client={queryClient}>
146
+ <FavoritesProvider>
147
+ <FavoriteHeart id="123" name="App #1" />
148
+ </FavoritesProvider>
149
+ </QueryClientProvider>
150
+ );
151
+
152
+ const heart = container.querySelector('#av-favorite-heart-123');
153
+
154
+ act(() => {
155
+ if (heart) fireEvent.mouseOver(heart);
156
+ });
157
+
158
+ expect(heart).toBeDefined();
159
+
160
+ await waitFor(
161
+ () => {
162
+ const tooltip = getByRole('tooltip');
163
+
164
+ expect(tooltip.textContent).toContain('Remove from My Favorites');
165
+ },
166
+ { timeout: 2000 }
167
+ );
168
+ });
169
+
170
+ it('should render label with app name', async () => {
171
+ const { container } = render(
172
+ <QueryClientProvider client={queryClient}>
173
+ <FavoritesProvider>
174
+ <FavoriteHeart id="123" name="App #1" />
175
+ </FavoritesProvider>
176
+ </QueryClientProvider>
177
+ );
178
+
179
+ const heart = container.querySelector('#av-favorite-heart-123');
180
+
181
+ expect(heart).toBeDefined();
182
+
183
+ expect(heart).toHaveAttribute('aria-label', 'Favorite App #1');
184
+ });
185
+
186
+ it('should add favorite and send post message with updated favorites', async () => {
187
+ const user = userEvent.setup();
188
+
189
+ const { container } = render(
190
+ <QueryClientProvider client={queryClient}>
191
+ <FavoritesProvider>
192
+ <FavoriteHeart id="789" name="App #3" />
193
+ </FavoritesProvider>
194
+ </QueryClientProvider>
195
+ );
196
+
197
+ const heart = container.querySelector('#av-favorite-heart-789');
198
+
199
+ await waitFor(() => expect(heart).not.toBeChecked());
200
+
201
+ if (heart) await user.click(heart);
202
+
203
+ await waitFor(() => expect(heart).toBeChecked());
204
+
205
+ await waitFor(() => {
206
+ expect(avMessages.send).toHaveBeenCalledTimes(1);
207
+ expect(avMessages.send).toHaveBeenCalledWith({
208
+ event: 'av:favorites:update',
209
+ favorites: [
210
+ { id: '123', pos: 0 },
211
+ { id: '456', pos: 1 },
212
+ { id: '789', pos: 2 },
213
+ ],
214
+ });
215
+ });
216
+ });
217
+
218
+ it('should toggle favorite and send post message with updated favorites on keypress', async () => {
219
+ const { container } = render(
220
+ <QueryClientProvider client={queryClient}>
221
+ <FavoritesProvider>
222
+ <FavoriteHeart id="123" name="App #1" />
223
+ </FavoritesProvider>
224
+ </QueryClientProvider>
225
+ );
226
+
227
+ const heart = container.querySelector('#av-favorite-heart-123');
228
+
229
+ await waitFor(() => {
230
+ expect(heart).toBeChecked();
231
+ });
232
+
233
+ if (heart) await fireEvent.keyDown(heart, { key: 'Enter', code: 'Enter', charCode: 13 });
234
+
235
+ await waitFor(() => expect(heart).not.toBeChecked());
236
+
237
+ await waitFor(() => {
238
+ expect(avMessages.send).toHaveBeenCalledTimes(1);
239
+ expect(avMessages.send).toHaveBeenCalledWith({
240
+ event: 'av:favorites:update',
241
+ favorites: [{ id: '456', pos: 1 }],
242
+ });
243
+ });
244
+ });
245
+
246
+ it('should remove favorite and send post message with updated favorites', async () => {
247
+ const user = userEvent.setup();
248
+
249
+ const { container } = render(
250
+ <QueryClientProvider client={queryClient}>
251
+ <FavoritesProvider>
252
+ <FavoriteHeart id="123" name="App #1" />
253
+ </FavoritesProvider>
254
+ </QueryClientProvider>
255
+ );
256
+
257
+ const heart = container.querySelector('#av-favorite-heart-123');
258
+
259
+ await waitFor(() => expect(heart).toBeChecked());
260
+
261
+ if (heart) await user.click(heart);
262
+
263
+ await waitFor(() => expect(heart).not.toBeChecked());
264
+
265
+ await waitFor(() => {
266
+ expect(avMessages.send).toHaveBeenCalledTimes(1);
267
+ expect(avMessages.send).toHaveBeenCalledWith({
268
+ event: 'av:favorites:update',
269
+ favorites: [{ id: '456', pos: 1 }],
270
+ });
271
+ });
272
+ });
273
+ });
@@ -0,0 +1,168 @@
1
+ import { createContext, useContext, useEffect, useState, useMemo } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import avMessages from '@availity/message-core';
4
+ import type { Favorite } from './utils';
5
+ import { useFavoritesQuery, useSubmitFavorites, sendUpdateMessage, openMaxModal } from './utils';
6
+ import { AV_INTERNAL_GLOBALS, MAX_FAVORITES } from './constants';
7
+
8
+ type StatusUnion = 'idle' | 'error' | 'loading' | 'success';
9
+
10
+ type FavoritesContextType = {
11
+ favorites?: Favorite[];
12
+ queryStatus: StatusUnion;
13
+ mutationStatus: StatusUnion;
14
+ lastClickedFavoriteId: string;
15
+ deleteFavorite: (id: string) => void;
16
+ addFavorite: (id: string) => void;
17
+ };
18
+
19
+ const FavoritesContext = createContext<FavoritesContextType | null>(null);
20
+
21
+ export const FavoritesProvider = ({
22
+ children,
23
+ onFavoritesChange,
24
+ }: {
25
+ children: React.ReactNode;
26
+ onFavoritesChange?: (favorites: Favorite[]) => void;
27
+ }): JSX.Element => {
28
+ const [lastClickedFavoriteId, setLastClickedFavoriteId] = useState<string>('');
29
+
30
+ const queryClient = useQueryClient();
31
+ const { data: favorites, status: queryStatus } = useFavoritesQuery();
32
+
33
+ const { submitFavorites, status: mutationStatus } = useSubmitFavorites({
34
+ onMutationStart(targetFavoriteId) {
35
+ setLastClickedFavoriteId(targetFavoriteId);
36
+ },
37
+ });
38
+
39
+ useEffect(() => {
40
+ const unsubscribeFavoritesChanged = avMessages.subscribe(
41
+ AV_INTERNAL_GLOBALS.FAVORITES_CHANGED,
42
+ (data) => {
43
+ if (data?.favorites) {
44
+ queryClient.setQueryData(['favorites'], data?.favorites);
45
+ }
46
+ },
47
+ { ignoreSameWindow: false }
48
+ );
49
+
50
+ const unsubscribeFavoritesUpdate = avMessages.subscribe(
51
+ AV_INTERNAL_GLOBALS.FAVORITES_UPDATE,
52
+ (data) => {
53
+ if (data?.favorites) {
54
+ queryClient.setQueryData(['favorites'], data?.favorites);
55
+ }
56
+ },
57
+ { ignoreSameWindow: false }
58
+ );
59
+
60
+ return () => {
61
+ unsubscribeFavoritesChanged();
62
+ unsubscribeFavoritesUpdate();
63
+ };
64
+ }, [queryClient]);
65
+
66
+ const deleteFavorite = async (id: string) => {
67
+ if (favorites) {
68
+ const response = await submitFavorites({
69
+ favorites: favorites.filter((favorite: Favorite) => favorite.id !== id),
70
+ targetFavoriteId: id,
71
+ });
72
+
73
+ sendUpdateMessage(response.favorites);
74
+ onFavoritesChange?.(response.favorites);
75
+ }
76
+ };
77
+
78
+ const addFavorite = async (id: string) => {
79
+ if (!favorites) return false;
80
+
81
+ if (favorites.length >= MAX_FAVORITES) {
82
+ openMaxModal();
83
+ return false;
84
+ }
85
+
86
+ const maxFavorite = favorites.reduce<Favorite | null>((accum, fave) => {
87
+ if (!accum || fave.pos > accum.pos) {
88
+ accum = fave;
89
+ }
90
+
91
+ return accum;
92
+ }, null);
93
+
94
+ const newFavPos = maxFavorite ? maxFavorite.pos + 1 : 0;
95
+ const response = await submitFavorites({
96
+ favorites: [...favorites, { id, pos: newFavPos }],
97
+ targetFavoriteId: id,
98
+ });
99
+
100
+ sendUpdateMessage(response.favorites);
101
+ onFavoritesChange?.(response.favorites);
102
+
103
+ const isFavorited = response.favorites.find((f) => f.id === id);
104
+
105
+ return !!isFavorited;
106
+ };
107
+
108
+ return (
109
+ <FavoritesContext.Provider
110
+ value={{
111
+ favorites,
112
+ queryStatus,
113
+ mutationStatus,
114
+ lastClickedFavoriteId,
115
+ deleteFavorite,
116
+ addFavorite,
117
+ }}
118
+ >
119
+ {children}
120
+ </FavoritesContext.Provider>
121
+ );
122
+ };
123
+
124
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
125
+ const noOp = () => {};
126
+
127
+ type MergedStatusUnion = 'initLoading' | 'reloading' | 'error' | 'success';
128
+
129
+ export const useFavorites = (
130
+ id: string
131
+ ): {
132
+ isFavorited: boolean;
133
+ status: MergedStatusUnion;
134
+ isLastClickedFavorite: boolean;
135
+ toggleFavorite: () => void;
136
+ } => {
137
+ const context = useContext(FavoritesContext);
138
+
139
+ if (context === null) {
140
+ throw new Error('useFavorites must be used within a FavoritesProvider');
141
+ }
142
+
143
+ const { favorites, queryStatus, mutationStatus, lastClickedFavoriteId, deleteFavorite, addFavorite } = context;
144
+
145
+ const isLastClickedFavorite = lastClickedFavoriteId === id;
146
+
147
+ const isFavorited = useMemo(() => {
148
+ const fav = favorites?.find((f) => f.id === id);
149
+ return !!fav;
150
+ }, [favorites, id]);
151
+
152
+ const toggleFavorite = () => (isFavorited ? deleteFavorite(id) : addFavorite(id));
153
+
154
+ const isDisabled = queryStatus === 'loading' || queryStatus === 'idle' || mutationStatus === 'loading';
155
+
156
+ let status: MergedStatusUnion = 'initLoading';
157
+ if (queryStatus === 'loading') status = 'initLoading';
158
+ if (mutationStatus === 'loading') status = 'reloading';
159
+ if (queryStatus === 'error' || mutationStatus === 'error') status = 'error';
160
+ if (queryStatus === 'success' && (mutationStatus === 'success' || mutationStatus === 'idle')) status = 'success';
161
+
162
+ return {
163
+ isFavorited,
164
+ status,
165
+ isLastClickedFavorite,
166
+ toggleFavorite: isDisabled ? noOp : toggleFavorite,
167
+ };
168
+ };
@@ -0,0 +1,9 @@
1
+ export const MAX_FAVORITES = 60;
2
+ export const NAV_APP_ID = 'Gateway-AvNavigation';
3
+
4
+ export const AV_INTERNAL_GLOBALS = {
5
+ FAVORITES_UPDATE: 'av:favorites:update',
6
+ FAVORITES_CHANGED: 'av:favorites:changed',
7
+ MAX_FAVORITES: 'av:favorites:maxed',
8
+ MY_TOP_APPS_UPDATED: 'av:topApps:updated',
9
+ } as const;
@@ -0,0 +1,61 @@
1
+ import avMessages from '@availity/message-core';
2
+ import { avSettingsApi } from '@availity/api-axios';
3
+ import { useMutation, useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
4
+ import { AV_INTERNAL_GLOBALS, NAV_APP_ID } from './constants';
5
+
6
+ export type Favorite = {
7
+ id: string;
8
+ pos: number;
9
+ };
10
+
11
+ export const isFavorite = (arg: Favorite) => Boolean(typeof arg?.id === 'string' && typeof arg?.pos === 'number');
12
+
13
+ export const validateFavorites = (unvalidatedFavorites: Favorite[]) => {
14
+ const validatedFavorites = Array.isArray(unvalidatedFavorites) ? unvalidatedFavorites?.filter(isFavorite) : [];
15
+ return validatedFavorites;
16
+ }
17
+
18
+ type MutationVariables = {
19
+ favorites: Favorite[];
20
+ targetFavoriteId: string;
21
+ }
22
+
23
+ type SettingsResponse = { data: { favorites: Favorite[] } };
24
+
25
+ const submit = async ({ favorites, targetFavoriteId }: MutationVariables) => {
26
+ const response: SettingsResponse = await avSettingsApi.setApplication(NAV_APP_ID, { favorites });
27
+ return { favorites: response.data.favorites, targetFavoriteId };
28
+ }
29
+
30
+ const getFavorites = async () => {
31
+ const result = await avSettingsApi.getApplication(NAV_APP_ID);
32
+ const unvalidatedFavorites = result?.data?.settings?.[0]?.favorites;
33
+ const validatedFavorites = validateFavorites(unvalidatedFavorites);
34
+
35
+ return validatedFavorites;
36
+ };
37
+
38
+ export const useFavoritesQuery = (): UseQueryResult<Favorite[], unknown> => useQuery(['favorites'], getFavorites);
39
+
40
+ type MutationOptions = {
41
+ onMutationStart?: (targetFavoriteId: string) => void;
42
+ };
43
+
44
+ export const useSubmitFavorites = ({ onMutationStart }: MutationOptions) => {
45
+ const queryClient = useQueryClient();
46
+ const { mutateAsync: submitFavorites, ...rest } = useMutation(submit, {
47
+ onMutate(variables) {
48
+ onMutationStart?.(variables.targetFavoriteId);
49
+ },
50
+ onSuccess(data) {
51
+ queryClient.setQueryData(['favorites'], data.favorites);
52
+ }
53
+ });
54
+ return { submitFavorites, ...rest };
55
+ };
56
+
57
+ export const sendUpdateMessage = (favorites: Favorite[]): void => {
58
+ avMessages.send({ favorites, event: AV_INTERNAL_GLOBALS.FAVORITES_UPDATE });
59
+ };
60
+
61
+ export const openMaxModal = (): void => avMessages.send(AV_INTERNAL_GLOBALS.MAX_FAVORITES);
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["."],
4
+ "exclude": ["dist", "build", "node_modules"]
5
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "module": "commonjs",
6
+ "types": ["jest", "node", "@testing-library/jest-dom"],
7
+ "allowJs": true
8
+ },
9
+ "include": ["**/*.test.js", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"]
10
+ }