@jskit-ai/users-web 0.1.35 → 0.1.37

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.
Files changed (32) hide show
  1. package/package.descriptor.mjs +8 -22
  2. package/package.json +7 -6
  3. package/src/client/components/MembersAdminClientElement.vue +5 -5
  4. package/src/client/components/WorkspaceMembersClientElement.vue +16 -16
  5. package/src/client/components/WorkspacesClientElement.vue +2 -2
  6. package/src/client/composables/accountSettingsAvatarUploadRuntime.js +26 -172
  7. package/src/client/composables/accountSettingsRuntimeConstants.js +0 -4
  8. package/src/client/composables/accountSettingsRuntimeHelpers.js +1 -1
  9. package/src/client/composables/addEditUiRuntime.js +11 -2
  10. package/src/client/composables/crudLookupFieldLabelSupport.js +36 -4
  11. package/src/client/composables/crudLookupFieldRuntime.js +5 -2
  12. package/src/client/composables/crudSchemaFormHelpers.js +23 -3
  13. package/src/client/composables/listQueryParamSupport.js +459 -0
  14. package/src/client/composables/listUiRuntime.js +18 -6
  15. package/src/client/composables/routeTemplateHelpers.js +122 -0
  16. package/src/client/composables/useAddEdit.js +10 -0
  17. package/src/client/composables/useList.js +242 -2
  18. package/src/client/composables/usePagedCollection.js +55 -4
  19. package/src/client/composables/useView.js +4 -1
  20. package/src/client/composables/viewUiRuntime.js +11 -2
  21. package/src/client/lib/bootstrap.js +1 -1
  22. package/src/client/lib/menuIcons.js +27 -6
  23. package/templates/src/components/WorkspaceNotFoundCard.vue +2 -1
  24. package/templates/src/components/account/settings/AccountSettingsInvitesSection.vue +1 -1
  25. package/test/addEditUiRuntime.test.js +18 -0
  26. package/test/crudLookupFieldRuntime.test.js +51 -1
  27. package/test/listQueryParamSupport.test.js +190 -0
  28. package/test/listUiRuntime.test.js +21 -0
  29. package/test/menuIcons.test.js +2 -0
  30. package/test/routeTemplateHelpers.test.js +56 -0
  31. package/test/usePagedCollection.test.js +53 -0
  32. package/test/viewUiRuntime.test.js +35 -0
@@ -14,6 +14,7 @@ import {
14
14
  import {
15
15
  resolveResourceMessages
16
16
  } from "./scopeHelpers.js";
17
+ import { resolveRouteParamNamesInOrder } from "./routeTemplateHelpers.js";
17
18
 
18
19
  function useAddEdit({
19
20
  ownershipFilter = USERS_ROUTE_VISIBILITY_WORKSPACE,
@@ -53,6 +54,7 @@ function useAddEdit({
53
54
  const addEditUiRuntime = createAddEditUiRuntime({
54
55
  recordIdParam,
55
56
  routeParams: routeParams ?? computed(() => route?.params || {}),
57
+ routeParamNames: computed(() => resolveRouteParamNamesInOrder(route)),
56
58
  routePath: computed(() => route?.path || ""),
57
59
  routeRecordId,
58
60
  apiUrlTemplate,
@@ -134,6 +136,12 @@ function useAddEdit({
134
136
  const isInitialLoading = operationScope.isLoading(endpointResource.isInitialLoading);
135
137
  const isFetching = operationScope.isLoading(endpointResource.isFetching);
136
138
  const isRefetching = computed(() => Boolean(isFetching.value && !isInitialLoading.value));
139
+ const isFieldLocked = computed(() =>
140
+ Boolean(!canSave.value || addEdit.saving.value || isRefetching.value)
141
+ );
142
+ const isSubmitDisabled = computed(() =>
143
+ Boolean(isInitialLoading.value || isRefetching.value || !canSave.value)
144
+ );
137
145
  const loadError = operationScope.loadError(endpointResource.loadError);
138
146
  const isLoading = operationScope.isLoading(endpointResource.isLoading);
139
147
  setupOperationErrorReporting({
@@ -148,6 +156,8 @@ function useAddEdit({
148
156
  isInitialLoading,
149
157
  isFetching,
150
158
  isRefetching,
159
+ isFieldLocked,
160
+ isSubmitDisabled,
151
161
  isLoading,
152
162
  isSaving: addEdit.saving,
153
163
  fieldErrors: addEdit.fieldErrors,
@@ -1,4 +1,5 @@
1
1
  import { computed, onScopeDispose, proxyRefs, ref, watch } from "vue";
2
+ import { useRouter } from "vue-router";
2
3
  import { appendQueryString } from "@jskit-ai/kernel/shared/support";
3
4
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
5
  import { resolveCrudLookupFieldKeys } from "@jskit-ai/kernel/shared/support/crudLookup";
@@ -7,10 +8,23 @@ import { useListCore } from "./useListCore.js";
7
8
  import { resolveOperationAdapter } from "./operationAdapters.js";
8
9
  import { setupOperationErrorReporting } from "./operationUiHelpers.js";
9
10
  import { createListUiRuntime } from "./listUiRuntime.js";
11
+ import { asPlainObject } from "./scopeHelpers.js";
10
12
  import {
11
13
  normalizeListSearchConfig,
12
14
  matchesLocalSearch
13
15
  } from "./listSearchSupport.js";
16
+ import {
17
+ normalizeListSyncToRouteConfig,
18
+ resolveQueryParamDescriptors,
19
+ resolveActiveQueryParamEntries,
20
+ resolveWritableQueryParamBindings,
21
+ buildQueryParamEntriesToken,
22
+ parseRouteBindingValue,
23
+ areQueryParamBindingValuesEqual,
24
+ buildRouteQueryCompareToken,
25
+ mergeManagedQueryParamKeyHistory,
26
+ resolveRouteSyncManagedKeys
27
+ } from "./listQueryParamSupport.js";
14
28
  import { resolveLookupFieldDisplayValue } from "./crudLookupFieldLabelSupport.js";
15
29
  import {
16
30
  resolveRouteParamNamesInOrder,
@@ -18,6 +32,8 @@ import {
18
32
  toRouteParamValue
19
33
  } from "./routeTemplateHelpers.js";
20
34
 
35
+ const EMPTY_ROUTE_SYNC_QUERY_PARAM_BLACKLIST = Object.freeze([]);
36
+
21
37
  function useList({
22
38
  ownershipFilter = USERS_ROUTE_VISIBILITY_WORKSPACE,
23
39
  surfaceId = "",
@@ -40,9 +56,15 @@ function useList({
40
56
  recordIdSelector = null,
41
57
  viewUrlTemplate = "",
42
58
  editUrlTemplate = "",
43
- search = null
59
+ search = null,
60
+ queryParams = null,
61
+ syncToRoute = false
44
62
  } = {}) {
45
63
  const searchConfig = normalizeListSearchConfig(search);
64
+ const routeSyncConfig = normalizeListSyncToRouteConfig(syncToRoute, {
65
+ defaultSearchParam: searchConfig.queryParam
66
+ });
67
+ const router = routeSyncConfig.enabled === true ? useRouter() : null;
46
68
  const searchQuery = ref(searchConfig.initialQuery);
47
69
  const debouncedSearchQuery = ref(searchConfig.initialQuery);
48
70
  let searchDebounceTimer = null;
@@ -90,7 +112,46 @@ function useList({
90
112
  },
91
113
  realtime
92
114
  });
115
+ if (
116
+ routeSyncConfig.enabled === true &&
117
+ routeSyncConfig.hydrateFromRoute === true &&
118
+ routeSyncConfig.syncSearch === true &&
119
+ searchConfig.enabled === true
120
+ ) {
121
+ const routeQuerySource = asPlainObject(operationScope.routeContext.route?.query || {});
122
+ const routeSearchValue = routeQuerySource[routeSyncConfig.searchParam];
123
+ const hydratedSearch = normalizeText(Array.isArray(routeSearchValue) ? routeSearchValue[0] : routeSearchValue);
124
+
125
+ if (searchDebounceTimer) {
126
+ clearTimeout(searchDebounceTimer);
127
+ searchDebounceTimer = null;
128
+ }
129
+
130
+ searchQuery.value = hydratedSearch;
131
+ debouncedSearchQuery.value = hydratedSearch;
132
+ isSearchDebouncing.value = false;
133
+ }
93
134
  const canView = operationScope.permissionGate("view");
135
+ const queryParamsContext = computed(() => {
136
+ return Object.freeze({
137
+ surfaceId: operationScope.routeContext.currentSurfaceId.value,
138
+ workspaceSlug: operationScope.workspaceSlugFromRoute.value,
139
+ ownershipFilter: operationScope.normalizedOwnershipFilter
140
+ });
141
+ });
142
+ const queryParamDescriptors = computed(() => {
143
+ return resolveQueryParamDescriptors(queryParams, queryParamsContext.value);
144
+ });
145
+ const declaredQueryParamKeys = computed(() => {
146
+ return queryParamDescriptors.value.map((descriptor) => descriptor.key);
147
+ });
148
+ const activeQueryParamEntries = computed(() => {
149
+ return resolveActiveQueryParamEntries(queryParamDescriptors.value);
150
+ });
151
+ const activeQueryParamsToken = computed(() => buildQueryParamEntriesToken(activeQueryParamEntries.value));
152
+ const writableQueryParamBindings = computed(() => {
153
+ return resolveWritableQueryParamBindings(queryParamDescriptors.value);
154
+ });
94
155
  const parentRouteFilter = computed(() => {
95
156
  const lookupFieldKeys = resolveCrudLookupFieldKeys(resource);
96
157
  if (lookupFieldKeys.length < 1) {
@@ -157,6 +218,12 @@ function useList({
157
218
  searchParams.set(parentFilter.key, parentFilter.value);
158
219
  }
159
220
 
221
+ for (const entry of activeQueryParamEntries.value) {
222
+ for (const value of entry.values) {
223
+ searchParams.append(entry.key, value);
224
+ }
225
+ }
226
+
160
227
  const serializedSearch = searchParams.toString();
161
228
  if (!serializedSearch) {
162
229
  return basePath;
@@ -178,6 +245,9 @@ function useList({
178
245
  if (querySearchEnabled.value) {
179
246
  baseQueryKey.push("__search__", searchConfig.queryParam, activeSearchQuery.value);
180
247
  }
248
+ if (activeQueryParamsToken.value) {
249
+ baseQueryKey.push("__query__", activeQueryParamsToken.value);
250
+ }
181
251
  return baseQueryKey;
182
252
  });
183
253
 
@@ -192,6 +262,174 @@ function useList({
192
262
  queryOptions,
193
263
  fallbackLoadError
194
264
  });
265
+ const routeSyncHydrated = ref(routeSyncConfig.enabled !== true);
266
+ const routeSyncApplying = ref(false);
267
+ const routeSyncManagedKeyHistory = ref([]);
268
+ const routeSyncQueryParamBlacklist = computed(() => {
269
+ if (routeSyncConfig.enabled !== true || routeSyncConfig.syncQueryParams !== true) {
270
+ return EMPTY_ROUTE_SYNC_QUERY_PARAM_BLACKLIST;
271
+ }
272
+ return routeSyncConfig.queryParamBlacklist;
273
+ });
274
+ const routeSyncQueryParamBlacklistSet = computed(() => {
275
+ return new Set(routeSyncQueryParamBlacklist.value);
276
+ });
277
+ if (routeSyncConfig.enabled === true && routeSyncConfig.syncQueryParams === true) {
278
+ watch(declaredQueryParamKeys, (nextKeys) => {
279
+ routeSyncManagedKeyHistory.value = mergeManagedQueryParamKeyHistory(
280
+ routeSyncManagedKeyHistory.value,
281
+ nextKeys
282
+ );
283
+ }, { immediate: true });
284
+ }
285
+ const routeSyncManagedKeys = computed(() => {
286
+ const managedKeys = resolveRouteSyncManagedKeys({
287
+ searchEnabled: searchConfig.enabled,
288
+ searchParam: routeSyncConfig.searchParam,
289
+ syncSearch: routeSyncConfig.enabled === true && routeSyncConfig.syncSearch === true,
290
+ syncQueryParams: routeSyncConfig.enabled === true && routeSyncConfig.syncQueryParams === true,
291
+ declaredKeys: declaredQueryParamKeys.value,
292
+ keyHistory: routeSyncManagedKeyHistory.value
293
+ });
294
+ if (routeSyncConfig.enabled !== true || routeSyncConfig.syncQueryParams !== true) {
295
+ return managedKeys;
296
+ }
297
+
298
+ const output = new Set(managedKeys);
299
+ for (const key of routeSyncQueryParamBlacklist.value) {
300
+ output.add(key);
301
+ }
302
+ return [...output].sort((left, right) => left.localeCompare(right));
303
+ });
304
+ const routeSyncDesiredQuery = computed(() => {
305
+ if (routeSyncConfig.enabled !== true) {
306
+ return {};
307
+ }
308
+
309
+ const desiredQuery = {};
310
+ if (routeSyncConfig.syncSearch === true && searchConfig.enabled === true) {
311
+ const normalizedSearch = normalizeText(searchQuery.value);
312
+ if (normalizedSearch) {
313
+ desiredQuery[routeSyncConfig.searchParam] = normalizedSearch;
314
+ }
315
+ }
316
+ if (routeSyncConfig.syncQueryParams === true) {
317
+ for (const entry of activeQueryParamEntries.value) {
318
+ if (routeSyncQueryParamBlacklistSet.value.has(entry.key)) {
319
+ continue;
320
+ }
321
+ if (entry.values.length === 1) {
322
+ desiredQuery[entry.key] = entry.values[0];
323
+ continue;
324
+ }
325
+ desiredQuery[entry.key] = [...entry.values];
326
+ }
327
+ }
328
+
329
+ return desiredQuery;
330
+ });
331
+ if (routeSyncConfig.enabled === true) {
332
+ watch(
333
+ () => operationScope.routeContext.route?.query || {},
334
+ (routeQuery) => {
335
+ if (routeSyncConfig.hydrateFromRoute !== true || routeSyncApplying.value === true) {
336
+ routeSyncHydrated.value = true;
337
+ return;
338
+ }
339
+
340
+ const routeQuerySource = asPlainObject(routeQuery);
341
+ if (routeSyncConfig.syncSearch === true && searchConfig.enabled === true) {
342
+ const routeSearchValue = routeQuerySource[routeSyncConfig.searchParam];
343
+ const nextSearch = normalizeText(Array.isArray(routeSearchValue) ? routeSearchValue[0] : routeSearchValue);
344
+ if (nextSearch !== searchQuery.value) {
345
+ searchQuery.value = nextSearch;
346
+ }
347
+ }
348
+ if (routeSyncConfig.syncQueryParams === true) {
349
+ for (const binding of writableQueryParamBindings.value) {
350
+ if (routeSyncQueryParamBlacklistSet.value.has(binding.key)) {
351
+ continue;
352
+ }
353
+ const nextValue = parseRouteBindingValue(binding, routeQuerySource[binding.key]);
354
+ const currentValue = typeof binding.get === "function" ? binding.get() : undefined;
355
+ if (areQueryParamBindingValuesEqual(currentValue, nextValue)) {
356
+ continue;
357
+ }
358
+ try {
359
+ binding.set(nextValue);
360
+ } catch {
361
+ // Ignore non-writable query param bindings.
362
+ }
363
+ }
364
+ }
365
+
366
+ routeSyncHydrated.value = true;
367
+ },
368
+ {
369
+ immediate: true
370
+ }
371
+ );
372
+
373
+ watch(
374
+ [routeSyncDesiredQuery, routeSyncManagedKeys],
375
+ async ([desiredQuery, managedKeys]) => {
376
+ if (routeSyncHydrated.value !== true || routeSyncApplying.value === true) {
377
+ return;
378
+ }
379
+
380
+ const managedKeySet = new Set(Array.isArray(managedKeys) ? managedKeys : []);
381
+ const currentQuery = asPlainObject(operationScope.routeContext.route?.query || {});
382
+ const nextQuery = {};
383
+
384
+ for (const [key, value] of Object.entries(currentQuery)) {
385
+ if (managedKeySet.has(key)) {
386
+ continue;
387
+ }
388
+ nextQuery[key] = value;
389
+ }
390
+ for (const [key, value] of Object.entries(asPlainObject(desiredQuery))) {
391
+ nextQuery[key] = value;
392
+ }
393
+
394
+ if (buildRouteQueryCompareToken(currentQuery) === buildRouteQueryCompareToken(nextQuery)) {
395
+ return;
396
+ }
397
+
398
+ routeSyncApplying.value = true;
399
+ try {
400
+ if (routeSyncConfig.mode === "push") {
401
+ await router.push({
402
+ query: nextQuery
403
+ });
404
+ } else {
405
+ await router.replace({
406
+ query: nextQuery
407
+ });
408
+ }
409
+ } finally {
410
+ routeSyncApplying.value = false;
411
+ }
412
+ }
413
+ );
414
+ }
415
+
416
+ watch(activeSearchQuery, (nextValue, previousValue) => {
417
+ if (!querySearchEnabled.value) {
418
+ return;
419
+ }
420
+ if (nextValue === previousValue) {
421
+ return;
422
+ }
423
+
424
+ list.trimToFirstPage();
425
+ });
426
+ watch(activeQueryParamsToken, (nextValue, previousValue) => {
427
+ if (nextValue === previousValue) {
428
+ return;
429
+ }
430
+
431
+ list.trimToFirstPage();
432
+ });
195
433
  const filteredItems = computed(() => {
196
434
  const sourceItems = Array.isArray(list.items.value) ? list.items.value : [];
197
435
  if (searchConfig.enabled !== true || searchConfig.mode !== "local") {
@@ -217,6 +455,7 @@ function useList({
217
455
  recordIdParam,
218
456
  recordIdSelector,
219
457
  routeParams: computed(() => operationScope.routeContext.route?.params || {}),
458
+ routeParamNames: computed(() => resolveRouteParamNamesInOrder(operationScope.routeContext.route)),
220
459
  routePath: computed(() => operationScope.routeContext.route?.path || ""),
221
460
  viewUrlTemplate,
222
461
  editUrlTemplate
@@ -261,7 +500,8 @@ function useList({
261
500
  searchQuery,
262
501
  searchLabel: searchConfig.label,
263
502
  searchPlaceholder: searchConfig.placeholder,
264
- isSearchDebouncing
503
+ isSearchDebouncing,
504
+ activeQueryParamsToken
265
505
  });
266
506
  }
267
507
 
@@ -1,5 +1,5 @@
1
- import { computed } from "vue";
2
- import { useInfiniteQuery } from "@tanstack/vue-query";
1
+ import { computed, unref } from "vue";
2
+ import { useInfiniteQuery, useQueryClient } from "@tanstack/vue-query";
3
3
  import { asPlainObject } from "./scopeHelpers.js";
4
4
  import { resolveEnabledRef } from "./refValueHelpers.js";
5
5
  import { toQueryErrorMessage } from "./errorMessageHelpers.js";
@@ -12,6 +12,42 @@ function defaultGetNextPageParam(lastPage) {
12
12
  return lastPage?.nextCursor ?? null;
13
13
  }
14
14
 
15
+ function normalizeQueryKeyValue(queryKey = null) {
16
+ const resolved = unref(queryKey);
17
+ if (Array.isArray(resolved)) {
18
+ return resolved;
19
+ }
20
+ if (resolved === null || resolved === undefined || resolved === "") {
21
+ return [];
22
+ }
23
+
24
+ return [resolved];
25
+ }
26
+
27
+ function trimInfinitePagesToFirst(data = null) {
28
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
29
+ return data;
30
+ }
31
+
32
+ const sourcePages = Array.isArray(data.pages) ? data.pages : [];
33
+ const sourcePageParams = Array.isArray(data.pageParams) ? data.pageParams : [];
34
+ if (sourcePages.length < 2 && sourcePageParams.length < 2) {
35
+ return data;
36
+ }
37
+ const pages = sourcePages.slice(0, 1);
38
+ const pageParams = sourcePageParams.length > 0
39
+ ? sourcePageParams.slice(0, 1)
40
+ : pages.length > 0
41
+ ? [null]
42
+ : [];
43
+
44
+ return {
45
+ ...data,
46
+ pages,
47
+ pageParams
48
+ };
49
+ }
50
+
15
51
  function usePagedCollection({
16
52
  queryKey,
17
53
  enabled = true,
@@ -36,7 +72,9 @@ function usePagedCollection({
36
72
  throw new TypeError("usePagedCollection dedupeBy must be a function when provided.");
37
73
  }
38
74
 
75
+ const queryClient = useQueryClient();
39
76
  const queryEnabled = computed(() => resolveEnabledRef(enabled));
77
+ const normalizedQueryKey = computed(() => normalizeQueryKeyValue(queryKey));
40
78
 
41
79
  const query = useInfiniteQuery({
42
80
  queryKey,
@@ -106,6 +144,15 @@ function usePagedCollection({
106
144
  return query.fetchNextPage();
107
145
  }
108
146
 
147
+ function trimToFirstPage() {
148
+ const key = normalizedQueryKey.value;
149
+ if (key.length < 1) {
150
+ return;
151
+ }
152
+
153
+ queryClient.setQueryData(key, (data) => trimInfinitePagesToFirst(data));
154
+ }
155
+
109
156
  return Object.freeze({
110
157
  query,
111
158
  pages,
@@ -118,8 +165,12 @@ function usePagedCollection({
118
165
  hasMore,
119
166
  loadError,
120
167
  reload,
121
- loadMore
168
+ loadMore,
169
+ trimToFirstPage
122
170
  });
123
171
  }
124
172
 
125
- export { usePagedCollection };
173
+ export {
174
+ usePagedCollection,
175
+ trimInfinitePagesToFirst
176
+ };
@@ -6,7 +6,8 @@ import { useEndpointResource } from "./useEndpointResource.js";
6
6
  import { resolveOperationAdapter } from "./operationAdapters.js";
7
7
  import { setupOperationErrorReporting } from "./operationUiHelpers.js";
8
8
  import { createViewUiRuntime } from "./viewUiRuntime.js";
9
- import { resolveLookupFieldDisplayValue } from "./crudLookupFieldLabelSupport.js";
9
+ import { resolveLookupFieldDisplayValue, resolveRecordTitle } from "./crudLookupFieldLabelSupport.js";
10
+ import { resolveRouteParamNamesInOrder } from "./routeTemplateHelpers.js";
10
11
 
11
12
  function useView({
12
13
  ownershipFilter = USERS_ROUTE_VISIBILITY_WORKSPACE,
@@ -37,6 +38,7 @@ function useView({
37
38
  const viewUiRuntime = createViewUiRuntime({
38
39
  recordIdParam,
39
40
  routeParams: routeParams ?? computed(() => route?.params || {}),
41
+ routeParamNames: computed(() => resolveRouteParamNamesInOrder(route)),
40
42
  routePath: computed(() => route?.path || ""),
41
43
  routeRecordId,
42
44
  apiUrlTemplate,
@@ -114,6 +116,7 @@ function useView({
114
116
  editUrl: viewUiRuntime.editUrl,
115
117
  resolveParams: viewUiRuntime.resolveParams,
116
118
  resolveFieldDisplay: resolveLookupFieldDisplayValue,
119
+ resolveRecordTitle,
117
120
  canView,
118
121
  isLoading,
119
122
  isFetching,
@@ -3,7 +3,7 @@ import { asPlainObject } from "./scopeHelpers.js";
3
3
  import {
4
4
  normalizeRouteParamName,
5
5
  resolveRouteParamsSource,
6
- resolveRoutePathnameSource,
6
+ resolveScopedRoutePathname,
7
7
  resolveRouteTemplateLocation,
8
8
  toRouteParamValue
9
9
  } from "./routeTemplateHelpers.js";
@@ -22,6 +22,7 @@ function resolveRecordId({ routeParams, recordIdParam, routeRecordId }) {
22
22
  function createViewUiRuntime({
23
23
  recordIdParam = "recordId",
24
24
  routeParams = null,
25
+ routeParamNames = null,
25
26
  routePath = "",
26
27
  routeRecordId = null,
27
28
  apiUrlTemplate = "",
@@ -53,10 +54,18 @@ function createViewUiRuntime({
53
54
  routeRecordId
54
55
  });
55
56
  sourceParams[normalizedRecordIdParam] = resolvedRecordId;
57
+ const currentPathname = resolveScopedRoutePathname({
58
+ currentPathname: routePath,
59
+ params: currentRouteParams,
60
+ orderedParamNames: routeParamNames,
61
+ anchorParamName: normalizedRecordIdParam,
62
+ anchorParamValue: resolvedRecordId,
63
+ anchorMode: "at"
64
+ });
56
65
 
57
66
  return resolveRouteTemplateLocation(normalizedTemplate, {
58
67
  params: sourceParams,
59
- currentPathname: resolveRoutePathnameSource(routePath)
68
+ currentPathname
60
69
  });
61
70
  }
62
71
 
@@ -30,7 +30,7 @@ function normalizeWorkspaceEntry(entry) {
30
30
  surfaceColor: String(entry.surfaceColor || "").trim(),
31
31
  surfaceVariantColor: String(entry.surfaceVariantColor || "").trim(),
32
32
  avatarUrl: String(entry.avatarUrl || "").trim(),
33
- roleId: String(entry.roleId || "member").trim().toLowerCase() || "member",
33
+ roleSid: String(entry.roleSid || "member").trim().toLowerCase() || "member",
34
34
  isAccessible: entry.isAccessible !== false
35
35
  });
36
36
  }
@@ -1,3 +1,4 @@
1
+ import * as mdiIcons from "@mdi/js";
1
2
  import {
2
3
  mdiAccountCircleOutline,
3
4
  mdiAccountCogOutline,
@@ -26,6 +27,26 @@ const SURFACE_SWITCH_ICON_BY_ID = Object.freeze({
26
27
  console: mdiConsoleNetworkOutline
27
28
  });
28
29
 
30
+ function resolveExplicitIconValue(explicitIcon = "") {
31
+ const normalizedExplicitIcon = normalizeText(explicitIcon);
32
+ if (!normalizedExplicitIcon) {
33
+ return "";
34
+ }
35
+
36
+ if (!normalizedExplicitIcon.startsWith("mdi-")) {
37
+ return normalizedExplicitIcon;
38
+ }
39
+
40
+ const iconKey = normalizedExplicitIcon
41
+ .slice("mdi-".length)
42
+ .split("-")
43
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
44
+ .join("");
45
+ const exportName = `mdi${iconKey}`;
46
+ const resolvedIcon = mdiIcons[exportName];
47
+ return typeof resolvedIcon === "string" && resolvedIcon ? resolvedIcon : normalizedExplicitIcon;
48
+ }
49
+
29
50
  function normalizePathname(value) {
30
51
  const normalizedValue = normalizeText(value);
31
52
  if (!normalizedValue) {
@@ -70,9 +91,9 @@ function resolveSurfaceSwitchIdFromLabel(label = "") {
70
91
  }
71
92
 
72
93
  function resolveSurfaceSwitchIcon(surfaceId = "", explicitIcon = "") {
73
- const normalizedExplicitIcon = normalizeText(explicitIcon);
74
- if (normalizedExplicitIcon) {
75
- return normalizedExplicitIcon;
94
+ const resolvedExplicitIcon = resolveExplicitIconValue(explicitIcon);
95
+ if (resolvedExplicitIcon) {
96
+ return resolvedExplicitIcon;
76
97
  }
77
98
 
78
99
  const normalizedSurfaceId = normalizeText(surfaceId).toLowerCase();
@@ -80,9 +101,9 @@ function resolveSurfaceSwitchIcon(surfaceId = "", explicitIcon = "") {
80
101
  }
81
102
 
82
103
  function resolveMenuLinkIcon({ icon = "", label = "", to = "" } = {}) {
83
- const normalizedIcon = normalizeText(icon);
84
- if (normalizedIcon) {
85
- return normalizedIcon;
104
+ const resolvedExplicitIcon = resolveExplicitIconValue(icon);
105
+ if (resolvedExplicitIcon) {
106
+ return resolvedExplicitIcon;
86
107
  }
87
108
 
88
109
  const normalizedLabel = normalizeText(label).toLowerCase();
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { mdiAlertCircleOutline } from "@mdi/js";
2
3
  import { computed } from "vue";
3
4
 
4
5
  const props = defineProps({
@@ -20,7 +21,7 @@ const normalizedMessage = computed(() => String(props.message || "").trim() || "
20
21
  <v-card rounded="lg" elevation="1" border>
21
22
  <v-card-item>
22
23
  <template #prepend>
23
- <v-icon icon="mdi-alert-circle-outline" color="error" />
24
+ <v-icon :icon="mdiAlertCircleOutline" color="error" />
24
25
  </template>
25
26
  <v-card-title class="text-h5">Unavailable</v-card-title>
26
27
  <v-card-subtitle>{{ normalizedSurfaceLabel }} surface.</v-card-subtitle>
@@ -33,7 +33,7 @@ const invites = props.runtime.invites;
33
33
  v-for="invite in invites.items.value"
34
34
  :key="invite.id"
35
35
  :title="invite.workspaceName"
36
- :subtitle="`/${invite.workspaceSlug} • role: ${invite.roleId}`"
36
+ :subtitle="`/${invite.workspaceSlug} • role: ${invite.roleSid}`"
37
37
  class="px-0"
38
38
  >
39
39
  <template #prepend>
@@ -51,6 +51,24 @@ test("createAddEditUiRuntime resolves edit-page relative list and cancel links",
51
51
  assert.equal(runtime.cancelUrl.value, "/contacts/7/addresses/42");
52
52
  });
53
53
 
54
+ test("createAddEditUiRuntime resolves nested edit links from the record scope", () => {
55
+ const runtime = createAddEditUiRuntime({
56
+ recordIdParam: "petId",
57
+ routeParams: ref({
58
+ workspaceSlug: "dogandgroom",
59
+ contactId: "541841",
60
+ petId: "715528"
61
+ }),
62
+ routeParamNames: ref(["workspaceSlug", "contactId", "petId"]),
63
+ routePath: ref("/w/dogandgroom/admin/contacts/541841/pets/715528/edit/advanced"),
64
+ viewUrlTemplate: "..",
65
+ listUrlTemplate: "../.."
66
+ });
67
+
68
+ assert.equal(runtime.cancelUrl.value, "/w/dogandgroom/admin/contacts/541841/pets/715528");
69
+ assert.equal(runtime.listUrl.value, "/w/dogandgroom/admin/contacts/541841/pets");
70
+ });
71
+
54
72
  test("createAddEditUiRuntime supports custom saved-record selector", () => {
55
73
  const runtime = createAddEditUiRuntime({
56
74
  recordIdParam: "addressId",