• 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 { useUrlResolverQuery } from '@/core/apollo/generated/apollo-hooks';
2
import { SpaceLevel, UrlResolverResultState, UrlType } from '@/core/apollo/generated/graphql-schema';
3
import { isUrlResolverError } from '@/core/apollo/hooks/useApolloErrorHandler';
4
import { NotAuthorizedError, NotFoundError } from '@/core/40XErrorHandler/40XErrors';
5
import { PartialRecord } from '@/core/utils/PartialRecord';
6
import { compact } from 'lodash';
7
import { createContext, ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react';
8
import { buildReturnUrlParam, TabbedLayoutParams } from '../urlBuilders';
9
import { AuthenticationContext } from '@/core/auth/authentication/context/AuthenticationProvider';
10
import { useLocation } from 'react-router-dom';
11
import { AUTH_REQUIRED_PATH } from '@/core/auth/authentication/constants/authentication.constants';
12

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

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

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

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

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

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

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

58
  // Innovation Hubs
59
  innovationHubId: string | undefined;
60

1✔
61
  loading: boolean;
62
};
63

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

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

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

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

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

125
  /**
126
   * Default Apollo's cache behavior will store the result of the URL resolver queries based on the Id of the space returned
127
   * And will fill the gaps of missing Ids when the user navigates to a different URL
128
   * for example:
129
   *   - for a Url like /space/spaceNameId/settings/templates
130
   *      the server is returning { spaceId: 1234, templateId: null } // which is correct
131
   *   - but then when the user navigates to /space/spaceNameId/settings/templates/templateNameId
132
   *      the server is returning { spaceId: 1234, templateId: 5678 } // which is also correct
×
133
   *   - but then when the user closes the dialog, and the URL changes back to /space/spaceNameId/settings/templates
134
   *      Apollo is not making the request again, which is good,
135
   *      but the cache is returning the previous value { spaceId: 1234, templateId: 5678 },
136
   *      completing that null templateId which is wrong
137
   *
NEW
138
   * 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
139
   * 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
140
   */
×
NEW
141
  const {
×
NEW
142
    data: urlResolverData,
×
NEW
143
    error,
×
144
    loading: urlResolverLoading,
145
  } = useUrlResolverQuery({
×
146
    variables: {
147
      url: currentUrl,
148
    },
149
    skip: !currentUrl,
×
150
  });
×
151

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

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

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

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

190
      return url;
191
    };
192

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

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

202
      // Update the last processed URL and trigger URL resolution
203
      lastProcessedUrlRef.current = nextUrl;
204
      setCurrentUrl(nextUrl);
205
    };
206

207
    // Set up event listeners
×
208
    globalThis.addEventListener('popstate', handleUrlChange);
209
    const originalPushState = globalThis.history.pushState;
210
    globalThis.history.pushState = function (...args) {
211
      originalPushState.apply(globalThis.history, args);
212
      handleUrlChange();
213
    };
214

215
    // Initial URL resolution
216
    handleUrlChange();
217

218
    return () => {
219
      globalThis.removeEventListener('popstate', handleUrlChange);
×
220
      // Restore original pushState
221
      globalThis.history.pushState = originalPushState;
222
    };
223
  }, []);
×
224

×
225
  // Create cache for the resolver value
×
226
  const valueRef = useRef<UrlResolverContextValue>(emptyResult);
227
  const value = useMemo<UrlResolverContextValue>(() => {
228
    // start generating the context value on successfull request
229
    if (urlResolverData?.urlResolver.type) {
×
230
      const type = urlResolverData.urlResolver.type;
231
      const data = urlResolverData.urlResolver;
232
      const spaceId = data?.space?.id;
233
      const spacesIds = compact([...(data?.space?.parentSpaces ?? []), spaceId]);
234
      const spaceHierarchyPath = spacesIds.length > 0 ? (spacesIds as SpaceHierarchyPath) : undefined;
235

236
      const value = {
237
        type,
238
        // Space:
239
        spaceId: data.space?.id,
240
        spaceLevel: data.space?.level,
241
        levelZeroSpaceId: data.space?.levelZeroSpaceID,
242
        parentSpaceId: (data.space?.parentSpaces ?? []).at(-1),
243
        spaceHierarchyPath: spaceHierarchyPath,
244

245
        // Collaboration:
246
        collaborationId: data.space?.collaboration.id,
247

248
        // CalloutsSet:
249
        ...selectUrlParams(
250
          type,
251
          {
252
            [UrlType.Space]: data.space?.collaboration.calloutsSet,
253
            [UrlType.VirtualContributor]: data.virtualContributor?.calloutsSet,
254
          },
255
          calloutsSet => ({
256
            calloutsSetId: calloutsSet?.id,
257
            calloutId: calloutsSet?.calloutId,
258
            contributionId: calloutsSet?.contributionId,
259
            postId: calloutsSet?.postId,
260
            whiteboardId: calloutsSet?.['whiteboardId'], // No whiteboards yet on VCKBs, so TypeScript is complaining
261
          })
262
        ),
263

264
        // Calendar:
265
        calendarId: data.space?.calendar?.id,
266
        calendarEventId: data.space?.calendar?.calendarEventId,
267

268
        // Contributors:
269
        organizationId: data.organizationId,
270
        userId: data.userId,
271
        vcId: data.virtualContributor?.id,
272

273
        // Innovation Packs:
274
        innovationPackId: data.innovationPack?.id,
275

276
        // InnovationHub:
277
        innovationHubId: data.innovationHubId,
278

279
        // Templates:
280
        ...selectUrlParams(
281
          type,
282
          {
283
            [UrlType.Space]: data.space?.templatesSet,
284
            [UrlType.InnovationPacks]: data.innovationPack?.templatesSet,
285
          },
286
          templatesSet => ({
287
            templatesSetId: templatesSet?.id,
288
            templateId: templatesSet?.templateId,
289
          })
290
        ),
291

292
        // Forum:
293
        discussionId: data.discussionId,
294
        loading: urlResolverLoading,
295
      };
296
      // store in the cache
297
      valueRef.current = value;
298
      return value;
299
    }
300
    // return the cached value until the new request is resolved
301
    if (urlResolverLoading) {
302
      return valueRef.current;
303
    }
304
    // if the value is not resolved and loading is complete return empty result
305
    return emptyResult;
306
  }, [urlResolverData, urlResolverLoading]);
307

308
  return <UrlResolverContext value={value}>{children}</UrlResolverContext>;
309
};
310

311
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