@likec4/core 1.34.2 → 1.36.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/README.md +1 -1
- package/dist/builder/index.d.mts +15 -4
- package/dist/builder/index.mjs +19 -15
- package/dist/compute-view/index.d.mts +3 -3
- package/dist/compute-view/index.mjs +16 -13
- package/dist/compute-view/relationships-view/index.d.mts +3 -3
- package/dist/compute-view/relationships-view/index.mjs +2 -2
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +14 -9
- package/dist/model/connection/deployment/index.d.mts +5 -5
- package/dist/model/connection/deployment/index.mjs +5 -4
- package/dist/model/connection/index.d.mts +6 -6
- package/dist/model/connection/index.mjs +6 -5
- package/dist/model/connection/model/index.d.mts +5 -5
- package/dist/model/connection/model/index.mjs +3 -3
- package/dist/model/index.d.mts +33 -9
- package/dist/model/index.mjs +12 -10
- package/dist/shared/{core.DAj3LuFH.d.mts → core.BFb3p5d-.d.mts} +4 -2
- package/dist/shared/{core.PqNe5WxQ.mjs → core.BlwZ4tBa.mjs} +98 -92
- package/dist/shared/core.BpJu1RrF.mjs +5 -0
- package/dist/shared/{core.CJSFKrYf.d.mts → core.Bt3kuGUN.d.mts} +3 -3
- package/dist/shared/{core.D5qVzkws.mjs → core.Bus32P0S.mjs} +4 -3
- package/dist/shared/{core.4SpVCM9m.mjs → core.BvIGRozf.mjs} +364 -28
- package/dist/shared/{core.C80Z37Eu.mjs → core.C1GV0WWV.mjs} +1 -1
- package/dist/shared/{core.DXRAwwDB.mjs → core.CaJp8Amg.mjs} +3 -2
- package/dist/shared/{core.D56nCp7e.mjs → core.CaaYNQDM.mjs} +1 -3
- package/dist/shared/core.CnIyafWn.mjs +3 -0
- package/dist/shared/{core.D3xEWj5B.d.mts → core.D8bTI5KC.d.mts} +2 -2
- package/dist/shared/{core.Di7__sMg.d.mts → core.DHYd2-VU.d.mts} +3 -3
- package/dist/shared/{core.C_C-3c1S.mjs → core.DQj9iv_y.mjs} +3 -5
- package/dist/shared/{core.DrS4Bf4y.d.mts → core.DoNP3Yl-.d.mts} +187 -11
- package/dist/shared/{core.IWLie-Ma.d.mts → core.DxDn5nB5.d.mts} +24 -6
- package/dist/shared/{core.CtKXPqN_.mjs → core.HUONfQfd.mjs} +1 -1
- package/dist/shared/{core.BTnf14gD.mjs → core.Y3j3s-oq.mjs} +2 -2
- package/dist/shared/{core.Br7Fab9l.mjs → core._jnYNUQ-.mjs} +8 -70
- package/dist/shared/{core.XptQjhMA.mjs → core.b2BzqGz0.mjs} +2 -2
- package/dist/shared/{core.BMCb0wJI.mjs → core.yaRvRnMW.mjs} +1 -1
- package/dist/theme/index.mjs +34 -3510
- package/dist/types/_aux.d.mts +1 -1
- package/dist/types/index.d.mts +3 -3
- package/dist/types/index.mjs +6 -4
- package/dist/types/scalar.d.mts +2 -0
- package/dist/types/scalar.mjs +68 -0
- package/dist/utils/index.d.mts +6 -8
- package/dist/utils/index.mjs +5 -5
- package/dist/utils/iterable/index.d.mts +18 -1
- package/dist/utils/iterable/index.mjs +22 -2
- package/package.json +11 -16
- package/src/builder/Builder.ts +17 -6
- package/src/builder/__snapshots__/Builder-style1.spec.ts.snap +9 -0
- package/src/builder/__snapshots__/Builder-style2.compute-model.json5 +3 -0
- package/src/builder/__snapshots__/Builder-style2.spec.ts.snap +3 -0
- package/src/builder/_types.ts +10 -0
- package/src/compute-view/dynamic-view/compute.ts +2 -0
- package/src/compute-view/element-view/__test__/fixture.ts +1 -0
- package/src/compute-view/element-view/predicates/relation-direct.ts +8 -2
- package/src/compute-view/utils/applyCustomRelationProperties.ts +2 -0
- package/src/model/DeploymentModel.ts +1 -1
- package/src/model/LikeC4Model.ts +162 -13
- package/src/model/index.ts +8 -0
- package/src/model/types.ts +1 -8
- package/src/model/utils.ts +66 -0
- package/src/model/view/LikeC4ViewModel.ts +62 -7
- package/src/model/view/LikeC4ViewsFolder.ts +151 -0
- package/src/theme/__test__/theme-index.ts +29 -0
- package/src/theme/element.ts +0 -19
- package/src/theme/index.ts +49 -3
- package/src/types/_aux.ts +6 -0
- package/src/types/index.ts +1 -0
- package/src/types/model-data.ts +2 -0
- package/src/types/model-dump.ts +8 -1
- package/src/types/model-spec.ts +4 -0
- package/src/types/project.ts +9 -0
- package/src/types/view-computed.ts +1 -1
- package/src/types/view-parsed.dynamic.ts +2 -2
- package/src/utils/compare-natural.ts +14 -12
- package/src/utils/index.ts +1 -0
- package/src/utils/iterable/first.ts +48 -0
- package/src/utils/iterable/index.ts +1 -0
package/src/model/LikeC4Model.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { entries, map, pipe, prop, sort, sortBy, values } from 'remeda'
|
|
1
|
+
import { entries, hasAtLeast, map, pipe, prop, sort, sortBy, split, values } from 'remeda'
|
|
2
2
|
import type {
|
|
3
3
|
Any,
|
|
4
4
|
Aux,
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
IteratorLike,
|
|
9
9
|
LayoutedLikeC4ModelData,
|
|
10
10
|
LikeC4ModelDump,
|
|
11
|
+
LikeC4Project,
|
|
11
12
|
ModelGlobals,
|
|
12
13
|
ParsedLikeC4ModelData,
|
|
13
14
|
Relationship,
|
|
@@ -17,7 +18,7 @@ import type {
|
|
|
17
18
|
} from '../types'
|
|
18
19
|
import { type ProjectId, _stage, GlobalFqn, isGlobalFqn, isOnStage, whereOperatorAsPredicate } from '../types'
|
|
19
20
|
import type * as aux from '../types/_aux'
|
|
20
|
-
import { compareNatural, ifilter, invariant, memoizeProp, nonNullable } from '../utils'
|
|
21
|
+
import { compareNatural, compareNaturalHierarchically, ifilter, invariant, memoizeProp, nonNullable } from '../utils'
|
|
21
22
|
import { ancestorsFqn, commonAncestor, parentFqn, sortParentsFirst } from '../utils/fqn'
|
|
22
23
|
import { DefaultMap } from '../utils/mnemonist'
|
|
23
24
|
import type {
|
|
@@ -36,9 +37,10 @@ import {
|
|
|
36
37
|
type IncomingFilter,
|
|
37
38
|
type OutgoingFilter,
|
|
38
39
|
type RelationOrId,
|
|
39
|
-
getId,
|
|
40
40
|
} from './types'
|
|
41
|
+
import { getId, getViewFolderPath, normalizeViewPath, VIEW_FOLDERS_SEPARATOR } from './utils'
|
|
41
42
|
import { LikeC4ViewModel } from './view/LikeC4ViewModel'
|
|
43
|
+
import { LikeC4ViewsFolder } from './view/LikeC4ViewsFolder'
|
|
42
44
|
import type { NodeModel } from './view/NodeModel'
|
|
43
45
|
|
|
44
46
|
export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
@@ -68,6 +70,16 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
|
68
70
|
|
|
69
71
|
protected readonly _views = new Map<aux.ViewId<A>, LikeC4ViewModel<A>>()
|
|
70
72
|
|
|
73
|
+
protected readonly _rootViewFolder: LikeC4ViewsFolder<A>
|
|
74
|
+
protected readonly _viewFolders = new Map<string, LikeC4ViewsFolder<A>>()
|
|
75
|
+
|
|
76
|
+
protected readonly _viewFolderItems = new DefaultMap<
|
|
77
|
+
string,
|
|
78
|
+
Set<LikeC4ViewsFolder<A> | LikeC4ViewModel<A>>
|
|
79
|
+
>(
|
|
80
|
+
() => new Set(),
|
|
81
|
+
)
|
|
82
|
+
|
|
71
83
|
protected readonly _allTags = new DefaultMap<
|
|
72
84
|
aux.Tag<A>,
|
|
73
85
|
Set<ElementModel<A> | RelationshipModel<A> | LikeC4ViewModel<A>>
|
|
@@ -108,6 +120,7 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
|
108
120
|
const {
|
|
109
121
|
_stage: stage = 'layouted',
|
|
110
122
|
projectId = 'unknown',
|
|
123
|
+
project,
|
|
111
124
|
globals,
|
|
112
125
|
imports,
|
|
113
126
|
deployments,
|
|
@@ -119,6 +132,7 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
|
119
132
|
return new LikeC4Model({
|
|
120
133
|
[_stage]: stage as 'layouted',
|
|
121
134
|
projectId,
|
|
135
|
+
project,
|
|
122
136
|
globals: {
|
|
123
137
|
predicates: globals?.predicates ?? {},
|
|
124
138
|
dynamicPredicates: globals?.dynamicPredicates ?? {},
|
|
@@ -165,17 +179,65 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
|
165
179
|
this.deployment = new LikeC4DeploymentModel(this)
|
|
166
180
|
|
|
167
181
|
if (isOnStage($data, 'computed') || isOnStage($data, 'layouted')) {
|
|
182
|
+
const compare = compareNaturalHierarchically(VIEW_FOLDERS_SEPARATOR)
|
|
168
183
|
const views = pipe(
|
|
169
184
|
values($data.views as Record<string, $View<A>>),
|
|
170
|
-
|
|
185
|
+
map(view => ({
|
|
186
|
+
view,
|
|
187
|
+
path: normalizeViewPath(view.title ?? view.id),
|
|
188
|
+
folderPath: view.title && getViewFolderPath(view.title) || '',
|
|
189
|
+
})),
|
|
190
|
+
// Sort hierarchically by groups, but keep same order within groups
|
|
191
|
+
sort((a, b) => compare(a.folderPath, b.folderPath)),
|
|
171
192
|
)
|
|
172
|
-
|
|
173
|
-
|
|
193
|
+
|
|
194
|
+
const getOrCreateFolder = (path: string) => {
|
|
195
|
+
let folder = this._viewFolders.get(path)
|
|
196
|
+
if (!folder) {
|
|
197
|
+
const segments = split(path, VIEW_FOLDERS_SEPARATOR)
|
|
198
|
+
invariant(hasAtLeast(segments, 1), `View group path "${path}" must have at least one element`)
|
|
199
|
+
let defaultView
|
|
200
|
+
// Root group has "index" as default view
|
|
201
|
+
if (path === '') {
|
|
202
|
+
defaultView = views.find(view => view.view.id === 'index')
|
|
203
|
+
} else {
|
|
204
|
+
defaultView = views.find(view => view.path === path)
|
|
205
|
+
}
|
|
206
|
+
folder = new LikeC4ViewsFolder(this, segments, defaultView?.view.id)
|
|
207
|
+
this._viewFolders.set(path, folder)
|
|
208
|
+
}
|
|
209
|
+
return folder
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this._rootViewFolder = getOrCreateFolder('')
|
|
213
|
+
|
|
214
|
+
// Process view groups
|
|
215
|
+
// Sort in natural order to preserve hierarchy
|
|
216
|
+
for (const { folderPath } of views) {
|
|
217
|
+
if (this._viewFolders.has(folderPath)) {
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
// Create groups for each segment of the path
|
|
221
|
+
split(folderPath, VIEW_FOLDERS_SEPARATOR).reduce((parent, segment) => {
|
|
222
|
+
const path = [...parent, segment]
|
|
223
|
+
const folder = getOrCreateFolder(path.join(VIEW_FOLDERS_SEPARATOR))
|
|
224
|
+
this._viewFolderItems.get(parent.join(VIEW_FOLDERS_SEPARATOR)).add(folder)
|
|
225
|
+
return path
|
|
226
|
+
}, [] as string[])
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const { view, folderPath } of views) {
|
|
230
|
+
const vm = new LikeC4ViewModel(this, view, getOrCreateFolder(folderPath))
|
|
231
|
+
this._viewFolderItems.get(folderPath).add(vm)
|
|
174
232
|
this._views.set(view.id, vm)
|
|
175
233
|
for (const tag of vm.tags) {
|
|
176
234
|
this._allTags.get(tag).add(vm)
|
|
177
235
|
}
|
|
178
236
|
}
|
|
237
|
+
} else {
|
|
238
|
+
// Model is not computed or layouted, but we still need to create root folder
|
|
239
|
+
this._rootViewFolder = new LikeC4ViewsFolder(this, [''], undefined)
|
|
240
|
+
this._viewFolders.set(this._rootViewFolder.path, this._rootViewFolder)
|
|
179
241
|
}
|
|
180
242
|
}
|
|
181
243
|
|
|
@@ -236,6 +298,12 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
|
236
298
|
return this.$data.projectId ?? 'unknown' as any
|
|
237
299
|
}
|
|
238
300
|
|
|
301
|
+
get project(): LikeC4Project {
|
|
302
|
+
return this.$data.project ?? memoizeProp(this, Symbol.for('project'), () => ({
|
|
303
|
+
id: this.projectId as unknown as scalar.ProjectId,
|
|
304
|
+
}))
|
|
305
|
+
}
|
|
306
|
+
|
|
239
307
|
get specification(): Specification<A> {
|
|
240
308
|
return this.$data.specification
|
|
241
309
|
}
|
|
@@ -254,6 +322,21 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
|
254
322
|
}))
|
|
255
323
|
}
|
|
256
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Returns the element with the given FQN.
|
|
327
|
+
*
|
|
328
|
+
* @throws Error if element is not found\
|
|
329
|
+
* Use {@link findElement} if you don't want to throw an error
|
|
330
|
+
*
|
|
331
|
+
* @note Method is type-safe for typed model
|
|
332
|
+
|
|
333
|
+
* @example
|
|
334
|
+
* model.element('cloud.frontend')
|
|
335
|
+
* // or object with id property of scalar.Fqn
|
|
336
|
+
* model.element({
|
|
337
|
+
* id: 'dashboard',
|
|
338
|
+
* })
|
|
339
|
+
*/
|
|
257
340
|
public element(el: ElementOrFqn<A>): ElementModel<A> {
|
|
258
341
|
if (el instanceof ElementModel) {
|
|
259
342
|
return el
|
|
@@ -261,6 +344,16 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
|
261
344
|
const id = getId(el)
|
|
262
345
|
return nonNullable(this._elements.get(id), `Element ${id} not found`)
|
|
263
346
|
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Returns the element with the given FQN.
|
|
350
|
+
*
|
|
351
|
+
* @returns Element if found, null otherwise
|
|
352
|
+
* @note Method is not type-safe as {@link element}
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* model.findElement('cloud.frontend')
|
|
356
|
+
*/
|
|
264
357
|
public findElement(el: aux.LooseElementId<A>): ElementModel<A> | null {
|
|
265
358
|
return this._elements.get(getId(el)) ?? null
|
|
266
359
|
}
|
|
@@ -342,15 +435,69 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
|
342
435
|
|
|
343
436
|
/**
|
|
344
437
|
* Returns a specific view by its ID.
|
|
438
|
+
* @note Method is type-safe for typed model
|
|
439
|
+
* @throws Error if view is not found\
|
|
440
|
+
* Use {@link findView} if you don't want to throw an error
|
|
441
|
+
*
|
|
442
|
+
* @example
|
|
443
|
+
* model.view('index')
|
|
444
|
+
* // or object with id property of scalar.ViewId
|
|
445
|
+
* model.view({
|
|
446
|
+
* id: 'index',
|
|
447
|
+
* })
|
|
345
448
|
*/
|
|
346
449
|
public view(viewId: aux.ViewId<A> | { id: scalar.ViewId<aux.ViewId<A>> }): LikeC4ViewModel<A, $View<A>> {
|
|
347
450
|
const id = getId(viewId)
|
|
348
451
|
return nonNullable(this._views.get(id), `View ${id} not found`)
|
|
349
452
|
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Returns a specific view by its ID.
|
|
456
|
+
* @note Method is not type-safe as {@link view}
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* model.findView('index')
|
|
460
|
+
*/
|
|
350
461
|
public findView(viewId: aux.LooseViewId<A>): LikeC4ViewModel<A, $View<A>> | null {
|
|
351
462
|
return this._views.get(viewId as aux.ViewId<A>) ?? null
|
|
352
463
|
}
|
|
353
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Returns a view folder by its path.
|
|
467
|
+
* Path is extracted from the view title, e.g. "Group 1/Group 2/View" -> "Group 1/Group 2"
|
|
468
|
+
* @throws Error if view folder is not found.
|
|
469
|
+
*/
|
|
470
|
+
public viewFolder(path: string): LikeC4ViewsFolder<A> {
|
|
471
|
+
return nonNullable(this._viewFolders.get(path), `View folder ${path} not found`)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Root folder is a special one with an empty path and used only for internal purposes.
|
|
476
|
+
* It is not visible to the user and should be used only to get top-level folders and views.
|
|
477
|
+
*/
|
|
478
|
+
get rootViewFolder(): LikeC4ViewsFolder<A> {
|
|
479
|
+
return this._rootViewFolder
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Whether the model has any view folders.
|
|
484
|
+
*/
|
|
485
|
+
get hasViewFolders(): boolean {
|
|
486
|
+
// Root view folder is always present
|
|
487
|
+
return this._viewFolders.size > 1
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Returns all children of a view folder.
|
|
492
|
+
* Path is extracted from the view title, e.g. "Group 1/Group 2/View" -> "Group 1/Group 2"
|
|
493
|
+
*
|
|
494
|
+
* @throws Error if view folder is not found.
|
|
495
|
+
*/
|
|
496
|
+
public viewFolderItems(path: string): ReadonlySet<LikeC4ViewsFolder<A> | LikeC4ViewModel<A>> {
|
|
497
|
+
invariant(this._viewFolders.has(path), `View folder ${path} not found`)
|
|
498
|
+
return this._viewFolderItems.get(path)
|
|
499
|
+
}
|
|
500
|
+
|
|
354
501
|
/**
|
|
355
502
|
* Returns the parent element of given element.
|
|
356
503
|
* @see ancestors
|
|
@@ -452,7 +599,8 @@ export class LikeC4Model<A extends Any = aux.Unknown> {
|
|
|
452
599
|
}
|
|
453
600
|
|
|
454
601
|
/**
|
|
455
|
-
* Returns all tags used in the model, sorted
|
|
602
|
+
* Returns array of all tags used in the model, sorted naturally.\
|
|
603
|
+
* Use {@link specification.tags} to get all defined tags
|
|
456
604
|
*/
|
|
457
605
|
get tags(): aux.Tags<A> {
|
|
458
606
|
return memoizeProp(this, 'tags', () => sort([...this._allTags.keys()], compareNatural))
|
|
@@ -667,6 +815,7 @@ export namespace LikeC4Model {
|
|
|
667
815
|
export const EMPTY = LikeC4Model.create<aux.UnknownComputed>({
|
|
668
816
|
_stage: 'computed',
|
|
669
817
|
projectId: 'default' as never,
|
|
818
|
+
project: { id: 'default' as never },
|
|
670
819
|
specification: {
|
|
671
820
|
elements: {},
|
|
672
821
|
relationships: {},
|
|
@@ -692,24 +841,24 @@ export namespace LikeC4Model {
|
|
|
692
841
|
// dprint-ignore
|
|
693
842
|
aux.Unknown extends A
|
|
694
843
|
? LikeC4Model<aux.UnknownParsed>
|
|
695
|
-
: A extends Aux<any, infer E, infer D, infer V, infer
|
|
696
|
-
? LikeC4Model<Aux<'parsed', E, D, V,
|
|
844
|
+
: A extends Aux<any, infer E, infer D, infer V, infer PID, infer Spec>
|
|
845
|
+
? LikeC4Model<Aux<'parsed', E, D, V, PID, Spec>>
|
|
697
846
|
: never
|
|
698
847
|
|
|
699
848
|
export type Computed<A = aux.Unknown> =
|
|
700
849
|
// dprint-ignore
|
|
701
850
|
aux.Unknown extends A
|
|
702
851
|
? LikeC4Model<aux.UnknownComputed>
|
|
703
|
-
: A extends Aux<any, infer E, infer D, infer V, infer
|
|
704
|
-
? LikeC4Model<Aux<'computed', E, D, V,
|
|
852
|
+
: A extends Aux<any, infer E, infer D, infer V, infer PID, infer Spec>
|
|
853
|
+
? LikeC4Model<Aux<'computed', E, D, V, PID, Spec>>
|
|
705
854
|
: never
|
|
706
855
|
|
|
707
856
|
export type Layouted<A = aux.Unknown> =
|
|
708
857
|
// dprint-ignore
|
|
709
858
|
aux.Unknown extends A
|
|
710
859
|
? LikeC4Model<aux.UnknownLayouted>
|
|
711
|
-
: A extends Aux<any, infer E, infer D, infer V, infer
|
|
712
|
-
? LikeC4Model<Aux<'layouted', E, D, V,
|
|
860
|
+
: A extends Aux<any, infer E, infer D, infer V, infer PID, infer Spec>
|
|
861
|
+
? LikeC4Model<Aux<'layouted', E, D, V, PID, Spec>>
|
|
713
862
|
: never
|
|
714
863
|
|
|
715
864
|
export type Node<A = aux.Unknown> = A extends aux.AnyAux ? NodeModel<A> : never
|
package/src/model/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export { LikeC4DeploymentModel } from './DeploymentModel'
|
|
|
36
36
|
|
|
37
37
|
export { EdgeModel } from './view/EdgeModel'
|
|
38
38
|
export { LikeC4ViewModel } from './view/LikeC4ViewModel'
|
|
39
|
+
export { LikeC4ViewsFolder } from './view/LikeC4ViewsFolder'
|
|
39
40
|
export { NodeModel } from './view/NodeModel'
|
|
40
41
|
|
|
41
42
|
export type {
|
|
@@ -62,3 +63,10 @@ export type {
|
|
|
62
63
|
} from '../types'
|
|
63
64
|
|
|
64
65
|
export { RichText, type RichTextEmpty, type RichTextOrEmpty } from '../types'
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
extractViewTitleFromPath,
|
|
69
|
+
getViewFolderPath,
|
|
70
|
+
normalizeViewPath,
|
|
71
|
+
VIEW_FOLDERS_SEPARATOR,
|
|
72
|
+
} from './utils'
|
package/src/model/types.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { LikeC4ViewModel } from './view/LikeC4ViewModel'
|
|
|
16
16
|
export type IncomingFilter = 'all' | 'direct' | 'to-descendants'
|
|
17
17
|
export type OutgoingFilter = 'all' | 'direct' | 'from-descendants'
|
|
18
18
|
|
|
19
|
-
type AnyScalar<V extends string> =
|
|
19
|
+
export type AnyScalar<V extends string> =
|
|
20
20
|
| Tagged<V, 'Fqn'>
|
|
21
21
|
| Tagged<V, 'DeploymentFqn'>
|
|
22
22
|
| Tagged<V, 'ViewId'>
|
|
@@ -24,13 +24,6 @@ type AnyScalar<V extends string> =
|
|
|
24
24
|
| Tagged<V, 'EdgeId'>
|
|
25
25
|
| Tagged<V, 'RelationId'>
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
* Utility function to extract `id` from the given element.
|
|
29
|
-
*/
|
|
30
|
-
export function getId<Id extends string, Scalar extends AnyScalar<Id>>(element: Id | { id: Scalar }): Scalar {
|
|
31
|
-
return typeof element === 'string' ? element as unknown as Scalar : element.id
|
|
32
|
-
}
|
|
33
|
-
|
|
34
27
|
export type ElementOrFqn<A extends AnyAux> = aux.ElementId<A> | { id: aux.Fqn<A> }
|
|
35
28
|
|
|
36
29
|
export type DeploymentOrFqn<A extends AnyAux> = aux.DeploymentId<A> | { id: aux.DeploymentFqn<A> }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { hasAtLeast } from 'remeda'
|
|
2
|
+
import type { NonEmptyArray } from '../types'
|
|
3
|
+
import { invariant } from '../utils'
|
|
4
|
+
import type { AnyScalar } from './types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Utility function to extract `id` from the given element.
|
|
8
|
+
*/
|
|
9
|
+
export const getId = <Id extends string, Scalar extends AnyScalar<Id>>(element: Id | { id: Scalar }): Scalar => {
|
|
10
|
+
return typeof element === 'string' ? element as unknown as Scalar : element.id
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const VIEW_FOLDERS_SEPARATOR = '/'
|
|
14
|
+
|
|
15
|
+
const splitViewTitle = (title: string): NonEmptyArray<string> => {
|
|
16
|
+
invariant(!title.includes('\n'), 'View title cannot contain newlines')
|
|
17
|
+
if (title.includes(VIEW_FOLDERS_SEPARATOR)) {
|
|
18
|
+
const segments = title
|
|
19
|
+
.split(VIEW_FOLDERS_SEPARATOR)
|
|
20
|
+
.map(s => s.trim())
|
|
21
|
+
.filter(s => s.length > 0)
|
|
22
|
+
if (hasAtLeast(segments, 1)) {
|
|
23
|
+
return segments
|
|
24
|
+
}
|
|
25
|
+
return ['']
|
|
26
|
+
}
|
|
27
|
+
return [title.trim()]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalizes view path by removing spaces from segments, removing empty segments,
|
|
32
|
+
* and removing leading/trailing slashes
|
|
33
|
+
* @example
|
|
34
|
+
* normalizeViewPath('One / Tw o / Thre e') === 'One/Tw o/Thre e'
|
|
35
|
+
*/
|
|
36
|
+
export const normalizeViewPath = (title: string): string => {
|
|
37
|
+
return splitViewTitle(title).join(VIEW_FOLDERS_SEPARATOR)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns view group path if it is used as a path
|
|
42
|
+
* Returns empty string if it is not a path
|
|
43
|
+
* @example
|
|
44
|
+
* getViewFolderPath('One / Tw o / Thre e') === 'One/Tw o'
|
|
45
|
+
* getViewFolderPath('One') === ''
|
|
46
|
+
*/
|
|
47
|
+
export const getViewFolderPath = (title: string): string | null => {
|
|
48
|
+
const segments = splitViewTitle(title)
|
|
49
|
+
if (!hasAtLeast(segments, 2)) {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
return segments.slice(0, -1).join(VIEW_FOLDERS_SEPARATOR)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns view title if it is used as a path
|
|
57
|
+
* @example
|
|
58
|
+
* getViewTitleFromPath('One / Tw o / Thre e') === 'Thre e'
|
|
59
|
+
* getViewTitleFromPath('One') === 'One'
|
|
60
|
+
*/
|
|
61
|
+
export const extractViewTitleFromPath = (title: string): string => {
|
|
62
|
+
if (!title.includes(VIEW_FOLDERS_SEPARATOR)) {
|
|
63
|
+
return title.trim()
|
|
64
|
+
}
|
|
65
|
+
return splitViewTitle(title).pop() ?? title
|
|
66
|
+
}
|
|
@@ -7,10 +7,9 @@ import type {
|
|
|
7
7
|
scalar,
|
|
8
8
|
ViewWithType,
|
|
9
9
|
} from '../../types'
|
|
10
|
-
import { _stage, _type } from '../../types'
|
|
11
|
-
import { type RichTextOrEmpty, RichText } from '../../types'
|
|
10
|
+
import { type RichTextOrEmpty, _stage, _type, RichText } from '../../types'
|
|
12
11
|
import type * as aux from '../../types/_aux'
|
|
13
|
-
import { DefaultMap, ifind, nonNullable } from '../../utils'
|
|
12
|
+
import { DefaultMap, ifind, memoizeProp, nonNullable } from '../../utils'
|
|
14
13
|
import type { ElementModel } from '../ElementModel'
|
|
15
14
|
import type { LikeC4Model } from '../LikeC4Model'
|
|
16
15
|
import {
|
|
@@ -18,9 +17,10 @@ import {
|
|
|
18
17
|
type EdgeOrId,
|
|
19
18
|
type NodeOrId,
|
|
20
19
|
type WithTags,
|
|
21
|
-
getId,
|
|
22
20
|
} from '../types'
|
|
21
|
+
import { extractViewTitleFromPath, getId, normalizeViewPath } from '../utils'
|
|
23
22
|
import { type EdgesIterator, EdgeModel } from './EdgeModel'
|
|
23
|
+
import type { LikeC4ViewsFolder } from './LikeC4ViewsFolder'
|
|
24
24
|
import { type NodesIterator, NodeModel } from './NodeModel'
|
|
25
25
|
|
|
26
26
|
export type ViewsIterator<A extends Any, V extends $View<A> = $View<A>> = IteratorLike<LikeC4ViewModel<A, V>>
|
|
@@ -44,9 +44,29 @@ export class LikeC4ViewModel<A extends Any = Any, V extends $View<A> = $View<A>>
|
|
|
44
44
|
public readonly $view: V
|
|
45
45
|
public readonly $model: LikeC4Model<A>
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
public readonly title: string | null
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* View folder this view belongs to.
|
|
51
|
+
* If view is top-level, this is the root folder.
|
|
52
|
+
*/
|
|
53
|
+
public readonly folder: LikeC4ViewsFolder<A>
|
|
54
|
+
/**
|
|
55
|
+
* Path to this view, processed by {@link normalizeViewPath}
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* "Group 1/Group 2/View"
|
|
59
|
+
*/
|
|
60
|
+
public readonly viewPath: string
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
model: LikeC4Model<A>,
|
|
64
|
+
view: V,
|
|
65
|
+
folder: LikeC4ViewsFolder<A>,
|
|
66
|
+
) {
|
|
48
67
|
this.$model = model
|
|
49
68
|
this.$view = view
|
|
69
|
+
this.folder = folder
|
|
50
70
|
for (const node of view.nodes) {
|
|
51
71
|
const el = new NodeModel<A, V>(this, Object.freeze(node))
|
|
52
72
|
this.#nodes.set(node.id, el)
|
|
@@ -79,6 +99,9 @@ export class LikeC4ViewModel<A extends Any = Any, V extends $View<A> = $View<A>>
|
|
|
79
99
|
}
|
|
80
100
|
this.#edges.set(edge.id, edgeModel)
|
|
81
101
|
}
|
|
102
|
+
|
|
103
|
+
this.title = this.$view.title ? extractViewTitleFromPath(this.$view.title) : null
|
|
104
|
+
this.viewPath = this.$view.title ? normalizeViewPath(this.$view.title) : this.$view.id
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
get _type(): V[_type] {
|
|
@@ -89,8 +112,40 @@ export class LikeC4ViewModel<A extends Any = Any, V extends $View<A> = $View<A>>
|
|
|
89
112
|
return this.$view.id
|
|
90
113
|
}
|
|
91
114
|
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Returns title if defined, otherwise returns its {@link id}
|
|
117
|
+
*/
|
|
118
|
+
get titleOrId(): string {
|
|
119
|
+
return this.title ?? this.id
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns title if defined, otherwise returns `Untitled`.
|
|
124
|
+
*/
|
|
125
|
+
get titleOrUntitled(): string {
|
|
126
|
+
return this.title ?? 'Untitled'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Returns path to this view as an array of groups and this view as the last element
|
|
131
|
+
* If view is top-level, returns only this view.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* viewPath = "Group 1/Group 2/View"
|
|
135
|
+
*
|
|
136
|
+
* breadcrumbs = [
|
|
137
|
+
* "Group 1", // folder
|
|
138
|
+
* "Group 1/Group 2", // folder
|
|
139
|
+
* "Group 1/Group 2/View" // view
|
|
140
|
+
* ]
|
|
141
|
+
*/
|
|
142
|
+
get breadcrumbs(): [...LikeC4ViewsFolder<A>[], this] {
|
|
143
|
+
return memoizeProp(this, 'breadcrumbs', () => {
|
|
144
|
+
if (!this.folder.isRoot) {
|
|
145
|
+
return [...this.folder.breadcrumbs, this]
|
|
146
|
+
}
|
|
147
|
+
return [this]
|
|
148
|
+
})
|
|
94
149
|
}
|
|
95
150
|
|
|
96
151
|
get description(): RichTextOrEmpty {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isEmpty, last } from 'remeda'
|
|
2
|
+
import type { aux, NonEmptyArray } from '../../types'
|
|
3
|
+
import { invariant, memoizeProp } from '../../utils'
|
|
4
|
+
import type { LikeC4Model } from '../LikeC4Model'
|
|
5
|
+
import { LikeC4ViewModel } from './LikeC4ViewModel'
|
|
6
|
+
|
|
7
|
+
export class LikeC4ViewsFolder<A extends aux.Any = aux.Any> {
|
|
8
|
+
public readonly $model: LikeC4Model<A>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Path to this view folder, processed by {@link normalizeViewPath}
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* "Folder 1/Folder 2/Folder 3"
|
|
15
|
+
*/
|
|
16
|
+
public readonly path: string
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Title of this view folder.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* // title is the last segment of the path
|
|
23
|
+
* path = "Folder 1/Folder 2/Folder 3"
|
|
24
|
+
* title = "Folder 3"
|
|
25
|
+
*/
|
|
26
|
+
public readonly title: string
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether this is the root view folder.
|
|
30
|
+
*
|
|
31
|
+
* !NOTE
|
|
32
|
+
* Root folder is special folder with an empty path and used only for internal purposes. \
|
|
33
|
+
* It is not visible to the user and should not be used in the code.
|
|
34
|
+
*/
|
|
35
|
+
public readonly isRoot: boolean
|
|
36
|
+
|
|
37
|
+
protected readonly parentPath: string | undefined
|
|
38
|
+
|
|
39
|
+
protected readonly defaultViewId: aux.StrictViewId<A> | undefined
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
$model: LikeC4Model<A>,
|
|
43
|
+
path: NonEmptyArray<string>,
|
|
44
|
+
defaultViewId: aux.StrictViewId<A> | undefined,
|
|
45
|
+
) {
|
|
46
|
+
this.$model = $model
|
|
47
|
+
this.path = path.join('/')
|
|
48
|
+
this.isRoot = this.path === ''
|
|
49
|
+
this.title = last(path)
|
|
50
|
+
if (this.isRoot) {
|
|
51
|
+
this.parentPath = undefined
|
|
52
|
+
} else {
|
|
53
|
+
this.parentPath = path.slice(0, -1).join('/')
|
|
54
|
+
}
|
|
55
|
+
this.defaultViewId = defaultViewId
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Default view of this view folder.\
|
|
60
|
+
* It is for the case when there is a view at the same path as this folder.\
|
|
61
|
+
* (if there are multiple views at the same path, the first one is chosen)
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```
|
|
65
|
+
* // Assume the following views exist:
|
|
66
|
+
* const views = [
|
|
67
|
+
* "Folder 1/ Folder 2 / View",
|
|
68
|
+
* "Folder 1/ Folder 2 / View / Subview",
|
|
69
|
+
* ]
|
|
70
|
+
* ```
|
|
71
|
+
* Group with path `Folder 1/ Folder 2 / View`\
|
|
72
|
+
* will have default view `Folder 1/ Folder 2 / View`
|
|
73
|
+
*/
|
|
74
|
+
get defaultView(): LikeC4ViewModel<A> | null {
|
|
75
|
+
if (!this.defaultViewId) {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
return this.$model.view(this.defaultViewId)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Returns path to this view folder as an array of ancestors (excluding root) and this view folder as the last element
|
|
83
|
+
*
|
|
84
|
+
* @throws Error if this is the root folder.
|
|
85
|
+
*/
|
|
86
|
+
get breadcrumbs(): [...LikeC4ViewsFolder<A>[], this] {
|
|
87
|
+
invariant(!this.isRoot, 'Root view folder has no breadcrumbs')
|
|
88
|
+
return memoizeProp(this, 'breadcrumbs', () => {
|
|
89
|
+
const parent = this.parent
|
|
90
|
+
if (parent) {
|
|
91
|
+
if (parent.isRoot) {
|
|
92
|
+
return [parent, this]
|
|
93
|
+
}
|
|
94
|
+
return [...parent.breadcrumbs, this]
|
|
95
|
+
}
|
|
96
|
+
return [this]
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Returns parent folder
|
|
102
|
+
*
|
|
103
|
+
* @throws Error if this is the root folder.
|
|
104
|
+
*/
|
|
105
|
+
get parent(): LikeC4ViewsFolder<A> | null {
|
|
106
|
+
invariant(!this.isRoot, 'Root view folder has no parent')
|
|
107
|
+
if (isEmpty(this.parentPath)) {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
return this.$model.viewFolder(this.parentPath)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns sorted set of children
|
|
115
|
+
* - First folders
|
|
116
|
+
* - Then views
|
|
117
|
+
*/
|
|
118
|
+
get children(): ReadonlySet<LikeC4ViewsFolder<A> | LikeC4ViewModel<A>> {
|
|
119
|
+
return this.$model.viewFolderItems(this.path)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns sorted array of children folders
|
|
124
|
+
*/
|
|
125
|
+
get folders(): ReadonlyArray<LikeC4ViewsFolder<A>> {
|
|
126
|
+
return memoizeProp(this, 'folders', () => {
|
|
127
|
+
const folders: LikeC4ViewsFolder<A>[] = []
|
|
128
|
+
for (const child of this.children) {
|
|
129
|
+
if (child instanceof LikeC4ViewsFolder) {
|
|
130
|
+
folders.push(child)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return folders
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns all views in this view folder.
|
|
139
|
+
*/
|
|
140
|
+
get views(): ReadonlyArray<LikeC4ViewModel<A>> {
|
|
141
|
+
return memoizeProp(this, 'views', () => {
|
|
142
|
+
const views: LikeC4ViewModel<A>[] = []
|
|
143
|
+
for (const child of this.children) {
|
|
144
|
+
if (child instanceof LikeC4ViewModel) {
|
|
145
|
+
views.push(child)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return views
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|