Skip to content

Commit 4c62b10

Browse files
committed
feat(activation): improve plugin installation status handling and UI feedback
- Enhanced the `ActivationPluginsStep` component to track and display the installation status of plugins, including states for pending, installing, success, and error. - Updated the UI to provide real-time feedback during the installation process, including dynamic status messages and logs for each plugin. - Improved unit tests to verify the new status handling and ensure accurate UI updates based on installation progress. - Added new localization strings for installation status messages to enhance user experience. This update significantly improves the clarity and responsiveness of the plugin installation process, providing users with better insights into the status of their selected plugins.
1 parent 771840c commit 4c62b10

File tree

3 files changed

+118
-32
lines changed

3 files changed

+118
-32
lines changed

web/__test__/components/Activation/ActivationPluginsStep.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ describe('ActivationPluginsStep', () => {
7777
.findAll('[data-testid="brand-button"]')
7878
.find((button) => button.text().includes('Install'));
7979
expect(installButton).toBeTruthy();
80+
expect(installButton!.text()).toContain('Install Selected');
8081
await installButton!.trigger('click');
8182
await flushPromises();
8283

@@ -86,6 +87,7 @@ describe('ActivationPluginsStep', () => {
8687
expect(firstCallArgs?.url).toContain('community.applications');
8788
expect(props.onComplete).not.toHaveBeenCalled();
8889
expect(wrapper.html()).toContain('installation started');
90+
expect(wrapper.html()).toContain('Installed');
8991
expect(wrapper.html()).toContain('installed successfully');
9092

9193
const continueButton = wrapper
@@ -119,5 +121,6 @@ describe('ActivationPluginsStep', () => {
119121

120122
expect(props.onComplete).not.toHaveBeenCalled();
121123
expect(wrapper.html()).toContain('Failed to install plugins. Please try again.');
124+
expect(wrapper.html()).toContain('Install failed');
122125
});
123126
});

web/src/components/Activation/ActivationPluginsStep.vue

Lines changed: 112 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts" setup>
2-
import { computed, ref } from 'vue';
2+
import { computed, reactive, ref } from 'vue';
33
import { useI18n } from 'vue-i18n';
44
55
import { BrandButton } from '@unraid/ui';
@@ -46,19 +46,44 @@ const availablePlugins: Plugin[] = [
4646
},
4747
];
4848
49+
type PluginStatus = 'pending' | 'installing' | 'success' | 'error';
50+
51+
type PluginState = {
52+
status: PluginStatus;
53+
logs: string[];
54+
};
55+
56+
const pluginStates = reactive<Record<string, PluginState>>(
57+
Object.fromEntries(
58+
availablePlugins.map((plugin) => [
59+
plugin.id,
60+
{
61+
status: 'pending',
62+
logs: [],
63+
},
64+
])
65+
)
66+
);
67+
4968
const selectedPlugins = ref<Set<string>>(new Set());
5069
const isInstalling = ref(false);
5170
const error = ref<string | null>(null);
52-
const installationLogs = ref<string[]>([]);
5371
const installationFinished = ref(false);
5472
5573
const { installPlugin } = usePluginInstaller();
5674
57-
const appendLogs = (lines: string[] | string) => {
75+
const combinedLogs = computed(() => {
76+
return availablePlugins.flatMap((plugin) =>
77+
pluginStates[plugin.id].logs.map((line) => `[${plugin.name}] ${line}`)
78+
);
79+
});
80+
81+
const appendPluginLogs = (pluginId: string, lines: string[] | string) => {
82+
const state = pluginStates[pluginId];
5883
if (Array.isArray(lines)) {
59-
lines.forEach((line) => installationLogs.value.push(line));
84+
state.logs.push(...lines);
6085
} else {
61-
installationLogs.value.push(lines);
86+
state.logs.push(lines);
6287
}
6388
};
6489
@@ -70,8 +95,14 @@ const togglePlugin = (pluginId: string) => {
7095
const next = new Set(selectedPlugins.value);
7196
if (next.has(pluginId)) {
7297
next.delete(pluginId);
98+
pluginStates[pluginId].status = 'pending';
99+
pluginStates[pluginId].logs = [];
73100
} else {
74101
next.add(pluginId);
102+
if (pluginStates[pluginId].status !== 'pending') {
103+
pluginStates[pluginId].status = 'pending';
104+
pluginStates[pluginId].logs = [];
105+
}
75106
}
76107
selectedPlugins.value = next;
77108
resetCompletionState();
@@ -80,40 +111,62 @@ const togglePlugin = (pluginId: string) => {
80111
const handleInstall = async () => {
81112
if (selectedPlugins.value.size === 0) {
82113
installationFinished.value = true;
83-
props.onComplete();
84114
return;
85115
}
86116
87117
isInstalling.value = true;
88118
error.value = null;
89-
installationLogs.value = [];
90119
installationFinished.value = false;
91120
92121
try {
93122
const pluginsToInstall = availablePlugins.filter((p) => selectedPlugins.value.has(p.id));
94123
95124
for (const plugin of pluginsToInstall) {
96-
appendLogs(t('activation.pluginsStep.installingPluginMessage', { name: plugin.name }));
97-
98-
const result = await installPlugin({
99-
url: plugin.url,
100-
name: plugin.name,
101-
forced: true,
102-
onEvent: (event) => {
103-
if (event.output?.length) {
104-
appendLogs(event.output.map((line) => `[${plugin.name}] ${line}`));
105-
}
106-
},
107-
});
125+
const state = pluginStates[plugin.id];
126+
state.status = 'installing';
127+
state.logs = [];
128+
appendPluginLogs(
129+
plugin.id,
130+
t('activation.pluginsStep.installingPluginMessage', { name: plugin.name })
131+
);
132+
133+
let result;
134+
try {
135+
result = await installPlugin({
136+
url: plugin.url,
137+
name: plugin.name,
138+
forced: true,
139+
onEvent: (event) => {
140+
if (event.output?.length) {
141+
appendPluginLogs(plugin.id, event.output);
142+
}
143+
},
144+
});
145+
} catch (installError) {
146+
state.status = 'error';
147+
appendPluginLogs(plugin.id, t('activation.pluginsStep.installFailed'));
148+
throw installError;
149+
}
108150
109151
if (result.status !== PluginInstallStatus.SUCCEEDED) {
152+
state.status = 'error';
153+
appendPluginLogs(plugin.id, t('activation.pluginsStep.installFailed'));
110154
throw new Error(`Plugin installation failed for ${plugin.name}`);
111155
}
112156
113-
appendLogs(t('activation.pluginsStep.pluginInstalledMessage', { name: plugin.name }));
157+
if (result.output?.length) {
158+
appendPluginLogs(plugin.id, result.output);
159+
}
160+
appendPluginLogs(
161+
plugin.id,
162+
t('activation.pluginsStep.pluginInstalledMessage', { name: plugin.name })
163+
);
164+
state.status = 'success';
114165
}
115166
116-
installationFinished.value = true;
167+
installationFinished.value = pluginsToInstall.every(
168+
(plugin) => pluginStates[plugin.id].status === 'success'
169+
);
117170
} catch (err) {
118171
error.value = t('activation.pluginsStep.installFailed');
119172
console.error('Failed to install plugins:', err);
@@ -181,26 +234,53 @@ const isPrimaryActionDisabled = computed(() => {
181234
:for="plugin.id"
182235
class="border-border bg-card hover:bg-accent/50 flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-colors"
183236
>
184-
<input
185-
:id="plugin.id"
186-
type="checkbox"
187-
:checked="selectedPlugins.has(plugin.id)"
188-
:disabled="isInstalling"
189-
@change="() => togglePlugin(plugin.id)"
190-
class="text-primary focus:ring-primary mt-1 h-5 w-5 cursor-pointer rounded border-gray-300 focus:ring-2"
191-
/>
237+
<div class="mt-1 h-5 w-5">
238+
<div
239+
v-if="pluginStates[plugin.id].status === 'installing'"
240+
class="border-primary h-5 w-5 animate-spin rounded-full border-2 border-t-transparent"
241+
/>
242+
<input
243+
v-else
244+
:id="plugin.id"
245+
type="checkbox"
246+
:checked="selectedPlugins.has(plugin.id)"
247+
:disabled="isInstalling"
248+
@change="() => togglePlugin(plugin.id)"
249+
class="text-primary focus:ring-primary h-5 w-5 cursor-pointer rounded border-gray-300 focus:ring-2"
250+
/>
251+
</div>
192252
<div class="flex-1">
193-
<div class="font-semibold">{{ plugin.name }}</div>
253+
<div class="flex items-center gap-2">
254+
<div class="font-semibold">{{ plugin.name }}</div>
255+
<span
256+
v-if="pluginStates[plugin.id].status === 'installing'"
257+
class="text-primary flex items-center gap-1 text-xs"
258+
>
259+
<span
260+
class="h-3 w-3 animate-spin rounded-full border border-current border-t-transparent"
261+
/>
262+
{{ t('activation.pluginsStep.status.installing') }}
263+
</span>
264+
<span
265+
v-else-if="pluginStates[plugin.id].status === 'success'"
266+
class="text-xs text-green-600"
267+
>
268+
{{ t('activation.pluginsStep.status.success') }}
269+
</span>
270+
<span v-else-if="pluginStates[plugin.id].status === 'error'" class="text-xs text-red-500">
271+
{{ t('activation.pluginsStep.status.error') }}
272+
</span>
273+
</div>
194274
<div class="text-sm opacity-75">{{ plugin.description }}</div>
195275
</div>
196276
</label>
197277
</div>
198278

199279
<div
200-
v-if="installationLogs.length > 0"
280+
v-if="combinedLogs.length > 0"
201281
class="border-border bg-muted/40 mb-4 max-h-48 w-full overflow-y-auto rounded border p-3 text-left font-mono text-xs"
202282
>
203-
<div v-for="(line, index) in installationLogs" :key="`${index}-${line}`">
283+
<div v-for="(line, index) in combinedLogs" :key="`${index}-${line}`">
204284
{{ line }}
205285
</div>
206286
</div>

web/src/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
"activation.pluginsStep.addHelpfulPlugins": "Add helpful plugins",
1616
"activation.pluginsStep.installAndContinue": "Install & Continue",
1717
"activation.pluginsStep.installSelected": "Install Selected",
18+
"activation.pluginsStep.status.installing": "Installing…",
19+
"activation.pluginsStep.status.success": "Installed",
20+
"activation.pluginsStep.status.error": "Install failed",
1821
"activation.pluginsStep.installEssentialPlugins": "Install Essential Plugins",
1922
"activation.pluginsStep.installFailed": "Failed to install plugins. Please try again.",
2023
"activation.pluginsStep.installingPluginMessage": "Installing {name}...",

0 commit comments

Comments
 (0)