@mosetta/editor 0.0.0-stage → 0.0.28
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/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/mosetta.json +6 -0
- package/package.json +35 -3
- package/prefabs/workbench/prefab.json +11 -0
- package/prefabs/workbench/versions/1.json +120 -0
- package/prefabs/workbench/versions/2.json +13 -0
- package/prefabs/workbench/versions/3.json +19 -0
- package/prefabs/workbench/versions/4.json +29 -0
- package/prefabs/workbench/versions/5.json +29 -0
- package/tiles/canvas/canvas.component.tsx +2038 -0
- package/tiles/canvas/canvas.css +973 -0
- package/tiles/canvas/canvasTools.ts +66 -0
- package/tiles/canvas/chromeNodes.ts +35 -0
- package/tiles/canvas/ghosts.ts +33 -0
- package/tiles/canvas/insertPopover.tsx +56 -0
- package/tiles/canvas/popoverPlacement.ts +104 -0
- package/tiles/canvas/popoverSize.ts +87 -0
- package/tiles/canvas/providerChrome.tsx +191 -0
- package/tiles/canvas/scrollWatch.ts +43 -0
- package/tiles/canvas/settingsPopover.tsx +257 -0
- package/tiles/canvas/settingsTabs.ts +78 -0
- package/tiles/canvas/toolPanel.tsx +86 -0
- package/tiles/content/autoBind.ts +61 -0
- package/tiles/content/blockPage.css +390 -0
- package/tiles/content/blockPage.tsx +379 -0
- package/tiles/content/blockPicker.css +215 -0
- package/tiles/content/blockPicker.tsx +501 -0
- package/tiles/content/blockUpgrades.ts +145 -0
- package/tiles/content/blockVariants.ts +185 -0
- package/tiles/content/blockVocabulary.ts +77 -0
- package/tiles/content/catalogVersions.ts +106 -0
- package/tiles/content/componentPreview.css +82 -0
- package/tiles/content/componentPreview.tsx +249 -0
- package/tiles/content/content.server.ts +593 -0
- package/tiles/content/contentCommon.ts +133 -0
- package/tiles/content/disconnected.ts +56 -0
- package/tiles/content/distributions.css +256 -0
- package/tiles/content/distributions.tsx +271 -0
- package/tiles/content/editingAutosave.ts +125 -0
- package/tiles/content/editingClipboard.ts +185 -0
- package/tiles/content/editingConflicts.ts +223 -0
- package/tiles/content/editingHotkeys.ts +59 -0
- package/tiles/content/editingNotices.ts +105 -0
- package/tiles/content/formRoot.ts +18 -0
- package/tiles/content/fuzzySearch.ts +121 -0
- package/tiles/content/mediaUpload.ts +55 -0
- package/tiles/content/moduleSiblings.ts +9 -0
- package/tiles/content/mountedTree.ts +136 -0
- package/tiles/content/nodeSettings.css +483 -0
- package/tiles/content/nodeSettings.tsx +963 -0
- package/tiles/content/pasteText.ts +131 -0
- package/tiles/content/prefabFields.tsx +158 -0
- package/tiles/content/prefabParts.ts +105 -0
- package/tiles/content/previewEnvironment.ts +46 -0
- package/tiles/content/projectConfig.ts +46 -0
- package/tiles/content/projectPrefabs.ts +57 -0
- package/tiles/content/publishModal.css +109 -0
- package/tiles/content/publishModal.tsx +92 -0
- package/tiles/content/refsEditing.ts +412 -0
- package/tiles/content/scaledStage.ts +63 -0
- package/tiles/content/searchField.css +32 -0
- package/tiles/content/searchField.tsx +32 -0
- package/tiles/content/settingsDraft.ts +178 -0
- package/tiles/content/settingsIssues.tsx +124 -0
- package/tiles/content/themeScope.ts +72 -0
- package/tiles/content/themeVariables.ts +47 -0
- package/tiles/content/tooltipPlacement.ts +17 -0
- package/tiles/content/tooltips.tsx +131 -0
- package/tiles/content/treeMounter.ts +33 -0
- package/tiles/content/updatesModal.css +124 -0
- package/tiles/content/updatesModal.tsx +110 -0
- package/tiles/content/useCtrlHeld.ts +24 -0
- package/tiles/content/useEditingSession.ts +115 -0
- package/tiles/content/useMountedTree.ts +47 -0
- package/tiles/content/workbenchScale.css +27 -0
- package/tiles/hostFallback/hostFallback.component.tsx +32 -0
- package/tiles/hostFallback/hostFallback.css +12 -0
- package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
- package/tiles/page/page.component.tsx +7 -0
- package/tiles/page/page.script.tsx +5 -0
- package/tiles/pageModules/pageModules.component.tsx +740 -0
- package/tiles/pageModules/pageModules.css +383 -0
- package/tiles/pageModules/pageModules.icon.svg +1 -0
- package/tiles/pageModules/servedPage.ts +465 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
- package/tiles/pagesPanel/pagesPanel.css +713 -0
- package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
- package/tiles/pagesPanel/pagesSearch.ts +194 -0
- package/tiles/pagesPanel/routeTree.ts +98 -0
- package/tiles/previewBridge/landing.ts +66 -0
- package/tiles/previewBridge/previewAgent.ts +457 -0
- package/tiles/previewBridge/previewBridge.component.tsx +137 -0
- package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
- package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
- package/tiles/previewBridge/standIn.ts +22 -0
- package/tiles/treeView/treeView.component.tsx +514 -0
- package/tiles/treeView/treeView.css +303 -0
- package/tiles/treeView/treeView.editor.tsx +12 -0
- package/tiles/workbench/assistant.css +401 -0
- package/tiles/workbench/assistant.tsx +516 -0
- package/tiles/workbench/assistantChat.ts +165 -0
- package/tiles/workbench/dictation.ts +354 -0
- package/tiles/workbench/projectInfo.ts +72 -0
- package/tiles/workbench/recent.ts +67 -0
- package/tiles/workbench/workbench.component.tsx +476 -0
- package/tiles/workbench/workbench.css +376 -0
- package/tiles/workbench/workbenchAddress.ts +202 -0
- package/tiles/workbench/workspace.ts +775 -0
- package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
- package/tiles/workbenchEditor/workbenchEditor.css +370 -0
- package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
- package/tiles/workbenchHome/workbenchHome.css +271 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// The BLOCK PICKER: what can be inserted, and the insert itself. One component for the two places
|
|
2
|
+
// that offer blocks — the canvas's insert popover (opened by a "+" in the preview) and the docked
|
|
3
|
+
// palette panel.
|
|
4
|
+
//
|
|
5
|
+
// - the site's own published PREFABS first — placed LIVE (a node referencing the prefab: a change
|
|
6
|
+
// published to it reaches every page), first in a search too — then the REAL provider catalog
|
|
7
|
+
// (the `content` server module). A prefab being edited is offered no prefab: one level only;
|
|
8
|
+
// - a SEARCH field on top: empty, the list is grouped by provider in folding branches; typed, it
|
|
9
|
+
// is one flat list ranked by fuzzySearch (typos and the other keyboard layout forgiven), the
|
|
10
|
+
// matched letters lit. Arrows walk the list, Enter inserts, Escape gives up;
|
|
11
|
+
// - a pick INSERTS at the picked place: a block's module is loaded through the holder to seed its
|
|
12
|
+
// settings from defaultSettings() (copy-on-insert — the runtime never merges settings);
|
|
13
|
+
// - holding a row shows the block in a preview bubble (componentPreview.tsx);
|
|
14
|
+
// - a row wears its block's ICON, a branch its distribution's icon and name (ADR-0059): a shape
|
|
15
|
+
// and a colour are found before a word is read;
|
|
16
|
+
// - the blocks inserted LAST lead the list (Recently used, five of them): the same few blocks —
|
|
17
|
+
// an image, a text — are what one reaches for again and again. Kept in this browser, per site.
|
|
18
|
+
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
19
|
+
import { TILES_HOLDER_TOKEN } from '@mosetta/runtime/client';
|
|
20
|
+
import { useDi, useLocalStorageKey } from '@mosetta/react';
|
|
21
|
+
import { useServerAction } from '@mosetta/react/server-action';
|
|
22
|
+
import { EServerActionStart } from '@mosetta/server-action/client';
|
|
23
|
+
import { DI_CONTAINER_TOKEN } from '@mosetta/di';
|
|
24
|
+
import { compareVersions, EditingSession, ICatalogEntry, IInsertionPoint, prefabReference } from '@mosetta/content';
|
|
25
|
+
import type { IDistributionEntry, ILibraryPrefabEntry } from '@mosetta/content';
|
|
26
|
+
import type { IBlueprintNode } from '@mosetta/core';
|
|
27
|
+
import { listComponentsToken } from './contentCommon';
|
|
28
|
+
import { publishVocabulary } from './blockVocabulary';
|
|
29
|
+
import { ComponentPreview, IPreviewTarget } from './componentPreview';
|
|
30
|
+
import { useProjectPrefabs } from './projectPrefabs';
|
|
31
|
+
import type { IPrefabTree } from './contentCommon';
|
|
32
|
+
import { rankFuzzy } from './fuzzySearch';
|
|
33
|
+
import type { IPathMatch } from '../pagesPanel/pagesSearch';
|
|
34
|
+
import './workbenchScale.css';
|
|
35
|
+
import './blockPicker.css';
|
|
36
|
+
import { SearchField } from './searchField';
|
|
37
|
+
import { useDisabledDistributions } from './disconnected';
|
|
38
|
+
import { catalogVersions } from './catalogVersions';
|
|
39
|
+
|
|
40
|
+
interface IComponentModuleShape {
|
|
41
|
+
defaultSettings?: () => Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** which branches the user folded away — an arrangement, like the dock's; never content */
|
|
45
|
+
export const COLLAPSED_KEY = 'mosetta-editor:palette-collapsed';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How long a row must be held before its preview appears. Sweeping the list would otherwise mount
|
|
49
|
+
* a module tree per row it passed under — and fire whatever those modules ask their server for.
|
|
50
|
+
*/
|
|
51
|
+
const PREVIEW_DELAY_MS = 200;
|
|
52
|
+
|
|
53
|
+
/** the branch the site's own prefabs fold under — first of all */
|
|
54
|
+
const PREFABS_BRANCH = 'prefabs of this site';
|
|
55
|
+
|
|
56
|
+
/** flat (a search) or among the recent, a row says where it comes from — and at which version */
|
|
57
|
+
const tagOf = (item: { readonly branch: string; readonly component?: ICatalogEntry }): string => (item.component
|
|
58
|
+
? `${item.component.reference.namespace}@${item.component.reference.version}`
|
|
59
|
+
: item.branch);
|
|
60
|
+
|
|
61
|
+
/** the branch of the blocks inserted last — above everything */
|
|
62
|
+
const RECENT_BRANCH = 'recently used';
|
|
63
|
+
|
|
64
|
+
/** what was inserted last, newest first — a habit of this browser, never content */
|
|
65
|
+
export const RECENT_BLOCKS_KEY = 'mosetta-editor:recent-blocks';
|
|
66
|
+
const RECENT_BLOCKS = 5;
|
|
67
|
+
|
|
68
|
+
/** one thing the picker offers */
|
|
69
|
+
interface IPickItem {
|
|
70
|
+
readonly key: string;
|
|
71
|
+
readonly kind: 'prefab' | 'component';
|
|
72
|
+
/** what the row says */
|
|
73
|
+
readonly label: string;
|
|
74
|
+
/** what the search matches against */
|
|
75
|
+
readonly text: string;
|
|
76
|
+
/** the branch it folds under (the provider, or "layouts") */
|
|
77
|
+
readonly branch: string;
|
|
78
|
+
readonly component?: ICatalogEntry;
|
|
79
|
+
readonly prefab?: IPrefabTree;
|
|
80
|
+
/** a prefab a LIBRARY brings: placed live, in its distribution's branch */
|
|
81
|
+
readonly library?: ILibraryPrefabEntry;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** the label with the matched letters lit */
|
|
85
|
+
function Lit({ text, match }: { text: string; match: IPathMatch | null }) {
|
|
86
|
+
if (!match || match.indices.length === 0) return <>{text}</>;
|
|
87
|
+
const lit = new Set(match.indices);
|
|
88
|
+
const parts: ReactNode[] = [];
|
|
89
|
+
let run = '';
|
|
90
|
+
let runLit = lit.has(0);
|
|
91
|
+
const flush = (key: number) => {
|
|
92
|
+
if (run) parts.push(runLit ? <mark key={key}>{run}</mark> : run);
|
|
93
|
+
run = '';
|
|
94
|
+
};
|
|
95
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
96
|
+
if (lit.has(i) !== runLit) {
|
|
97
|
+
flush(i);
|
|
98
|
+
runLit = lit.has(i);
|
|
99
|
+
}
|
|
100
|
+
run += text[i];
|
|
101
|
+
}
|
|
102
|
+
flush(text.length);
|
|
103
|
+
return <>{parts}</>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const PrefabIcon = (
|
|
107
|
+
<svg viewBox="0 0 24 24"
|
|
108
|
+
aria-hidden="true">
|
|
109
|
+
<rect width="24"
|
|
110
|
+
height="24"
|
|
111
|
+
rx="6"
|
|
112
|
+
fill="#7c3aed" />
|
|
113
|
+
<path d="M6 7.5h12v4H6zM6 14h5v3.5H6zM13 14h5v3.5h-5z"
|
|
114
|
+
fill="none"
|
|
115
|
+
stroke="#fff"
|
|
116
|
+
strokeWidth="1.6"
|
|
117
|
+
strokeLinejoin="round" />
|
|
118
|
+
</svg>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
/** a row's picture: the block's own icon, the prefab mark, or its initial on a quiet tile */
|
|
122
|
+
function BlockIcon({ item }: { item: IPickItem }) {
|
|
123
|
+
if (item.kind === 'prefab') return <span className="block-picker__icon">{PrefabIcon}</span>;
|
|
124
|
+
const url = item.component?.iconUrl;
|
|
125
|
+
return url ? (
|
|
126
|
+
<img className="block-picker__icon"
|
|
127
|
+
src={url}
|
|
128
|
+
alt=""
|
|
129
|
+
loading="lazy"
|
|
130
|
+
draggable={false} />
|
|
131
|
+
) : (
|
|
132
|
+
<span className="block-picker__icon block-picker__icon--initial"
|
|
133
|
+
aria-hidden="true">{item.label.slice(0, 1).toUpperCase()}</span>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** a branch's words: its distribution's icon and title (its namespace and version beside), or the raw name */
|
|
138
|
+
function BranchTitle({ branch, distribution }: { branch: string; distribution: IDistributionEntry | undefined }) {
|
|
139
|
+
if (!distribution) return <>{branch}</>;
|
|
140
|
+
return (
|
|
141
|
+
<>
|
|
142
|
+
{distribution.iconUrl ? <img className="block-picker__branch-icon"
|
|
143
|
+
src={distribution.iconUrl}
|
|
144
|
+
alt=""
|
|
145
|
+
draggable={false} /> : null}
|
|
146
|
+
<span>{distribution.title}</span>
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The version a branch offers (ADR-0061): the latest by default; with several deployed, a choice —
|
|
153
|
+
* a page may want a block at the version its others are at.
|
|
154
|
+
*/
|
|
155
|
+
function BranchVersion({ namespace, versions, chosen, onChoose }: {
|
|
156
|
+
namespace: string;
|
|
157
|
+
versions: readonly string[];
|
|
158
|
+
chosen: string | undefined;
|
|
159
|
+
onChoose: (version: string) => void;
|
|
160
|
+
}) {
|
|
161
|
+
if (versions.length < 2) {
|
|
162
|
+
return <span className="block-picker__branch-id">{`${namespace}@${chosen ?? versions[0] ?? ''}`}</span>;
|
|
163
|
+
}
|
|
164
|
+
return (
|
|
165
|
+
<select className="block-picker__version"
|
|
166
|
+
data-test-id="palette-branch-version"
|
|
167
|
+
aria-label={`The version of ${namespace} to insert`}
|
|
168
|
+
value={chosen}
|
|
169
|
+
onClick={(e) => e.stopPropagation()}
|
|
170
|
+
onChange={(e) => onChoose(e.target.value)}>
|
|
171
|
+
{versions.map((version, i) => (
|
|
172
|
+
<option key={version}
|
|
173
|
+
value={version}>
|
|
174
|
+
{i === 0 ? `${version} · latest` : version}
|
|
175
|
+
</option>
|
|
176
|
+
))}
|
|
177
|
+
</select>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface IBlockPickerProps {
|
|
182
|
+
readonly session: EditingSession;
|
|
183
|
+
/** the place a pick inserts into; null — the rows only show, there is nowhere to put a block */
|
|
184
|
+
readonly point: IInsertionPoint | null;
|
|
185
|
+
/** the search takes the focus on mount (the popover opens to be typed into) */
|
|
186
|
+
readonly autoFocus?: boolean;
|
|
187
|
+
/** Escape in the search: the caller closes what it opened */
|
|
188
|
+
readonly onCancel?: () => void;
|
|
189
|
+
/** a specimen catalog instead of the server's (a palette preview); no layouts, nothing remembered */
|
|
190
|
+
readonly specimen?: readonly ICatalogEntry[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function BlockPicker({ session, point, autoFocus, onCancel, specimen }: IBlockPickerProps) {
|
|
194
|
+
const catalog = useServerAction({ token: listComponentsToken, input: {}, ...(specimen ? { start: EServerActionStart.START_MANUAL } : {}) });
|
|
195
|
+
const holder = useDi(TILES_HOLDER_TOKEN);
|
|
196
|
+
const di = useDi(DI_CONTAINER_TOKEN);
|
|
197
|
+
const project = useProjectPrefabs(!specimen);
|
|
198
|
+
const editingPrefab = session.snapshot().kind === 'prefabs';
|
|
199
|
+
const collapsed = useLocalStorageKey<string[]>(COLLAPSED_KEY, []);
|
|
200
|
+
const recent = useLocalStorageKey<string[]>(RECENT_BLOCKS_KEY, []);
|
|
201
|
+
const [term, setTerm] = useState('');
|
|
202
|
+
const [active, setActive] = useState(0);
|
|
203
|
+
const [preview, setPreview] = useState<IPreviewTarget | null>(null);
|
|
204
|
+
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
205
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
206
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
207
|
+
|
|
208
|
+
// the catalog in hand names the blocks a TEXT paste builds with — told once, workbench-wide
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (catalog.isSuccess() && catalog.serverData.ok) publishVocabulary(di, catalog.serverData.components);
|
|
211
|
+
}, [catalog, di]);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (autoFocus) searchRef.current?.focus();
|
|
214
|
+
}, [autoFocus]);
|
|
215
|
+
useEffect(() => () => {
|
|
216
|
+
if (previewTimer.current !== null) clearTimeout(previewTimer.current);
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
// a distribution the project disconnected offers its blocks no more (ADR-0059)
|
|
220
|
+
const disabled = useDisabledDistributions();
|
|
221
|
+
const components: readonly ICatalogEntry[] = specimen
|
|
222
|
+
?? (catalog.isSuccess() && catalog.serverData.ok
|
|
223
|
+
? catalog.serverData.components.filter((c) => !disabled.includes(c.reference.namespace))
|
|
224
|
+
: []);
|
|
225
|
+
// what each provider calls itself — a branch shows its icon and title instead of its namespace
|
|
226
|
+
const distributions: readonly IDistributionEntry[] = !specimen && catalog.isSuccess() && catalog.serverData.ok
|
|
227
|
+
? catalog.serverData.distributions ?? []
|
|
228
|
+
: [];
|
|
229
|
+
// the prefabs the libraries bring — one each, whatever versions of its distribution are deployed:
|
|
230
|
+
// a library prefab is LIVE, the page always takes its current form
|
|
231
|
+
// (a tool's own — the editor's workbench — is no prefab for authors)
|
|
232
|
+
const libraryPrefabs: readonly ILibraryPrefabEntry[] = useMemo(() => {
|
|
233
|
+
const all = !specimen && catalog.isSuccess() && catalog.serverData.ok ? catalog.serverData.prefabs ?? [] : [];
|
|
234
|
+
const internal = new Set(distributions.filter((d) => d.internal).map((d) => d.namespace));
|
|
235
|
+
const seen = new Set<string>();
|
|
236
|
+
return all.filter((p) => !disabled.includes(p.namespace) && !internal.has(p.namespace) && !seen.has(p.reference) && Boolean(seen.add(p.reference)));
|
|
237
|
+
}, [catalog, specimen, disabled, distributions]);
|
|
238
|
+
// every version deployed stays in the catalog: a branch is a distribution, offering ONE version
|
|
239
|
+
// of it — the latest, unless the author chose another (ADR-0061)
|
|
240
|
+
const versions = useMemo(() => catalogVersions(components, distributions), [components, distributions]);
|
|
241
|
+
const [chosen, setChosen] = useState<Readonly<Record<string, string>>>({});
|
|
242
|
+
const versionOf = (namespace: string) => chosen[namespace] ?? versions.latest(namespace);
|
|
243
|
+
const offered = (c: ICatalogEntry) => {
|
|
244
|
+
const version = versionOf(c.reference.namespace);
|
|
245
|
+
return version === undefined || compareVersions(c.reference.version, version) === 0;
|
|
246
|
+
};
|
|
247
|
+
const distributionOf = (branch: string) => {
|
|
248
|
+
const version = versionOf(branch);
|
|
249
|
+
return version === undefined ? undefined : versions.distribution(branch, version);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const items = useMemo((): IPickItem[] => [
|
|
253
|
+
...(specimen || editingPrefab || !project.namespace ? [] : project.prefabs.map((prefab): IPickItem => ({
|
|
254
|
+
key: prefabReference(project.namespace!, prefab.name),
|
|
255
|
+
kind: 'prefab',
|
|
256
|
+
label: prefab.name,
|
|
257
|
+
text: prefab.name,
|
|
258
|
+
branch: PREFABS_BRANCH,
|
|
259
|
+
prefab,
|
|
260
|
+
}))),
|
|
261
|
+
// a page ROOT is not a block: it is what a page names as its own root, it takes the tree as a
|
|
262
|
+
// prop and can never be someone's child. Nor is an INTERNAL block — a tool's own (an editor
|
|
263
|
+
// panel, the editor's fallback: `markInternalTile`). The build marks both; the picker skips them.
|
|
264
|
+
...components.filter((c) => !c.isRoot && !c.internal).map((c): IPickItem => ({
|
|
265
|
+
key: c.id,
|
|
266
|
+
kind: 'component',
|
|
267
|
+
label: c.reference.name,
|
|
268
|
+
text: c.reference.name,
|
|
269
|
+
branch: c.reference.namespace,
|
|
270
|
+
component: c,
|
|
271
|
+
})),
|
|
272
|
+
...(editingPrefab ? [] : libraryPrefabs.map((p): IPickItem => ({
|
|
273
|
+
key: p.reference,
|
|
274
|
+
kind: 'prefab',
|
|
275
|
+
label: p.name,
|
|
276
|
+
text: p.name,
|
|
277
|
+
branch: p.namespace,
|
|
278
|
+
library: p,
|
|
279
|
+
}))),
|
|
280
|
+
], [components, project, specimen, editingPrefab, libraryPrefabs]);
|
|
281
|
+
// the rows of the versions on offer — the recent list keeps a block at whatever version it was
|
|
282
|
+
const shown = useMemo(() => items.filter((item) => !item.component || offered(item.component)), [items, chosen, versions]);
|
|
283
|
+
|
|
284
|
+
const searching = term.trim() !== '';
|
|
285
|
+
// the site's own prefabs lead a search too: they are what this site is made of
|
|
286
|
+
const ranked = useMemo(() => {
|
|
287
|
+
if (!searching) return [];
|
|
288
|
+
const found = rankFuzzy(shown, term, (item) => item.text);
|
|
289
|
+
return [...found.filter((r) => r.item.kind === 'prefab'), ...found.filter((r) => r.item.kind !== 'prefab')];
|
|
290
|
+
}, [shown, term, searching]);
|
|
291
|
+
|
|
292
|
+
// a specimen shows every branch open and remembers nothing: the fold is the REAL workbench's
|
|
293
|
+
const isCollapsed = (branch: string) => !specimen && collapsed.value.includes(branch);
|
|
294
|
+
const groups = new Map<string, IPickItem[]>();
|
|
295
|
+
// the blocks used last first — those still offered (a block that left the catalog is skipped);
|
|
296
|
+
// each a row of its own here, beside its place in its provider's branch
|
|
297
|
+
const recentItems = specimen ? [] : recent.value
|
|
298
|
+
.map((key) => items.find((item) => item.key === key))
|
|
299
|
+
.filter((item): item is IPickItem => item !== undefined)
|
|
300
|
+
.map((item): IPickItem => ({ ...item, branch: RECENT_BRANCH }));
|
|
301
|
+
if (recentItems.length) groups.set(RECENT_BRANCH, recentItems);
|
|
302
|
+
for (const item of shown) groups.set(item.branch, [...(groups.get(item.branch) ?? []), item]);
|
|
303
|
+
// the rows the keyboard walks: the ranked list, or every row of an open branch
|
|
304
|
+
const visible: IPickItem[] = searching
|
|
305
|
+
? ranked.map(({ item }) => item)
|
|
306
|
+
: [...groups].flatMap(([branch, list]) => (isCollapsed(branch) ? [] : list));
|
|
307
|
+
const activeIndex = Math.min(active, Math.max(0, visible.length - 1));
|
|
308
|
+
useEffect(() => setActive(0), [term]);
|
|
309
|
+
|
|
310
|
+
const dropPreview = () => {
|
|
311
|
+
if (previewTimer.current !== null) clearTimeout(previewTimer.current);
|
|
312
|
+
previewTimer.current = null;
|
|
313
|
+
setPreview(null);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// a block's OWN library may ship a root module (the catalog marks one), and that root is who
|
|
317
|
+
// knows how to render its blocks — a provider on another binding is hosted through it
|
|
318
|
+
const libraryRootOf = (component: ICatalogEntry) => components.find((entry) => entry.isRoot
|
|
319
|
+
&& entry.reference.namespace === component.reference.namespace
|
|
320
|
+
&& entry.reference.version === component.reference.version)?.id;
|
|
321
|
+
|
|
322
|
+
const armPreview = (item: IPickItem, row: HTMLElement) => {
|
|
323
|
+
if (previewTimer.current !== null) clearTimeout(previewTimer.current);
|
|
324
|
+
// a prefab is shown as it is: its own blocks under its own root
|
|
325
|
+
if (item.prefab) {
|
|
326
|
+
const { top, left, right } = row.getBoundingClientRect();
|
|
327
|
+
const { doc } = item.prefab;
|
|
328
|
+
previewTimer.current = setTimeout(() => setPreview({
|
|
329
|
+
reference: item.key,
|
|
330
|
+
hasEditor: true,
|
|
331
|
+
anchor: { top, left, right },
|
|
332
|
+
tree: { rootReference: doc.reference, children: doc.children as unknown as IBlueprintNode[] },
|
|
333
|
+
}), PREVIEW_DELAY_MS);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// a preview is DECLARED on the editor facet; without one there is nothing to show
|
|
337
|
+
const component = item.component;
|
|
338
|
+
if (!component?.hasEditor) {
|
|
339
|
+
setPreview(null);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const { top, left, right } = row.getBoundingClientRect();
|
|
343
|
+
previewTimer.current = setTimeout(() => setPreview({
|
|
344
|
+
reference: component.id,
|
|
345
|
+
hasEditor: component.hasEditor,
|
|
346
|
+
libraryRoot: libraryRootOf(component),
|
|
347
|
+
anchor: { top, left, right },
|
|
348
|
+
}), PREVIEW_DELAY_MS);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const pick = async (item: IPickItem | undefined) => {
|
|
352
|
+
if (!item || !point || !session.snapshot().doc) return;
|
|
353
|
+
dropPreview();
|
|
354
|
+
if (!specimen) recent.setValue([item.key, ...recent.value.filter((key) => key !== item.key)].slice(0, RECENT_BLOCKS));
|
|
355
|
+
// placed LIVE: the node names the prefab; its blocks come from its published revision
|
|
356
|
+
if (item.kind === 'prefab') {
|
|
357
|
+
session.insertNode(item.key, {});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const loaded: { module?: unknown } | undefined = holder.loadModule
|
|
361
|
+
? await holder.loadModule(item.key)
|
|
362
|
+
: holder.getModule(item.key);
|
|
363
|
+
const mod = loaded?.module as IComponentModuleShape | undefined;
|
|
364
|
+
session.insertNode(item.key, mod?.defaultSettings ? mod.defaultSettings() : {});
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const toggleBranch = (branch: string) => {
|
|
368
|
+
dropPreview();
|
|
369
|
+
if (specimen) return;
|
|
370
|
+
collapsed.setValue(isCollapsed(branch)
|
|
371
|
+
? collapsed.value.filter((name) => name !== branch)
|
|
372
|
+
: [...collapsed.value, branch]);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// the keyboard's row stays in sight
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
listRef.current?.querySelector('[data-active]')?.scrollIntoView?.({ block: 'nearest' });
|
|
378
|
+
}, [activeIndex, term]);
|
|
379
|
+
|
|
380
|
+
const row = (item: IPickItem, match: IPathMatch | null) => {
|
|
381
|
+
const index = visible.indexOf(item);
|
|
382
|
+
return (
|
|
383
|
+
<li key={item.key}
|
|
384
|
+
className={`block-picker__item${index === activeIndex ? ' block-picker__item--active' : ''}${point ? '' : ' block-picker__item--inert'}`}
|
|
385
|
+
data-active={index === activeIndex || undefined}
|
|
386
|
+
data-recent={item.branch === RECENT_BRANCH || undefined}
|
|
387
|
+
{...(item.kind === 'prefab'
|
|
388
|
+
? { 'data-test-id': 'palette-prefab-row', 'data-prefab': item.label }
|
|
389
|
+
: { 'data-test-id': 'palette-item-row', 'data-component': item.key, 'data-has-editor': item.component!.hasEditor })}
|
|
390
|
+
onMouseEnter={(e) => {
|
|
391
|
+
setActive(index);
|
|
392
|
+
armPreview(item, e.currentTarget);
|
|
393
|
+
}}
|
|
394
|
+
onMouseLeave={dropPreview}
|
|
395
|
+
onClick={point ? () => void pick(item) : undefined}>
|
|
396
|
+
<BlockIcon item={item} />
|
|
397
|
+
<span className="block-picker__name"><Lit text={item.label}
|
|
398
|
+
match={match} /></span>
|
|
399
|
+
{/* a library's prefab is placed live — it says so beside its name */}
|
|
400
|
+
{item.library ? <span className="block-picker__branch-tag"
|
|
401
|
+
title="Placed live: the page always takes this prefab's current form">live</span> : null}
|
|
402
|
+
{/* a search is flat; a recent block at another version than its branch offers says which */}
|
|
403
|
+
{searching || (item.branch === RECENT_BRANCH && item.component && !offered(item.component))
|
|
404
|
+
? <span className="block-picker__branch-tag">{tagOf(item)}</span>
|
|
405
|
+
: null}
|
|
406
|
+
</li>
|
|
407
|
+
);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const loading = !specimen && catalog.isLoading();
|
|
411
|
+
const failure = specimen ? null
|
|
412
|
+
: catalog.isError() ? catalog.serverError.code
|
|
413
|
+
: catalog.isSuccess() && !catalog.serverData.ok ? catalog.serverData.code : null;
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
<div className="block-picker"
|
|
417
|
+
data-test-id="block-picker"
|
|
418
|
+
onScroll={dropPreview}>
|
|
419
|
+
<SearchField className="block-picker__search-field">
|
|
420
|
+
<input ref={searchRef}
|
|
421
|
+
className="block-picker__search"
|
|
422
|
+
data-test-id="block-picker-search"
|
|
423
|
+
type="search"
|
|
424
|
+
placeholder="Search blocks — typos and the other keyboard layout are fine"
|
|
425
|
+
value={term}
|
|
426
|
+
spellCheck={false}
|
|
427
|
+
autoComplete="off"
|
|
428
|
+
onChange={(e) => setTerm(e.target.value)}
|
|
429
|
+
onKeyDown={(e) => {
|
|
430
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
431
|
+
e.preventDefault();
|
|
432
|
+
dropPreview();
|
|
433
|
+
const step = e.key === 'ArrowDown' ? 1 : -1;
|
|
434
|
+
setActive((visible.length + activeIndex + step) % Math.max(1, visible.length));
|
|
435
|
+
} else if (e.key === 'Enter') {
|
|
436
|
+
e.preventDefault();
|
|
437
|
+
void pick(visible[activeIndex]);
|
|
438
|
+
} else if (e.key === 'Escape') {
|
|
439
|
+
e.preventDefault();
|
|
440
|
+
e.stopPropagation();
|
|
441
|
+
if (term) setTerm('');
|
|
442
|
+
else onCancel?.();
|
|
443
|
+
}
|
|
444
|
+
}} />
|
|
445
|
+
</SearchField>
|
|
446
|
+
{loading ? <div className="block-picker__note">Loading blocks…</div> : null}
|
|
447
|
+
{failure ? <div className="block-picker__error">blocks failed to load: {failure}</div> : null}
|
|
448
|
+
<ul ref={listRef}
|
|
449
|
+
className="block-picker__list">
|
|
450
|
+
{searching ? (
|
|
451
|
+
ranked.length === 0
|
|
452
|
+
? <li className="block-picker__note">Nothing matches “{term.trim()}”</li>
|
|
453
|
+
: ranked.map(({ item, match }) => row(item, match))
|
|
454
|
+
) : [...groups].map(([branch, list]) => (
|
|
455
|
+
<li key={branch}
|
|
456
|
+
className="block-picker__branch"
|
|
457
|
+
data-branch={branch}
|
|
458
|
+
data-collapsed={isCollapsed(branch) || undefined}>
|
|
459
|
+
<div className="block-picker__branch-head">
|
|
460
|
+
<button type="button"
|
|
461
|
+
className="block-picker__branch-title"
|
|
462
|
+
data-test-id="palette-branch-toggle"
|
|
463
|
+
data-action="toggle-branch"
|
|
464
|
+
aria-expanded={!isCollapsed(branch)}
|
|
465
|
+
onClick={() => toggleBranch(branch)}>
|
|
466
|
+
<span className="block-picker__chevron"
|
|
467
|
+
aria-hidden="true">
|
|
468
|
+
<svg viewBox="0 0 10 10">
|
|
469
|
+
<path d="M3 1.5L6.5 5L3 8.5"
|
|
470
|
+
fill="none"
|
|
471
|
+
stroke="currentColor"
|
|
472
|
+
strokeWidth="1.6" />
|
|
473
|
+
</svg>
|
|
474
|
+
</span>
|
|
475
|
+
<BranchTitle branch={branch}
|
|
476
|
+
distribution={distributionOf(branch)} />
|
|
477
|
+
</button>
|
|
478
|
+
{branch === RECENT_BRANCH || branch === PREFABS_BRANCH ? null : (
|
|
479
|
+
<BranchVersion namespace={branch}
|
|
480
|
+
versions={versions.versions(branch)}
|
|
481
|
+
chosen={versionOf(branch)}
|
|
482
|
+
onChoose={(version) => {
|
|
483
|
+
dropPreview();
|
|
484
|
+
setChosen({ ...chosen, [branch]: version });
|
|
485
|
+
}} />
|
|
486
|
+
)}
|
|
487
|
+
</div>
|
|
488
|
+
{!isCollapsed(branch) && (
|
|
489
|
+
<ul className="block-picker__leaves">
|
|
490
|
+
{list.map((item) => row(item, null))}
|
|
491
|
+
</ul>
|
|
492
|
+
)}
|
|
493
|
+
</li>
|
|
494
|
+
))}
|
|
495
|
+
</ul>
|
|
496
|
+
{/* always rendered: the preview application lives as long as the picker, and a new row
|
|
497
|
+
only hands it a new tree (ADR-0034) */}
|
|
498
|
+
<ComponentPreview target={preview} />
|
|
499
|
+
</div>
|
|
500
|
+
);
|
|
501
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// UPDATING blocks to their newer versions (ADR-0061): a page names each block at the version it was
|
|
2
|
+
// placed with, a newer one is never taken by itself — the author updates in a draft, sees the
|
|
3
|
+
// preview, publishes. What is outdated, how a block's settings move to its new version, and the
|
|
4
|
+
// hook every panel reads it through (the tree's marks, the settings' line, the toolbar's window).
|
|
5
|
+
import { useMemo } from 'react';
|
|
6
|
+
import { useServerAction } from '@mosetta/react/server-action';
|
|
7
|
+
import { EServerActionStart } from '@mosetta/server-action/client';
|
|
8
|
+
import type { ITilesHolder } from '@mosetta/runtime/client';
|
|
9
|
+
import { compareVersions } from '@mosetta/content';
|
|
10
|
+
import type { ICatalogEntry, IDistributionEntry, IBlueprintNodeDoc } from '@mosetta/content';
|
|
11
|
+
import { listComponentsToken } from './contentCommon';
|
|
12
|
+
import { catalogVersions } from './catalogVersions';
|
|
13
|
+
import type { ICatalogVersions, IUpgrade } from './catalogVersions';
|
|
14
|
+
import { disconnectedOf, useDisabledDistributions } from './disconnected';
|
|
15
|
+
import { loadModule } from './mountedTree';
|
|
16
|
+
|
|
17
|
+
/** a node of the document whose block has a newer version */
|
|
18
|
+
export interface IOutdatedNode {
|
|
19
|
+
readonly nodeId: string;
|
|
20
|
+
readonly settings: Record<string, unknown>;
|
|
21
|
+
readonly upgrade: IUpgrade;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The document's outdated nodes, in document order — the root included (a page's root is a block
|
|
26
|
+
* too). A placed prefab names no version (it is live), a block of a disconnected distribution is
|
|
27
|
+
* left out by the site and offered nothing.
|
|
28
|
+
*/
|
|
29
|
+
export function outdatedNodes(doc: IBlueprintNodeDoc | null, versions: ICatalogVersions, disabled: readonly string[] = []): IOutdatedNode[] {
|
|
30
|
+
if (!doc) return [];
|
|
31
|
+
const found: IOutdatedNode[] = [];
|
|
32
|
+
// a tool's own blocks (the editor's page root) follow the framework the page runs on — no
|
|
33
|
+
// update to offer on every page whenever the tool is released
|
|
34
|
+
const tool = (reference: string) => {
|
|
35
|
+
const at = versions.upgradeOf(reference);
|
|
36
|
+
return at !== null && versions.distribution(at.namespace, at.toVersion)?.internal === true;
|
|
37
|
+
};
|
|
38
|
+
const walk = (node: IBlueprintNodeDoc) => {
|
|
39
|
+
const upgrade = disconnectedOf(node.reference, disabled) || tool(node.reference) ? null : versions.upgradeOf(node.reference);
|
|
40
|
+
if (upgrade) found.push({ nodeId: node.id, settings: node.settings ?? {}, upgrade });
|
|
41
|
+
for (const child of node.children ?? []) walk(child);
|
|
42
|
+
};
|
|
43
|
+
walk(doc);
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The document with its TOOLS at their latest (ADR-0061) — what the server renders and so what a
|
|
49
|
+
* preview realm holds: the page names the editor's root at the version it was made with, the
|
|
50
|
+
* realm runs the latest. A document with nothing to lead comes back as it is.
|
|
51
|
+
*/
|
|
52
|
+
export function withLatestTools<T extends IBlueprintNodeDoc>(doc: T, versions: ICatalogVersions): T {
|
|
53
|
+
const visit = (node: IBlueprintNodeDoc): IBlueprintNodeDoc => {
|
|
54
|
+
const reference = versions.leadTool(node.reference);
|
|
55
|
+
const own = node as IBlueprintNodeDoc & { fallback?: unknown };
|
|
56
|
+
const fallback = typeof own.fallback === 'string' ? versions.leadTool(own.fallback) : own.fallback;
|
|
57
|
+
const children = node.children.map(visit);
|
|
58
|
+
if (reference === node.reference && fallback === own.fallback && children.every((child, i) => child === node.children[i])) return node;
|
|
59
|
+
return { ...node, reference, ...(typeof own.fallback === 'string' ? { fallback } : {}), children };
|
|
60
|
+
};
|
|
61
|
+
return visit(doc) as T;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** what the updates window shows of one distribution: from which versions to which, and its blocks */
|
|
65
|
+
export interface IUpgradeGroup {
|
|
66
|
+
readonly namespace: string;
|
|
67
|
+
readonly title: string;
|
|
68
|
+
readonly toVersion: string;
|
|
69
|
+
/** the versions the page's blocks are at now, the oldest first */
|
|
70
|
+
readonly fromVersions: readonly string[];
|
|
71
|
+
/** each block of it the page holds, with how many */
|
|
72
|
+
readonly blocks: readonly { readonly name: string; readonly count: number }[];
|
|
73
|
+
/** what each version on the way says it changed */
|
|
74
|
+
readonly changes: readonly { readonly version: string; readonly changes: string }[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function upgradeGroups(outdated: readonly IOutdatedNode[], versions: ICatalogVersions): IUpgradeGroup[] {
|
|
78
|
+
const byNamespace = new Map<string, IOutdatedNode[]>();
|
|
79
|
+
for (const node of outdated) byNamespace.set(node.upgrade.namespace, [...(byNamespace.get(node.upgrade.namespace) ?? []), node]);
|
|
80
|
+
return [...byNamespace].map(([namespace, nodes]) => {
|
|
81
|
+
const toVersion = versions.latest(namespace) ?? nodes[0].upgrade.toVersion;
|
|
82
|
+
const fromVersions = [...new Set(nodes.map((n) => n.upgrade.fromVersion))].sort(compareVersions);
|
|
83
|
+
const counts = new Map<string, number>();
|
|
84
|
+
for (const node of nodes) counts.set(node.upgrade.name, (counts.get(node.upgrade.name) ?? 0) + 1);
|
|
85
|
+
return {
|
|
86
|
+
namespace,
|
|
87
|
+
title: versions.distribution(namespace, toVersion)?.title ?? namespace,
|
|
88
|
+
toVersion,
|
|
89
|
+
fromVersions,
|
|
90
|
+
blocks: [...counts].map(([name, count]) => ({ name, count })),
|
|
91
|
+
changes: versions.changesBetween(namespace, fromVersions[0], toVersion),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface IUpgradingFacet {
|
|
97
|
+
upgrade?: (settings: Record<string, unknown>, from: string) => Record<string, unknown>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface IDefaultsFacet {
|
|
101
|
+
defaultSettings?: () => Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The settings a block takes at its new version: its new defaults, under what the page had set —
|
|
106
|
+
* moved through the new version's own `upgrade` when it declares one.
|
|
107
|
+
*/
|
|
108
|
+
export async function upgradedSettings(holder: ITilesHolder, node: IOutdatedNode, hasEditor: boolean): Promise<Record<string, unknown>> {
|
|
109
|
+
const [component, editor] = await Promise.all([
|
|
110
|
+
loadModule(holder, node.upgrade.to),
|
|
111
|
+
hasEditor ? loadModule(holder, node.upgrade.to.replace('_t_component_', '_t_editor_')) : Promise.resolve(undefined),
|
|
112
|
+
]);
|
|
113
|
+
const defaults = (component?.module as IDefaultsFacet | undefined)?.defaultSettings?.() ?? {};
|
|
114
|
+
const upgrade = (editor?.module as IUpgradingFacet | undefined)?.upgrade;
|
|
115
|
+
const moved = upgrade ? upgrade(node.settings, node.upgrade.fromVersion) : node.settings;
|
|
116
|
+
return { ...defaults, ...moved };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** the session's change for each node, settings migrated — what `upgradeNodes` takes in one step */
|
|
120
|
+
export async function upgradeChanges(holder: ITilesHolder, nodes: readonly IOutdatedNode[], components: readonly ICatalogEntry[]) {
|
|
121
|
+
return Promise.all(nodes.map(async (node) => ({
|
|
122
|
+
nodeId: node.nodeId,
|
|
123
|
+
reference: node.upgrade.to,
|
|
124
|
+
settings: await upgradedSettings(holder, node, components.find((c) => c.id === node.upgrade.to)?.hasEditor ?? false),
|
|
125
|
+
})));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const NO_COMPONENTS: readonly ICatalogEntry[] = [];
|
|
129
|
+
const NO_DISTRIBUTIONS: readonly IDistributionEntry[] = [];
|
|
130
|
+
|
|
131
|
+
/** the catalog's versions, and the document's outdated nodes against them */
|
|
132
|
+
export function useUpgrades(doc: IBlueprintNodeDoc | null, asking = true): {
|
|
133
|
+
versions: ICatalogVersions;
|
|
134
|
+
components: readonly ICatalogEntry[];
|
|
135
|
+
outdated: readonly IOutdatedNode[];
|
|
136
|
+
} {
|
|
137
|
+
// shares the picker's cache entry (same token and input) — no extra request; a specimen asks nothing
|
|
138
|
+
const catalog = useServerAction({ token: listComponentsToken, input: {}, ...(asking ? {} : { start: EServerActionStart.START_MANUAL }) });
|
|
139
|
+
const disabled = useDisabledDistributions();
|
|
140
|
+
const components: readonly ICatalogEntry[] = catalog.isSuccess() && catalog.serverData.ok ? catalog.serverData.components : NO_COMPONENTS;
|
|
141
|
+
const distributions: readonly IDistributionEntry[] = catalog.isSuccess() && catalog.serverData.ok ? catalog.serverData.distributions ?? NO_DISTRIBUTIONS : NO_DISTRIBUTIONS;
|
|
142
|
+
const versions = useMemo(() => catalogVersions(components, distributions), [components, distributions]);
|
|
143
|
+
const outdated = useMemo(() => outdatedNodes(doc, versions, disabled), [doc, versions, disabled]);
|
|
144
|
+
return { versions, components, outdated };
|
|
145
|
+
}
|