• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

alkem-io / client-web / #10369

13 Feb 2025 08:25AM UTC coverage: 5.804%. First build
#10369

Pull #7670

travis-ci

Pull Request #7670: testing something

197 of 10756 branches covered (1.83%)

Branch coverage included in aggregate %.

0 of 24 new or added lines in 5 files covered. (0.0%)

1510 of 18656 relevant lines covered (8.09%)

0.18 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

8.89
/src/main/routing/urlResolver/UrlResolverProvider.tsx
1
import { compact } from 'lodash-es';
2
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react';
3
import { useLocation } from 'react-router-dom';
4
import { NotAuthorizedError, NotFoundError } from '@/core/40XErrorHandler/40XErrors';
5
import { useUrlResolverQuery } from '@/core/apollo/generated/apollo-hooks';
6
import { type SpaceLevel, UrlResolverResultState, UrlType } from '@/core/apollo/generated/graphql-schema';
7
import { isUrlResolverError } from '@/core/apollo/hooks/useApolloErrorHandler';
8
import { AUTH_REQUIRED_PATH } from '@/core/auth/authentication/constants/authentication.constants';
9
import { AuthenticationContext } from '@/core/auth/authentication/context/AuthenticationProvider';
10
import type { PartialRecord } from '@/core/utils/PartialRecord';
11
import { ROUTE_USER_ME } from '@/domain/platform/routes/constants';
12
import { buildReturnUrlParam, TabbedLayoutParams } from '../urlBuilders';
13

14
export type SpaceHierarchyPath = [] | [string] | [string, string] | [string, string, string];
15

16
export type UrlResolverContextValue = {
17
  type: UrlType | undefined;
18
  // Space:
19
  /**
20
   * The current Space or Subspace Id, no matter the level
21
   */
22
  spaceId: string | undefined;
23
  spaceLevel: SpaceLevel | undefined;
24
  levelZeroSpaceId: string | undefined;
25
  /**
26
   * [level0, level1, level2]
27
   */
28
  spaceHierarchyPath: SpaceHierarchyPath | undefined;
29
  /**
30
   * The parent space id of the current space
31
   */
32
  parentSpaceId: string | undefined;
33

34
  // Collaboration:
35
  collaborationId: string | undefined;
36
  calloutsSetId: string | undefined;
37
  calloutId: string | undefined;
38
  contributionId: string | undefined;
39
  postId: string | undefined;
40
  whiteboardId: string | undefined;
41

42
  // Calendar:
43
  calendarId: string | undefined;
44
  calendarEventId: string | undefined;
45

46
  // Contributors:
47
  organizationId: string | undefined;
48
  userId: string | undefined;
49
  vcId: string | undefined;
50

51
  // Forum:
52
  discussionId: string | undefined;
53

54
  // Templates
55
  innovationPackId: string | undefined;
56
  templatesSetId: string | undefined;
57
  templateId: string | undefined;
58

59
  // Innovation Hubs
60
  innovationHubId: string | undefined;
1✔
61

62
  loading: boolean;
63
};
64

65
const emptyResult: UrlResolverContextValue = {
66
  type: undefined,
67
  spaceId: undefined,
68
  spaceLevel: undefined,
69
  levelZeroSpaceId: undefined,
70
  spaceHierarchyPath: [],
71
  parentSpaceId: undefined,
72
  collaborationId: undefined,
73
  calloutsSetId: undefined,
74
  calloutId: undefined,
75
  contributionId: undefined,
76
  postId: undefined,
77
  whiteboardId: undefined,
78
  calendarId: undefined,
79
  calendarEventId: undefined,
80
  organizationId: undefined,
81
  userId: undefined,
82
  vcId: undefined,
83
  discussionId: undefined,
NEW
84
  innovationPackId: undefined,
×
85
  templatesSetId: undefined,
86
  templateId: undefined,
87
  innovationHubId: undefined,
88
  loading: true,
89
};
90

91
/**
92
 * Helper function to choose between two urlResolver results objects and generate the urlResolver result based on the selected one
93
 * For example, spaces have a calloutSet, but also VCs have a calloutSet.
94
 * This function can select the correct one based on the urlType
95
 *
96
 * @param values An object coming from the urlResolverService
97
 *   for example something like:{
98
 *    [UrlType.Space]: Space.Collaboration { CalloutsSet { id: '123', calloutId: '456' ...} }
99
 *    [UrlType.VirtualContributor]: VC.KnowledgeBase { CalloutsSet { id: '789', calloutId: '001' ...} }
100
 * @param urlType the type to use
101
 * @param generate A function that should generate the result based on the object selected by urlType
102
 * @returns
1✔
103
 */
104
const selectUrlParams = <T extends {}, R extends {}>(
105
  urlType: UrlType,
106
  values: PartialRecord<UrlType, Partial<T | undefined>>,
×
107
  generate: (values: Partial<T> | undefined) => R
108
): R => generate(values[urlType] ?? undefined);
1✔
109

110
const UrlResolverContext = createContext<UrlResolverContextValue>(emptyResult);
1✔
111

112
/**
×
113
 * The UrlResolverProvider monitors URL changes and resolves them to entities in the system.
114
 * It has been enhanced to prevent redundant updates and re-renders when navigating between
×
115
 * pages that use the same base entity (like a space and its callouts/posts).
116
 */
117
const UrlResolverProvider = ({ children }: { children: ReactNode }) => {
118
  // Using a state to force a re-render of the children when the url changes
119
  const [currentUrl, setCurrentUrl] = useState<string>('');
120
  const { isAuthenticated } = useContext(AuthenticationContext);
121
  const location = useLocation();
122

123
  // Keep a stable ref of the last processed URL to avoid duplicate updates
124
  const lastProcessedUrlRef = useRef<string>('');
125

126
  /**
127
   * Default Apollo's cache behavior will store the result of the URL resolver queries based on the Id of the space returned
128
   * And will fill the gaps of missing Ids when the user navigates to a different URL
129
   * for example:
130
   *   - for a Url like /space/spaceNameId/settings/templates
131
   *      the server is returning { spaceId: 1234, templateId: null } // which is correct
132
   *   - but then when the user navigates to /space/spaceNameId/settings/templates/templateNameId
×
133
   *      the server is returning { spaceId: 1234, templateId: 5678 } // which is also correct
134
   *   - but then when the user closes the dialog, and the URL changes back to /space/spaceNameId/settings/templates
135
   *      Apollo is not making the request again, which is good,
136
   *      but the cache is returning the previous value { spaceId: 1234, templateId: 5678 },
137
   *      completing that null templateId which is wrong
NEW
138
   *
×
NEW
139
   * To avoid this, we have modified the typePolicies we have disabled the keyFields for the UrlResolver queries and we are using the URL as the key
×
NEW
140
   * This way, the cache will store the entire result of the query based on the URL, and will not try to merge the results of different queries.
×
NEW
141
   */
×
NEW
142
  const {
×
NEW
143
    data: urlResolverData,
×
144
    error,
145
    loading: urlResolverLoading,
×
146
  } = useUrlResolverQuery({
147
    variables: {
148
      url: currentUrl,
149
    },
×
150
    skip: !currentUrl,
×
151
  });
×
152

×
153
  if (!urlResolverLoading && error && isUrlResolverError(error)) {
×
154
    // Normally the urlResolver doesn't throw an error, it returns a UrlResolverResult.Error instead
×
155
    throw new NotFoundError();
×
156
  }
157
  if (!urlResolverLoading && urlResolverData?.urlResolver.state === UrlResolverResultState.NotFound) {
×
158
    throw new NotFoundError({ closestAncestor: urlResolverData.urlResolver.closestAncestor });
159
  }
160
  if (!urlResolverLoading && urlResolverData?.urlResolver.state === UrlResolverResultState.Forbidden) {
161
    if (!isAuthenticated) {
162
      const returnUrl = location.pathname + location.search + location.hash;
163
      throw new NotAuthorizedError({ redirectUrl: `${AUTH_REQUIRED_PATH}${buildReturnUrlParam(returnUrl)}` });
×
164
    }
165
    throw new NotAuthorizedError({ closestAncestor: urlResolverData.urlResolver.closestAncestor });
166
  }
167

168
  useEffect(() => {
169
    // strip parts of the URL that go below the resolved entity
170
    const maskedUrlParts = [
171
      // Remove anything after /settings, because it's the settings url of the same entity, no need to resolve it:
172
      /\/settings(?:\/[a-zA-Z0-9-]+)?\/?$/,
173
      // Remove tabs from the URL as well
174
      `/${TabbedLayoutParams.Section}(?:/[a-zA-Z0-9-]+)?/?$`,
175
    ];
176

×
177
    // Process URL to get a standardized version for comparison
178
    const processUrl = (url: string): string => {
179
      // Remove trailing slash because /:spaceNameId and /:spaceNameId/ are the same url
180
      if (url.endsWith('/')) {
181
        url = url.slice(0, -1);
182
      }
183

184
      // Apply URL masks
185
      if (!/\/innovation-packs\/[a-zA-Z0-9-]+\/settings\/[a-zA-Z0-9-]+/.test(url)) {
186
        for (const mask of maskedUrlParts) {
187
          url = url.replace(mask, '');
188
        }
189
      }
190

191
      return url;
192
    };
193

194
    const handleUrlChange = () => {
195
      // Get the normalized URL
196
      const nextUrl = processUrl(globalThis.location.origin + globalThis.location.pathname);
197

198
      // Skip update if URL hasn't changed from the last processed URL
199
      if (nextUrl === lastProcessedUrlRef.current) {
200
        return;
201
      }
202

203
      // Skip URL resolution for /user/me routes - these are handled by MeUserContext
204
      const pathname = globalThis.location.pathname;
205
      if (pathname === ROUTE_USER_ME || pathname.startsWith(`${ROUTE_USER_ME}/`)) {
206
        lastProcessedUrlRef.current = nextUrl;
207
        setCurrentUrl('');
×
208
        return;
209
      }
210

211
      // Update the last processed URL and trigger URL resolution
212
      lastProcessedUrlRef.current = nextUrl;
213
      setCurrentUrl(nextUrl);
214
    };
215

216
    // Set up event listeners
217
    globalThis.addEventListener('popstate', handleUrlChange);
218
    const originalPushState = globalThis.history.pushState;
219
    globalThis.history.pushState = (...args) => {
×
220
      originalPushState.apply(globalThis.history, args);
221
      handleUrlChange();
222
    };
223

×
224
    // Initial URL resolution
×
225
    handleUrlChange();
×
226

227
    return () => {
228
      globalThis.removeEventListener('popstate', handleUrlChange);
229
      // Restore original pushState
×
230
      globalThis.history.pushState = originalPushState;
231
    };
232
  }, []);
233

234
  // Create cache for the resolver value
235
  const valueRef = useRef<UrlResolverContextValue>(emptyResult);
236
  const value = (() => {
237
    // When URL is empty (e.g., /user/me routes), return empty non-loading context
238
    if (!currentUrl) {
239
      const cleared = { ...emptyResult, loading: false };
240
      valueRef.current = cleared;
241
      return cleared;
242
    }
243
    // start generating the context value on successfull request
244
    if (urlResolverData?.urlResolver.type) {
245
      const type = urlResolverData.urlResolver.type;
246
      const data = urlResolverData.urlResolver;
247
      const spaceId = data?.space?.id;
248
      const spacesIds = compact([...(data?.space?.parentSpaces ?? []), spaceId]);
249
      const spaceHierarchyPath = spacesIds.length > 0 ? (spacesIds as SpaceHierarchyPath) : undefined;
250

251
      const value = {
252
        type,
253
        // Space:
254
        spaceId: data.space?.id,
255
        spaceLevel: data.space?.level,
256
        levelZeroSpaceId: data.space?.levelZeroSpaceID,
257
        parentSpaceId: (ps => ps[ps.length - 1])(data.space?.parentSpaces ?? []),
258
        spaceHierarchyPath: spaceHierarchyPath,
259

260
        // Collaboration:
261
        collaborationId: data.space?.collaboration.id,
262

263
        // CalloutsSet:
264
        ...selectUrlParams(
265
          type,
266
          {
267
            [UrlType.Space]: data.space?.collaboration.calloutsSet,
268
            [UrlType.VirtualContributor]: data.virtualContributor?.calloutsSet,
269
          },
270
          calloutsSet => ({
271
            calloutsSetId: calloutsSet?.id,
272
            calloutId: calloutsSet?.calloutId,
273
            contributionId: calloutsSet?.contributionId,
274
            postId: calloutsSet?.postId,
275
            whiteboardId: (calloutsSet as { whiteboardId?: string } | undefined)?.whiteboardId,
276
          })
277
        ),
278

279
        // Calendar:
280
        calendarId: data.space?.calendar?.id,
281
        calendarEventId: data.space?.calendar?.calendarEventId,
282

283
        // Contributors:
284
        organizationId: data.organizationId,
285
        userId: data.userId,
286
        vcId: data.virtualContributor?.id,
287

288
        // Innovation Packs:
289
        innovationPackId: data.innovationPack?.id,
290

291
        // InnovationHub:
292
        innovationHubId: data.innovationHubId,
293

294
        // Templates:
295
        ...selectUrlParams(
296
          type,
297
          {
298
            [UrlType.Space]: data.space?.templatesSet,
299
            [UrlType.InnovationPacks]: data.innovationPack?.templatesSet,
300
          },
301
          templatesSet => ({
302
            templatesSetId: templatesSet?.id,
303
            templateId: templatesSet?.templateId,
304
          })
305
        ),
306

307
        // Forum:
308
        discussionId: data.discussionId,
309
        loading: urlResolverLoading,
310
      };
311
      // store in the cache
312
      valueRef.current = value;
313
      return value;
314
    }
315
    // return the cached value until the new request is resolved
316
    if (urlResolverLoading) {
317
      return valueRef.current;
318
    }
319
    // if the value is not resolved and loading is complete return empty result
320
    return emptyResult;
321
  })();
322

323
  return <UrlResolverContext value={value}>{children}</UrlResolverContext>;
324
};
325

326
export { UrlResolverProvider, UrlResolverContext };
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc