aidevops 3.29.21 → 3.29.23
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/VERSION +1 -1
- package/aidevops.sh +1 -1
- package/package.json +1 -1
- package/packages/gui-api/src/status-adapter-utils.ts +9 -5
- package/packages/gui-desktop/scripts/install-macos-app.sh +31 -8
- package/packages/gui-shared/src/notifications.ts +3 -2
- package/packages/gui-web/src/App.tsx +37 -8
- package/packages/gui-web/src/AppActionTerminal.tsx +81 -0
- package/packages/gui-web/src/AppNavigation.tsx +11 -189
- package/packages/gui-web/src/AppWorkspace.tsx +21 -29
- package/packages/gui-web/src/AppearanceControls.tsx +244 -0
- package/packages/gui-web/src/InventoryEditorSurfaces.tsx +99 -0
- package/packages/gui-web/src/InventorySurfaces.tsx +39 -500
- package/packages/gui-web/src/ManagedAppPanel.tsx +142 -0
- package/packages/gui-web/src/RecommendedAppsSurface.tsx +199 -0
- package/packages/gui-web/src/app-model.ts +18 -0
- package/packages/gui-web/src/dashboard.ts +1 -1
- package/packages/gui-web/src/external-links.ts +60 -0
- package/packages/gui-web/src/styles.css +135 -3
- package/packages/gui-web/src/workspace-header-state.ts +107 -0
- package/setup.sh +1 -1
|
@@ -11,6 +11,7 @@ import { FileExplorerSurface } from "./FileExplorerSurface";
|
|
|
11
11
|
import { AppsSurface, EditableInventorySurface, InstallationSurface } from "./InventorySurfaces";
|
|
12
12
|
import { AiProvidersSurface, LocalReposSurface, LockedVaultGate, OverviewSurface, PlannedSurface, ProjectsSurface, SecuritySurface, VaultSurface } from "./StatusSurfaces";
|
|
13
13
|
import { isVaultSurfaceLocked, vaultCollectionForSurface } from "./VaultBadges";
|
|
14
|
+
import { applyCommandPaletteSelection, useHeaderMenuState } from "./workspace-header-state";
|
|
14
15
|
|
|
15
16
|
const communityLinks = {
|
|
16
17
|
github: "https://github.com/marcusquinn/aidevops",
|
|
@@ -64,11 +65,9 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
|
|
|
64
65
|
setShellMode: (mode: ShellMode) => void;
|
|
65
66
|
status: GuiStatusData;
|
|
66
67
|
}): ReactElement {
|
|
67
|
-
const [assistantOpen, setAssistantOpen] = useState(false);
|
|
68
68
|
const [commandInitialQuery, setCommandInitialQuery] = useState("");
|
|
69
69
|
const [commandOpen, setCommandOpen] = useState(false);
|
|
70
|
-
const
|
|
71
|
-
const [profileOpen, setProfileOpen] = useState(false);
|
|
70
|
+
const headerMenus = useHeaderMenuState();
|
|
72
71
|
const userInitials = status.machine.initials || "AI";
|
|
73
72
|
const userName = status.machine.username || "Local user";
|
|
74
73
|
const activeNotifications = status.notifications.filter((notification) => notification.status === "active");
|
|
@@ -89,24 +88,17 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
|
|
|
89
88
|
return () => window.removeEventListener("keydown", openCommandPalette);
|
|
90
89
|
}, [goBack, goForward]);
|
|
91
90
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
setSelectedSessionId(sessionId);
|
|
104
|
-
if (conversationMode) {
|
|
105
|
-
setConversationMode(conversationMode);
|
|
106
|
-
}
|
|
107
|
-
setActiveSurface(surface);
|
|
108
|
-
closeMenus();
|
|
109
|
-
setCommandOpen(false);
|
|
91
|
+
const openItem = (selection: CommandPaletteSelection) => {
|
|
92
|
+
applyCommandPaletteSelection(selection, {
|
|
93
|
+
closeCommandPalette: () => setCommandOpen(false),
|
|
94
|
+
closeHeaderMenus: headerMenus.closeHeaderMenus,
|
|
95
|
+
setActiveSurface,
|
|
96
|
+
setConversationMode,
|
|
97
|
+
setSelectedLocalRepoIndex,
|
|
98
|
+
setSelectedSessionId,
|
|
99
|
+
setShellMode,
|
|
100
|
+
status,
|
|
101
|
+
});
|
|
110
102
|
};
|
|
111
103
|
|
|
112
104
|
return (
|
|
@@ -129,28 +121,28 @@ function WorkspaceHeader({ activeItem, activeSectionLabel, activeSurface, canGoB
|
|
|
129
121
|
<strong>{text.searchPlaceholder}</strong>
|
|
130
122
|
</button>
|
|
131
123
|
</div>
|
|
132
|
-
<div className="header-actions">
|
|
124
|
+
<div className="header-actions" onPointerEnter={headerMenus.clearScheduledHeaderClose} onPointerLeave={headerMenus.scheduleHeaderMenusClose} ref={headerMenus.headerActionsRef}>
|
|
133
125
|
<div className="header-action-menu">
|
|
134
|
-
<button aria-label="Open signposts and help" className="header-icon-button" onClick={() => openItem({ surface: "help" })} title="Signposts and help (?)" type="button">
|
|
126
|
+
<button aria-label="Open signposts and help" className="header-icon-button" onClick={() => { headerMenus.closeHeaderMenus(); openItem({ surface: "help" }); }} title="Signposts and help (?)" type="button">
|
|
135
127
|
<FiHelpCircle aria-hidden="true" />
|
|
136
128
|
</button>
|
|
137
|
-
<button aria-expanded={notificationsOpen} aria-label="Open notifications" className="header-icon-button" onClick={
|
|
129
|
+
<button aria-expanded={headerMenus.notificationsOpen} aria-label="Open notifications" className="header-icon-button" onClick={headerMenus.toggleNotifications} type="button">
|
|
138
130
|
<FiBell aria-hidden="true" />
|
|
139
131
|
{activeNotifications.length > 0 ? <span className="notification-dot" aria-hidden="true" /> : null}
|
|
140
132
|
</button>
|
|
141
|
-
{notificationsOpen ? <NotificationsMenu notifications={status.notifications} openSurface={(surface) => openItem({ surface })} /> : null}
|
|
133
|
+
{headerMenus.notificationsOpen ? <NotificationsMenu notifications={status.notifications} openSurface={(surface) => openItem({ surface })} /> : null}
|
|
142
134
|
</div>
|
|
143
|
-
<button aria-pressed={assistantOpen} aria-label="Toggle AI Assistant" className={assistantOpen ? "header-icon-button active" : "header-icon-button"} onClick={
|
|
135
|
+
<button aria-pressed={headerMenus.assistantOpen} aria-label="Toggle AI Assistant" className={headerMenus.assistantOpen ? "header-icon-button active" : "header-icon-button"} onClick={headerMenus.toggleAssistant} title="AI sessions (_)" type="button">
|
|
144
136
|
<FiTerminal aria-hidden="true" />
|
|
145
137
|
</button>
|
|
146
138
|
<div className="header-action-menu">
|
|
147
|
-
<button aria-expanded={profileOpen} aria-label={`Open profile menu for ${userName}`} className="profile-avatar-button" onClick={
|
|
139
|
+
<button aria-expanded={headerMenus.profileOpen} aria-label={`Open profile menu for ${userName}`} className="profile-avatar-button" onClick={headerMenus.toggleProfile} title={userName} type="button">
|
|
148
140
|
<span>{userInitials}</span>
|
|
149
141
|
</button>
|
|
150
|
-
{profileOpen ? <ProfileMenu openSurface={(surface) => openItem({ surface })} userName={userName} /> : null}
|
|
142
|
+
{headerMenus.profileOpen ? <ProfileMenu openSurface={(surface) => openItem({ surface })} userName={userName} /> : null}
|
|
151
143
|
</div>
|
|
152
144
|
</div>
|
|
153
|
-
{assistantOpen ? <AssistantPanel activeSurface={activeSurface} userName={userName} /> : null}
|
|
145
|
+
{headerMenus.assistantOpen ? <AssistantPanel activeSurface={activeSurface} userName={userName} /> : null}
|
|
154
146
|
{commandOpen ? <CommandPalette activeSurface={activeSurface} close={() => setCommandOpen(false)} initialQuery={commandInitialQuery} openItem={openItem} status={status} /> : null}
|
|
155
147
|
</header>
|
|
156
148
|
);
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { type ReactElement, useEffect, useState } from "react";
|
|
2
|
+
import { FiChevronDown, FiChevronLeft, FiChevronRight, FiChevronUp, FiRotateCcw } from "react-icons/fi";
|
|
3
|
+
import type { ContrastPreference, FontPreference, FontSizePreference, ThemePreference } from "./app-model";
|
|
4
|
+
import { DEFAULT_ACCENT_HUE, contrastOptions, fontFamilyForPreference, fontOptions, fontSizeOptions, text } from "./app-model";
|
|
5
|
+
|
|
6
|
+
export function hueFromInputValue(value: string): number | null {
|
|
7
|
+
const trimmedValue = value.trim();
|
|
8
|
+
|
|
9
|
+
if (trimmedValue.length === 0) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const hue = Number(trimmedValue);
|
|
14
|
+
|
|
15
|
+
if (!Number.isInteger(hue)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return Math.min(359, Math.max(0, hue));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SidebarFooter(props: SidebarFooterProps): ReactElement {
|
|
23
|
+
const [appearanceOpen, setAppearanceOpen] = useState(true);
|
|
24
|
+
const AppearanceChevron = appearanceOpen ? FiChevronDown : FiChevronUp;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<footer className="sidebar-footer">
|
|
28
|
+
<section className={appearanceOpen ? "appearance-panel open" : "appearance-panel collapsed"}>
|
|
29
|
+
<button
|
|
30
|
+
aria-expanded={appearanceOpen}
|
|
31
|
+
className="appearance-panel-tab"
|
|
32
|
+
onClick={() => setAppearanceOpen((current) => !current)}
|
|
33
|
+
type="button"
|
|
34
|
+
>
|
|
35
|
+
{text.appearance}
|
|
36
|
+
<AppearanceChevron aria-hidden="true" className="appearance-chevron" />
|
|
37
|
+
</button>
|
|
38
|
+
{appearanceOpen ? <AppearancePanelBody {...props} /> : null}
|
|
39
|
+
</section>
|
|
40
|
+
</footer>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SidebarFooterProps {
|
|
45
|
+
accentHue: number;
|
|
46
|
+
contrastPreference: ContrastPreference;
|
|
47
|
+
fontPreference: FontPreference;
|
|
48
|
+
fontSizePreference: FontSizePreference;
|
|
49
|
+
setAccentHue: (hue: number) => void;
|
|
50
|
+
setContrastPreference: (contrast: ContrastPreference) => void;
|
|
51
|
+
setFontPreference: (font: FontPreference) => void;
|
|
52
|
+
setFontSizePreference: (size: FontSizePreference) => void;
|
|
53
|
+
setShowBorders: (show: boolean) => void;
|
|
54
|
+
setShowNavCounts: (show: boolean) => void;
|
|
55
|
+
setThemePreference: (theme: ThemePreference) => void;
|
|
56
|
+
showBorders: boolean;
|
|
57
|
+
showNavCounts: boolean;
|
|
58
|
+
themePreference: ThemePreference;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function AppearancePanelBody({ accentHue, contrastPreference, fontPreference, fontSizePreference, setAccentHue, setContrastPreference, setFontPreference, setFontSizePreference, setShowBorders, setShowNavCounts, setThemePreference, showBorders, showNavCounts, themePreference }: SidebarFooterProps): ReactElement {
|
|
62
|
+
return (
|
|
63
|
+
<div className="appearance-panel-body">
|
|
64
|
+
<ThemeControl setThemePreference={setThemePreference} themePreference={themePreference} />
|
|
65
|
+
<HueControl accentHue={accentHue} setAccentHue={setAccentHue} />
|
|
66
|
+
<ContrastControl contrastPreference={contrastPreference} setContrastPreference={setContrastPreference} />
|
|
67
|
+
<AppearanceSwitches setShowBorders={setShowBorders} setShowNavCounts={setShowNavCounts} showBorders={showBorders} showNavCounts={showNavCounts} />
|
|
68
|
+
<FontSizeControl fontSizePreference={fontSizePreference} setFontSizePreference={setFontSizePreference} />
|
|
69
|
+
<FontControl fontPreference={fontPreference} setFontPreference={setFontPreference} />
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ThemeControl({ setThemePreference, themePreference }: { setThemePreference: (theme: ThemePreference) => void; themePreference: ThemePreference }): ReactElement {
|
|
75
|
+
return (
|
|
76
|
+
<fieldset className="theme-control compact" aria-label={text.theme}>
|
|
77
|
+
{(["system", "light", "dark"] as const).map((theme) => (
|
|
78
|
+
<button
|
|
79
|
+
aria-pressed={themePreference === theme}
|
|
80
|
+
className={themePreference === theme ? "theme-option active" : "theme-option"}
|
|
81
|
+
key={theme}
|
|
82
|
+
onClick={() => setThemePreference(theme)}
|
|
83
|
+
type="button"
|
|
84
|
+
>
|
|
85
|
+
{theme}
|
|
86
|
+
</button>
|
|
87
|
+
))}
|
|
88
|
+
</fieldset>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function HueControl({ accentHue, setAccentHue }: { accentHue: number; setAccentHue: (hue: number) => void }): ReactElement {
|
|
93
|
+
const [hueInput, setHueInput] = useState(() => String(accentHue));
|
|
94
|
+
const updateAccentHue = (value: number) => {
|
|
95
|
+
if (Number.isFinite(value)) {
|
|
96
|
+
setAccentHue(Math.min(359, Math.max(0, value)));
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const updateHueInput = (value: string) => {
|
|
100
|
+
setHueInput(value);
|
|
101
|
+
const nextHue = hueFromInputValue(value);
|
|
102
|
+
if (nextHue !== null) {
|
|
103
|
+
updateAccentHue(nextHue);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
setHueInput(String(accentHue));
|
|
109
|
+
}, [accentHue]);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="theme-hue-control">
|
|
113
|
+
<div className="theme-control-heading">
|
|
114
|
+
<div className="hue-label-row">
|
|
115
|
+
<label htmlFor="theme-hue-slider">{text.hue}</label>
|
|
116
|
+
<input
|
|
117
|
+
aria-label="Hue value"
|
|
118
|
+
className="hue-number-input"
|
|
119
|
+
max="359"
|
|
120
|
+
min="0"
|
|
121
|
+
onChange={(event) => updateHueInput(event.currentTarget.value)}
|
|
122
|
+
type="number"
|
|
123
|
+
value={hueInput}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
<button aria-label="Reset hue to default" className="icon-reset-button" onClick={() => setAccentHue(DEFAULT_ACCENT_HUE)} title={text.reset} type="button"><FiRotateCcw aria-hidden="true" /></button>
|
|
127
|
+
</div>
|
|
128
|
+
<input
|
|
129
|
+
id="theme-hue-slider"
|
|
130
|
+
max="359"
|
|
131
|
+
min="0"
|
|
132
|
+
onChange={(event) => updateAccentHue(Number.parseInt(event.currentTarget.value, 10))}
|
|
133
|
+
type="range"
|
|
134
|
+
value={accentHue}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function ContrastControl({ contrastPreference, setContrastPreference }: { contrastPreference: ContrastPreference; setContrastPreference: (contrast: ContrastPreference) => void }): ReactElement {
|
|
141
|
+
return (
|
|
142
|
+
<fieldset className="contrast-control" aria-label={text.contrast}>
|
|
143
|
+
<legend>{text.contrast}</legend>
|
|
144
|
+
<div className="contrast-options">
|
|
145
|
+
{contrastOptions.map((option) => (
|
|
146
|
+
<button
|
|
147
|
+
aria-pressed={contrastPreference === option.value}
|
|
148
|
+
className={contrastPreference === option.value ? "active" : ""}
|
|
149
|
+
key={option.value}
|
|
150
|
+
onClick={() => setContrastPreference(option.value)}
|
|
151
|
+
type="button"
|
|
152
|
+
>
|
|
153
|
+
{option.label}
|
|
154
|
+
</button>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
</fieldset>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function AppearanceSwitches({ setShowBorders, setShowNavCounts, showBorders, showNavCounts }: { setShowBorders: (show: boolean) => void; setShowNavCounts: (show: boolean) => void; showBorders: boolean; showNavCounts: boolean }): ReactElement {
|
|
162
|
+
return (
|
|
163
|
+
<>
|
|
164
|
+
<label className="switch-control appearance-switch">
|
|
165
|
+
<strong>{text.showBorders}</strong>
|
|
166
|
+
<input checked={showBorders} onChange={(event) => setShowBorders(event.currentTarget.checked)} type="checkbox" />
|
|
167
|
+
<span aria-hidden="true" />
|
|
168
|
+
</label>
|
|
169
|
+
<label className="switch-control appearance-switch">
|
|
170
|
+
<strong>{text.showCounts}</strong>
|
|
171
|
+
<input checked={showNavCounts} onChange={(event) => setShowNavCounts(event.currentTarget.checked)} type="checkbox" />
|
|
172
|
+
<span aria-hidden="true" />
|
|
173
|
+
</label>
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function FontSizeControl({ fontSizePreference, setFontSizePreference }: { fontSizePreference: FontSizePreference; setFontSizePreference: (size: FontSizePreference) => void }): ReactElement {
|
|
179
|
+
const fontSizeIndex = Math.max(0, fontSizeOptions.findIndex((option) => option.value === fontSizePreference));
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="font-size-control">
|
|
183
|
+
<label htmlFor="font-size-slider">{text.fontSize}</label>
|
|
184
|
+
<input
|
|
185
|
+
id="font-size-slider"
|
|
186
|
+
max={fontSizeOptions.length - 1}
|
|
187
|
+
min="0"
|
|
188
|
+
onChange={(event) => setFontSizePreference(fontSizeOptions[Number.parseInt(event.currentTarget.value, 10)]?.value ?? "xs")}
|
|
189
|
+
step="1"
|
|
190
|
+
type="range"
|
|
191
|
+
value={fontSizeIndex}
|
|
192
|
+
/>
|
|
193
|
+
<fieldset className="range-labels">
|
|
194
|
+
<legend className="sr-only">Font size shortcuts</legend>
|
|
195
|
+
{fontSizeOptions.map((option) => (
|
|
196
|
+
<button
|
|
197
|
+
aria-pressed={option.value === fontSizePreference}
|
|
198
|
+
className={option.value === fontSizePreference ? "active" : ""}
|
|
199
|
+
key={option.value}
|
|
200
|
+
onClick={() => setFontSizePreference(option.value)}
|
|
201
|
+
type="button"
|
|
202
|
+
>
|
|
203
|
+
{option.label}
|
|
204
|
+
</button>
|
|
205
|
+
))}
|
|
206
|
+
</fieldset>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function FontControl({ fontPreference, setFontPreference }: { fontPreference: FontPreference; setFontPreference: (font: FontPreference) => void }): ReactElement {
|
|
212
|
+
const selectedFontFamily = fontFamilyForPreference(fontPreference);
|
|
213
|
+
const fontIndex = Math.max(0, fontOptions.findIndex((option) => option.value === fontPreference));
|
|
214
|
+
const canSelectPreviousFont = fontIndex > 0;
|
|
215
|
+
const canSelectNextFont = fontIndex < fontOptions.length - 1;
|
|
216
|
+
const setFontByIndex = (index: number) => {
|
|
217
|
+
const nextFont = fontOptions[index]?.value;
|
|
218
|
+
if (nextFont !== undefined) {
|
|
219
|
+
setFontPreference(nextFont);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className="font-control">
|
|
225
|
+
<span id="appearance-font-selector-label">{text.font}</span>
|
|
226
|
+
<div className="selector-with-stepper font-selector-row">
|
|
227
|
+
<button aria-label="Previous font" className="selector-step-button" disabled={!canSelectPreviousFont} onClick={() => setFontByIndex(fontIndex - 1)} type="button"><FiChevronLeft aria-hidden="true" /></button>
|
|
228
|
+
<select
|
|
229
|
+
aria-labelledby="appearance-font-selector-label"
|
|
230
|
+
onChange={(event) => setFontPreference(event.currentTarget.value as FontPreference)}
|
|
231
|
+
style={{ fontFamily: selectedFontFamily }}
|
|
232
|
+
value={fontPreference}
|
|
233
|
+
>
|
|
234
|
+
{fontOptions.map((option) => (
|
|
235
|
+
<option key={option.value} style={{ fontFamily: option.fontFamily }} value={option.value}>
|
|
236
|
+
{option.label}
|
|
237
|
+
</option>
|
|
238
|
+
))}
|
|
239
|
+
</select>
|
|
240
|
+
<button aria-label="Next font" className="selector-step-button" disabled={!canSelectNextFont} onClick={() => setFontByIndex(fontIndex + 1)} type="button"><FiChevronRight aria-hidden="true" /></button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type ReactElement, useState } from "react";
|
|
2
|
+
import type { InventoryColumn } from "./app-model";
|
|
3
|
+
import { installationRows, text } from "./app-model";
|
|
4
|
+
|
|
5
|
+
interface DraftInventoryRow {
|
|
6
|
+
id: string;
|
|
7
|
+
values: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let draftRowCounter = 0;
|
|
11
|
+
|
|
12
|
+
export function InstallationSurface(): ReactElement {
|
|
13
|
+
return (
|
|
14
|
+
<section className="panel" aria-label={text.installation}>
|
|
15
|
+
<div className="section-heading">
|
|
16
|
+
<p className="eyebrow">{text.setup}</p>
|
|
17
|
+
<h2>{text.installation}</h2>
|
|
18
|
+
<p>{text.installationIntro}</p>
|
|
19
|
+
</div>
|
|
20
|
+
<div className="installation-list">
|
|
21
|
+
{installationRows.map((row) => (
|
|
22
|
+
<article className="install-row" key={row.name}>
|
|
23
|
+
<div><strong>{row.name}</strong><small>{row.scope}</small></div>
|
|
24
|
+
<TogglePill checked={row.install} label={text.install} />
|
|
25
|
+
<TogglePill checked={row.update} label={text.update} />
|
|
26
|
+
</article>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
<p className="empty-state">{text.plannedNotice}</p>
|
|
30
|
+
</section>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function EditableInventorySurface({ columns, initialRows, intro, title }: {
|
|
35
|
+
columns: InventoryColumn[];
|
|
36
|
+
initialRows: Record<string, string>[];
|
|
37
|
+
intro: string;
|
|
38
|
+
title: string;
|
|
39
|
+
}): ReactElement {
|
|
40
|
+
const [draftRows, setDraftRows] = useState(() => initialRows.map((row) => createDraftRow(row)));
|
|
41
|
+
|
|
42
|
+
function updateDraftRow(rowId: string, key: string, value: string): void {
|
|
43
|
+
setDraftRows((currentRows) => currentRows.map((row) => row.id === rowId ? { ...row, values: { ...row.values, [key]: value } } : row));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function addDraftRow(): void {
|
|
47
|
+
setDraftRows((currentRows) => [...currentRows, createDraftRow(emptyRow(columns))]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<section className="panel" aria-label={title}>
|
|
52
|
+
<div className="section-heading split-heading">
|
|
53
|
+
<div>
|
|
54
|
+
<p className="eyebrow">{text.infrastructure}</p>
|
|
55
|
+
<h2>{title}</h2>
|
|
56
|
+
<p>{intro}</p>
|
|
57
|
+
</div>
|
|
58
|
+
<button className="secondary-action" onClick={addDraftRow} type="button">{text.addRow}</button>
|
|
59
|
+
</div>
|
|
60
|
+
<p className="notice compact-notice">{text.draftOnly}</p>
|
|
61
|
+
<div className="editable-table">
|
|
62
|
+
<div className="editable-row header-row">
|
|
63
|
+
{columns.map((column) => <span key={column.key}>{column.label}</span>)}
|
|
64
|
+
</div>
|
|
65
|
+
{draftRows.map((row) => (
|
|
66
|
+
<div className="editable-row" key={row.id}>
|
|
67
|
+
{columns.map((column) => (
|
|
68
|
+
<input
|
|
69
|
+
aria-label={`${title} ${column.label}`}
|
|
70
|
+
key={column.key}
|
|
71
|
+
onChange={(event) => updateDraftRow(row.id, column.key, event.currentTarget.value)}
|
|
72
|
+
placeholder={column.label}
|
|
73
|
+
value={row.values[column.key] ?? ""}
|
|
74
|
+
/>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function TogglePill({ checked, label }: { checked: boolean; label: string }): ReactElement {
|
|
84
|
+
return <span className={checked ? "toggle-pill checked" : "toggle-pill"}>{label}</span>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createDraftRow(values: Record<string, string>): DraftInventoryRow {
|
|
88
|
+
draftRowCounter += 1;
|
|
89
|
+
return { id: `draft-row-${draftRowCounter}`, values };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emptyRow(columns: InventoryColumn[]): Record<string, string> {
|
|
93
|
+
const row: Record<string, string> = {};
|
|
94
|
+
for (const column of columns) {
|
|
95
|
+
row[column.key] = "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return row;
|
|
99
|
+
}
|