diff --git a/Localize/locales/en/plugin-translation.json b/Localize/locales/en/plugin-translation.json
index 5664403b9..1fb219e83 100644
--- a/Localize/locales/en/plugin-translation.json
+++ b/Localize/locales/en/plugin-translation.json
@@ -11,6 +11,8 @@
"{{count}} failed._other": "{{count}} failed.",
"{{count}} min_one": "{{count}} min",
"{{count}} min_other": "{{count}} min",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes",
"{{count}} selected_one": "{{count}} selected",
"{{count}} selected_other": "{{count}} selected",
"{{days}}d ago": "{{days}}d ago",
@@ -659,6 +661,7 @@
"Target CPU utilization": "Target CPU utilization",
"Target CPU Utilization (%)": "Target CPU Utilization (%)",
"Target port": "Target port",
+ "Tenant": "Tenant",
"The agent has created the deployment PR. Review the generated files and merge to start the deployment pipeline.": "The agent has created the deployment PR. Review the generated files and merge to start the deployment pipeline.",
"The agent is creating the setup PR to enable the Copilot agent.": "The agent is creating the setup PR to enable the Copilot agent.",
"The AKS Desktop GitHub App must be installed on {{repoName}} to continue.": "The AKS Desktop GitHub App must be installed on {{repoName}} to continue.",
diff --git a/plugins/aks-desktop/locales/cs/translation.json b/plugins/aks-desktop/locales/cs/translation.json
index b82663619..190913d88 100644
--- a/plugins/aks-desktop/locales/cs/translation.json
+++ b/plugins/aks-desktop/locales/cs/translation.json
@@ -691,5 +691,8 @@
"{{minutes}} min ago": "před {{minutes}} min.",
"{{hours}}h ago": "Před {{hours}} h",
"{{days}}d ago": "Před {{days}} d",
- "{{weeks}}w ago": "před {{weeks}} týd."
+ "{{weeks}}w ago": "před {{weeks}} týd.",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/de/translation.json b/plugins/aks-desktop/locales/de/translation.json
index 3f0b5bae8..85443f09d 100644
--- a/plugins/aks-desktop/locales/de/translation.json
+++ b/plugins/aks-desktop/locales/de/translation.json
@@ -669,5 +669,8 @@
"{{minutes}} min ago": "Vor {{minutes}} Min.",
"{{hours}}h ago": "Vor {{hours}} Stunde(n)",
"{{days}}d ago": "Vor {{days}} Tag(en)",
- "{{weeks}}w ago": "Vor {{weeks}} Woche(n)"
+ "{{weeks}}w ago": "Vor {{weeks}} Woche(n)",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/en/translation.json b/plugins/aks-desktop/locales/en/translation.json
index 03cfa8fdb..827864a98 100644
--- a/plugins/aks-desktop/locales/en/translation.json
+++ b/plugins/aks-desktop/locales/en/translation.json
@@ -669,5 +669,8 @@
"{{minutes}} min ago": "{{minutes}} min ago",
"{{hours}}h ago": "{{hours}}h ago",
"{{days}}d ago": "{{days}}d ago",
- "{{weeks}}w ago": "{{weeks}}w ago"
+ "{{weeks}}w ago": "{{weeks}}w ago",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/es/translation.json b/plugins/aks-desktop/locales/es/translation.json
index 06c49a408..c277961cb 100644
--- a/plugins/aks-desktop/locales/es/translation.json
+++ b/plugins/aks-desktop/locales/es/translation.json
@@ -680,5 +680,8 @@
"{{minutes}} min ago": "hace {{minutes}}\u00a0min",
"{{hours}}h ago": "hace {{hours}}\u00a0h",
"{{days}}d ago": "hace {{days}}\u00a0d",
- "{{weeks}}w ago": "hace {{weeks}} sem."
+ "{{weeks}}w ago": "hace {{weeks}} sem.",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/fr/translation.json b/plugins/aks-desktop/locales/fr/translation.json
index 13b245251..799c6f421 100644
--- a/plugins/aks-desktop/locales/fr/translation.json
+++ b/plugins/aks-desktop/locales/fr/translation.json
@@ -680,5 +680,8 @@
"{{minutes}} min ago": "Il y a {{minutes}}\u00a0min",
"{{hours}}h ago": "Il y a {{hours}}\u00a0heures",
"{{days}}d ago": "Il y a {{days}}\u00a0jours",
- "{{weeks}}w ago": "Il y a {{weeks}}\u00a0semaines"
+ "{{weeks}}w ago": "Il y a {{weeks}}\u00a0semaines",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/hu/translation.json b/plugins/aks-desktop/locales/hu/translation.json
index 2e071d4a8..9e487798e 100644
--- a/plugins/aks-desktop/locales/hu/translation.json
+++ b/plugins/aks-desktop/locales/hu/translation.json
@@ -669,5 +669,8 @@
"{{minutes}} min ago": "{{minutes}} perce",
"{{hours}}h ago": "{{hours}} órája",
"{{days}}d ago": "{{days}} napja",
- "{{weeks}}w ago": "{{weeks}} hete"
+ "{{weeks}}w ago": "{{weeks}} hete",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/id/translation.json b/plugins/aks-desktop/locales/id/translation.json
index 1f450ea29..2ef74c28d 100644
--- a/plugins/aks-desktop/locales/id/translation.json
+++ b/plugins/aks-desktop/locales/id/translation.json
@@ -669,5 +669,8 @@
"Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "Menggabungkan kluster {{clusterName}} ({{count}} namespace)",
"Resources to be deployed ({{count}} object)_one": "Sumber daya yang akan disebarkan ({{count}} objek)",
"Successfully merged {{count}} cluster(s)_one": "Berhasil menggabungkan {{count}} kluster",
- "with {{count}} project(s)_one": "dengan {{count}} proyek"
+ "with {{count}} project(s)_one": "dengan {{count}} proyek",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/it/translation.json b/plugins/aks-desktop/locales/it/translation.json
index 08c6fbd78..d28ccfc81 100644
--- a/plugins/aks-desktop/locales/it/translation.json
+++ b/plugins/aks-desktop/locales/it/translation.json
@@ -680,5 +680,8 @@
"{{minutes}} min ago": "{{minutes}} minuto/i fa",
"{{hours}}h ago": "{{hours}} ora/e fa",
"{{days}}d ago": "{{days}} giorno/i fa",
- "{{weeks}}w ago": "{{weeks}} settimana/e fa"
+ "{{weeks}}w ago": "{{weeks}} settimana/e fa",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/ja/translation.json b/plugins/aks-desktop/locales/ja/translation.json
index def084dd7..f6bf674dc 100644
--- a/plugins/aks-desktop/locales/ja/translation.json
+++ b/plugins/aks-desktop/locales/ja/translation.json
@@ -669,5 +669,8 @@
"Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "クラスター {{clusterName}} をマージしています ({{count}} 個の名前空間)",
"Resources to be deployed ({{count}} object)_one": "デプロイするリソース ({{count}} 個のオブジェクト)",
"Successfully merged {{count}} cluster(s)_one": "{{count}} 個のクラスターが正常にマージされました",
- "with {{count}} project(s)_one": "{{count}} 個のプロジェクトを含む"
+ "with {{count}} project(s)_one": "{{count}} 個のプロジェクトを含む",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/ko/translation.json b/plugins/aks-desktop/locales/ko/translation.json
index 9d8e2d4b7..1f65d7099 100644
--- a/plugins/aks-desktop/locales/ko/translation.json
+++ b/plugins/aks-desktop/locales/ko/translation.json
@@ -669,5 +669,8 @@
"Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "{{clusterName}} 클러스터 병합(네임스페이스 {{count}}개)",
"Resources to be deployed ({{count}} object)_one": "배포할 리소스({{count}}개 개체)",
"Successfully merged {{count}} cluster(s)_one": "클러스터를 {{count}}개를 병합함",
- "with {{count}} project(s)_one": "프로젝트 {{count}}개 포함"
+ "with {{count}} project(s)_one": "프로젝트 {{count}}개 포함",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/nl/translation.json b/plugins/aks-desktop/locales/nl/translation.json
index 02325875b..e3e921950 100644
--- a/plugins/aks-desktop/locales/nl/translation.json
+++ b/plugins/aks-desktop/locales/nl/translation.json
@@ -669,5 +669,8 @@
"{{minutes}} min ago": "{{minutes}} min. geleden",
"{{hours}}h ago": "{{hours}}u geleden",
"{{days}}d ago": "{{days}}d geleden",
- "{{weeks}}w ago": "{{weeks}}w geleden"
+ "{{weeks}}w ago": "{{weeks}}w geleden",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/pl/translation.json b/plugins/aks-desktop/locales/pl/translation.json
index 6fc17c1a4..2001fdcae 100644
--- a/plugins/aks-desktop/locales/pl/translation.json
+++ b/plugins/aks-desktop/locales/pl/translation.json
@@ -691,5 +691,8 @@
"{{minutes}} min ago": "{{minutes}} min temu",
"{{hours}}h ago": "{{hours}} godz. temu",
"{{days}}d ago": "{{days}} dni temu",
- "{{weeks}}w ago": "{{weeks}} tyg. temu"
+ "{{weeks}}w ago": "{{weeks}} tyg. temu",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/pt-BR/translation.json b/plugins/aks-desktop/locales/pt-BR/translation.json
index becf1682b..9e64f7bce 100644
--- a/plugins/aks-desktop/locales/pt-BR/translation.json
+++ b/plugins/aks-desktop/locales/pt-BR/translation.json
@@ -680,5 +680,8 @@
"{{minutes}} min ago": "há {{minutes}} min",
"{{hours}}h ago": "Há {{hours}} hora(s)",
"{{days}}d ago": "{{days}} dia(s) atrás",
- "{{weeks}}w ago": "Há {{weeks}} semanas"
+ "{{weeks}}w ago": "Há {{weeks}} semanas",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/pt-PT/translation.json b/plugins/aks-desktop/locales/pt-PT/translation.json
index 2d7867309..398909224 100644
--- a/plugins/aks-desktop/locales/pt-PT/translation.json
+++ b/plugins/aks-desktop/locales/pt-PT/translation.json
@@ -680,5 +680,8 @@
"{{minutes}} min ago": "Há {{minutes}} min",
"{{hours}}h ago": "Há {{hours}}\u00a0h",
"{{days}}d ago": "Há {{days}} dia(s)",
- "{{weeks}}w ago": "Há {{weeks}} semanas"
+ "{{weeks}}w ago": "Há {{weeks}} semanas",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/ru/translation.json b/plugins/aks-desktop/locales/ru/translation.json
index f0e82b403..2c2862b55 100644
--- a/plugins/aks-desktop/locales/ru/translation.json
+++ b/plugins/aks-desktop/locales/ru/translation.json
@@ -691,5 +691,8 @@
"{{minutes}} min ago": "{{minutes}} мин. назад",
"{{hours}}h ago": "{{hours}} ч назад",
"{{days}}d ago": "{{days}}\u00a0дн. назад",
- "{{weeks}}w ago": "{{weeks}} нед. назад"
+ "{{weeks}}w ago": "{{weeks}} нед. назад",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/sv/translation.json b/plugins/aks-desktop/locales/sv/translation.json
index 8890451c4..286d5e94a 100644
--- a/plugins/aks-desktop/locales/sv/translation.json
+++ b/plugins/aks-desktop/locales/sv/translation.json
@@ -669,5 +669,8 @@
"{{minutes}} min ago": "{{minutes}} min sedan",
"{{hours}}h ago": "{{hours}}tim sedan",
"{{days}}d ago": "{{days}} d sedan",
- "{{weeks}}w ago": "{{weeks}} v sedan"
+ "{{weeks}}w ago": "{{weeks}} v sedan",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/tr/translation.json b/plugins/aks-desktop/locales/tr/translation.json
index 5a770e712..d0854a92f 100644
--- a/plugins/aks-desktop/locales/tr/translation.json
+++ b/plugins/aks-desktop/locales/tr/translation.json
@@ -669,5 +669,8 @@
"{{minutes}} min ago": "{{minutes}} dakika önce",
"{{hours}}h ago": "{{hours}} saat önce",
"{{days}}d ago": "{{days}} gün önce",
- "{{weeks}}w ago": "{{weeks}} hafta önce"
+ "{{weeks}}w ago": "{{weeks}} hafta önce",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/zh-Hans/translation.json b/plugins/aks-desktop/locales/zh-Hans/translation.json
index 79100194a..061645650 100644
--- a/plugins/aks-desktop/locales/zh-Hans/translation.json
+++ b/plugins/aks-desktop/locales/zh-Hans/translation.json
@@ -669,5 +669,8 @@
"Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "合并群集“{{clusterName}}”({{count}} 个命名空间)",
"Resources to be deployed ({{count}} object)_one": "要部署的资源({{count}} 个对象)",
"Successfully merged {{count}} cluster(s)_one": "已成功合并 {{count}} 个群集",
- "with {{count}} project(s)_one": "具有 {{count}} 个项目"
+ "with {{count}} project(s)_one": "具有 {{count}} 个项目",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/locales/zh-Hant/translation.json b/plugins/aks-desktop/locales/zh-Hant/translation.json
index a0ff1f7d6..528bb6b12 100644
--- a/plugins/aks-desktop/locales/zh-Hant/translation.json
+++ b/plugins/aks-desktop/locales/zh-Hant/translation.json
@@ -669,5 +669,8 @@
"Merging cluster {{clusterName}} ({{count}} namespace(s))_one": "正在合併叢集 {{clusterName}} ({{count}} 個命名空間)",
"Resources to be deployed ({{count}} object)_one": "要部署的資源 ({{count}} 個物件)",
"Successfully merged {{count}} cluster(s)_one": "已成功合併 {{count}} 個叢集",
- "with {{count}} project(s)_one": "具有 {{count}} 個專案"
+ "with {{count}} project(s)_one": "具有 {{count}} 個專案",
+ "Tenant": "Tenant",
+ "{{count}} nodes_one": "{{count}} node",
+ "{{count}} nodes_other": "{{count}} nodes"
}
diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.stories.tsx b/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.stories.tsx
new file mode 100644
index 000000000..4a53c1c40
--- /dev/null
+++ b/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.stories.tsx
@@ -0,0 +1,332 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache 2.0.
+
+import { Meta, StoryFn } from '@storybook/react';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import type { BasicsStepProps } from '../types';
+import { BasicsStep } from './BasicsStep';
+
+// ---------------------------------------------------------------------------
+// Shared fixture data
+// ---------------------------------------------------------------------------
+
+const BASE_FORM_DATA = {
+ projectName: 'azure-microservices-demo',
+ description: '',
+ subscription: '',
+ cluster: '',
+ resourceGroup: '',
+ ingress: 'AllowSameNamespace' as const,
+ egress: 'AllowAll' as const,
+ cpuRequest: 2000,
+ memoryRequest: 4096,
+ cpuLimit: 2000,
+ memoryLimit: 4096,
+ userAssignments: [],
+};
+
+const SUBSCRIPTIONS = [
+ {
+ id: 'sub-123',
+ name: 'Production Subscription',
+ tenant: 'tenant-abc',
+ tenantName: 'Contoso',
+ status: 'Enabled',
+ },
+ {
+ id: 'sub-456',
+ name: 'Dev / Test Subscription',
+ tenant: 'tenant-abc',
+ tenantName: 'Contoso',
+ status: 'Enabled',
+ },
+];
+
+const CLUSTERS = [
+ {
+ name: 'aks-prod-eastus',
+ location: 'eastus',
+ version: '1.28.5',
+ nodeCount: 3,
+ status: 'Succeeded',
+ resourceGroup: 'rg-prod',
+ powerState: 'Running',
+ },
+ {
+ name: 'aks-staging-westus',
+ location: 'westus',
+ version: '1.27.9',
+ nodeCount: 2,
+ status: 'Succeeded',
+ resourceGroup: 'rg-staging',
+ powerState: 'Running',
+ },
+];
+
+const BASE_PROPS: BasicsStepProps = {
+ formData: BASE_FORM_DATA,
+ onFormDataChange: () => {},
+ validation: { isValid: true, errors: [], warnings: [] },
+ loading: false,
+ error: null,
+ subscriptions: SUBSCRIPTIONS,
+ clusters: [],
+ loadingClusters: false,
+ clusterError: null,
+ totalClusterCount: null,
+ extensionStatus: { installed: true, installing: false, error: null, showSuccess: false },
+ featureStatus: {
+ registered: true,
+ state: 'Registered',
+ registering: false,
+ error: null,
+ showSuccess: false,
+ },
+ namespaceStatus: { exists: null, checking: false, error: null },
+ clusterCapabilities: null,
+ capabilitiesLoading: false,
+ onInstallExtension: async () => {},
+ onRegisterFeature: async () => {},
+ onRetrySubscriptions: async () => {},
+ onRetryClusters: async () => {},
+ onRefreshCapabilities: () => {},
+};
+
+// ---------------------------------------------------------------------------
+// Meta
+// ---------------------------------------------------------------------------
+
+export default {
+ title: 'CreateAKSProject/BasicsStep',
+ component: BasicsStep,
+ decorators: [
+ (Story: any) => (
+
+
+
+ ),
+ ],
+} as Meta;
+
+const Template: StoryFn = args => ;
+
+// ---------------------------------------------------------------------------
+// Stories
+// ---------------------------------------------------------------------------
+
+/**
+ * Initial state: subscriptions loaded, no subscription or cluster selected yet.
+ */
+export const Default = Template.bind({});
+Default.args = BASE_PROPS;
+
+/**
+ * Subscriptions are still loading — the Subscription dropdown is disabled with a spinner.
+ */
+export const SubscriptionsLoading = Template.bind({});
+SubscriptionsLoading.args = {
+ ...BASE_PROPS,
+ subscriptions: [],
+ loading: true,
+};
+
+/**
+ * Subscription selected, clusters are still loading.
+ */
+export const ClustersLoading = Template.bind({});
+ClustersLoading.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ loadingClusters: true,
+};
+
+/**
+ * Subscription selected and clusters loaded. 5 total in the subscription,
+ * only 2 have Entra ID auth — helper text shows the hidden-cluster count.
+ */
+export const HiddenClusters = Template.bind({});
+HiddenClusters.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ clusters: CLUSTERS,
+ totalClusterCount: 5,
+};
+
+/**
+ * Selected cluster is in a non-ready provisioning state.
+ * Shows the "Cluster Not Ready" warning banner with a Refresh button.
+ */
+export const ClusterNotReady = Template.bind({});
+ClusterNotReady.args = {
+ ...BASE_PROPS,
+ formData: {
+ ...BASE_FORM_DATA,
+ subscription: 'sub-123',
+ cluster: 'aks-prod-eastus',
+ resourceGroup: 'rg-prod',
+ },
+ clusters: [{ ...CLUSTERS[0], status: 'Updating' }, CLUSTERS[1]],
+ totalClusterCount: 2,
+};
+
+/**
+ * AKS Preview extension is not installed.
+ * Shows the yellow warning banner with the "Install Extension" button.
+ */
+export const ExtensionNotInstalled = Template.bind({});
+ExtensionNotInstalled.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ extensionStatus: { installed: false, installing: false, error: null, showSuccess: false },
+};
+
+/**
+ * AKS Preview extension is currently being installed.
+ * The "Install Extension" button shows a spinner and is disabled.
+ */
+export const ExtensionInstalling = Template.bind({});
+ExtensionInstalling.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ extensionStatus: { installed: false, installing: true, error: null, showSuccess: false },
+};
+
+/**
+ * AKS Preview extension install succeeded.
+ * Shows the green success alert.
+ */
+export const ExtensionInstallSuccess = Template.bind({});
+ExtensionInstallSuccess.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ extensionStatus: { installed: true, installing: false, error: null, showSuccess: true },
+};
+
+/**
+ * The `ManagedNamespacePreview` feature flag is not yet registered.
+ * Shows the red error panel with the "Register Feature" button.
+ */
+export const FeatureNotRegistered = Template.bind({});
+FeatureNotRegistered.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ featureStatus: {
+ registered: false,
+ state: 'NotRegistered',
+ registering: false,
+ error: null,
+ showSuccess: false,
+ },
+};
+
+/**
+ * Feature registration is in progress.
+ * The "Register Feature" button shows a spinner and is disabled.
+ */
+export const FeatureRegistering = Template.bind({});
+FeatureRegistering.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ featureStatus: {
+ registered: false,
+ state: 'Registering',
+ registering: true,
+ error: null,
+ showSuccess: false,
+ },
+};
+
+/**
+ * Project name collides with an existing namespace.
+ * The Project Name field shows an error state and a "name taken" message.
+ */
+export const NamespaceExists = Template.bind({});
+NamespaceExists.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ namespaceStatus: { exists: true, checking: false, error: null },
+};
+
+/**
+ * Namespace availability check is in flight.
+ * The Project Name field shows "Checking..." helper text.
+ */
+export const NamespaceChecking = Template.bind({});
+NamespaceChecking.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ namespaceStatus: { exists: null, checking: true, error: null },
+};
+
+/**
+ * Subscription list failed to load.
+ * Shows an error alert above the Subscription dropdown with a Retry button.
+ */
+export const SubscriptionError = Template.bind({});
+SubscriptionError.args = {
+ ...BASE_PROPS,
+ subscriptions: [],
+ error: 'Failed to load subscriptions: authorization failed',
+};
+
+/**
+ * Cluster list failed to load.
+ * Shows an error alert below the Cluster dropdown with a Retry button.
+ */
+export const ClusterError = Template.bind({});
+ClusterError.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ clusters: [],
+ clusterError: 'Failed to load clusters: network timeout',
+};
+
+/**
+ * Cluster capabilities are loading after a cluster is selected.
+ * Shows the "Checking cluster capabilities..." text.
+ */
+export const CapabilitiesLoading = Template.bind({});
+CapabilitiesLoading.args = {
+ ...BASE_PROPS,
+ formData: {
+ ...BASE_FORM_DATA,
+ subscription: 'sub-123',
+ cluster: 'aks-prod-eastus',
+ resourceGroup: 'rg-prod',
+ },
+ clusters: CLUSTERS,
+ capabilitiesLoading: true,
+};
+
+/**
+ * Cluster has some addons disabled (Prometheus off, KEDA off, VPA on).
+ * Shows the {@link ClusterConfigurePanel} below the cluster field.
+ */
+export const ConfigurableAddons = Template.bind({});
+ConfigurableAddons.args = {
+ ...BASE_PROPS,
+ formData: {
+ ...BASE_FORM_DATA,
+ subscription: 'sub-123',
+ cluster: 'aks-prod-eastus',
+ resourceGroup: 'rg-prod',
+ },
+ clusters: CLUSTERS,
+ clusterCapabilities: { prometheusEnabled: false, kedaEnabled: false, vpaEnabled: true },
+};
+
+/**
+ * Validation field error on the project name field.
+ */
+export const ProjectNameValidationError = Template.bind({});
+ProjectNameValidationError.args = {
+ ...BASE_PROPS,
+ formData: { ...BASE_FORM_DATA, subscription: 'sub-123' },
+ validation: {
+ isValid: false,
+ errors: [],
+ warnings: [],
+ fieldErrors: { projectName: ['Name must be 63 characters or fewer'] },
+ },
+};
diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.tsx b/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.tsx
index cfd83f0ab..a49e4514c 100644
--- a/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.tsx
+++ b/plugins/aks-desktop/src/components/CreateAKSProject/components/BasicsStep.tsx
@@ -2,7 +2,7 @@
// Licensed under the Apache 2.0.
import { Icon } from '@iconify/react';
-import { K8s, useTranslation } from '@kinvolk/headlamp-plugin/lib';
+import { useTranslation } from '@kinvolk/headlamp-plugin/lib';
import {
Alert,
AlertTitle,
@@ -12,188 +12,157 @@ import {
FormControl,
Typography,
} from '@mui/material';
-import React, { useEffect, useRef, useState } from 'react';
-import { useAzureAuth } from '../../../hooks/useAzureAuth';
+import React from 'react';
import type { ClusterCapabilities } from '../../../types/ClusterCapabilities';
-import { registerAKSCluster } from '../../../utils/azure/aks';
import { FormField } from '../../shared/FormField';
+import { useBasicsStep } from '../hooks/useBasicsStep';
+import { useRegisterCluster } from '../hooks/useRegisterCluster';
import type { BasicsStepProps } from '../types';
import { ClusterConfigurePanel } from './ClusterConfigurePanel';
-import { SearchableSelect, SearchableSelectOption } from './SearchableSelect';
+import { SearchableSelect } from './SearchableSelect';
import { ValidationAlert } from './ValidationAlert';
-/** Set to `true` locally to enable verbose debug logging. Never enable in production. */
-const DEBUG = false;
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
-// Helper to check if there are addons that can be enabled post-creation
+/** Returns `true` when there are addons that can still be enabled post-creation. */
const hasConfigurableAddons = (cap: ClusterCapabilities | null): boolean => {
if (!cap) return false;
return cap.prometheusEnabled !== true || cap.kedaEnabled !== true || cap.vpaEnabled !== true;
};
-function getClusterHelperText(
- t: (key: string, options?: Record) => string,
- loadingClusters: boolean,
- clusterCount: number,
- totalClusterCount: number | null
-): string {
- if (loadingClusters) {
- return t('Only clusters with Azure Entra ID authentication are shown.');
- }
- const hiddenCount =
- totalClusterCount !== null && totalClusterCount > clusterCount
- ? totalClusterCount - clusterCount
- : 0;
- const hiddenSuffix =
- hiddenCount > 0
- ? ` (${t('{{count}} cluster(s) hidden — no Azure Entra ID', { count: hiddenCount })})`
- : '';
- if (clusterCount === 0) {
- return `${t('No eligible clusters found in this subscription.')}${hiddenSuffix}`;
- }
- return `${t('{{count}} eligible cluster(s) found.', { count: clusterCount })}${hiddenSuffix}`;
+// ---------------------------------------------------------------------------
+// RegisterCluster sub-component (pure presentation)
+// ---------------------------------------------------------------------------
+
+/**
+ * Props for {@link RegisterCluster}.
+ */
+interface RegisterClusterProps {
+ cluster: string;
+ resourceGroup: string;
+ subscription: string;
+ tenantId?: string;
}
/**
- * Basics step component for project details and Azure resource selection
+ * Presentational component that prompts the user to register a cluster that
+ * is selected in the form but absent from the headlamp kubeconfig.
+ *
+ * All async logic lives in {@link useRegisterCluster}.
*/
-export const BasicsStep: React.FC = ({
- formData,
- onFormDataChange,
- validation,
- loading = false,
- error = null,
- subscriptions,
- clusters,
- loadingClusters,
- clusterError,
- extensionStatus,
- featureStatus,
- namespaceStatus,
- totalClusterCount,
- clusterCapabilities,
- capabilitiesLoading,
- onInstallExtension,
- onRegisterFeature,
- onRetrySubscriptions,
- onRetryClusters,
- onRefreshCapabilities,
-}) => {
+function RegisterCluster({ cluster, resourceGroup, subscription, tenantId }: RegisterClusterProps) {
const { t } = useTranslation();
- const headlampClusters = K8s.useClustersConf();
- const authStatus = useAzureAuth();
-
- // Focus the Project Name input on mount (the wizard-level focus effect
- // misses the initial render because AzureAuthGuard delays mounting).
- // Only steal focus when nothing else is focused (activeElement is ).
- const projectNameRef = useRef(null);
- useEffect(() => {
- if (document.activeElement?.tagName === 'BODY') {
- projectNameRef.current?.focus();
- }
- }, []);
-
- // Auto select default subscription
- const autoSelected = useRef(false);
- useEffect(() => {
- if (
- autoSelected.current === false &&
- authStatus?.subscriptionId &&
- !formData.subscription &&
- subscriptions &&
- subscriptions.find(it => it.id === authStatus.subscriptionId)
- ) {
- autoSelected.current = true;
- onFormDataChange({ subscription: authStatus.subscriptionId });
- }
- }, [formData.subscription, authStatus?.subscriptionId, subscriptions]);
-
- const handleInputChange = (field: string, value: any) => {
- onFormDataChange({ [field]: value });
- };
-
- const handleClusterChange = (clusterName: string) => {
- const selectedCluster = clusters.find(c => c.name === clusterName);
- if (selectedCluster) {
- onFormDataChange({
- cluster: clusterName,
- resourceGroup: selectedCluster.resourceGroup,
- });
- }
- };
-
- // helper to check for readiness
- const isClusterNonReady = (cluster: any): boolean => {
- const provisioningState = cluster.status?.toLowerCase() || '';
- const powerState = cluster.powerState?.toLowerCase() || '';
-
- const nonReadyProvisioningStates = ['updating', 'upgrading', 'deleting', 'creating', 'failed'];
- const nonReadyPowerStates = ['stopping', 'stopped', 'deallocating', 'deallocated'];
-
- return (
- nonReadyProvisioningStates.includes(provisioningState) ||
- nonReadyPowerStates.includes(powerState)
- );
- };
+ const { loading, error, success, handleRegister, clearError, clearSuccess } = useRegisterCluster(
+ cluster,
+ resourceGroup,
+ subscription,
+ tenantId
+ );
- // helper function to get cluster state message
- const getClusterStateMessage = (cluster: any): string => {
- const provisioningState = cluster.status?.toLowerCase() || '';
- const powerState = cluster.powerState?.toLowerCase() || '';
+ return (
+
+ {/* Missing-cluster notice — hidden once registration succeeds */}
+ {!success && (
+
+
+ {t('Selected cluster is missing from the kubeconfig. Register it before proceeding.')}
+
+
+ )}
- if (provisioningState === 'updating' || provisioningState === 'upgrading') {
- return t('Cluster is currently updating. Deployment may fail.');
- }
- if (provisioningState === 'deleting') {
- return t('Cluster is being deleted. Cannot deploy to this cluster.');
- }
- if (provisioningState === 'creating') {
- return t('Cluster is still being created. Please wait until creation completes.');
- }
- if (provisioningState === 'failed') {
- return t('Cluster is in a failed state. Please check Azure portal.');
- }
- if (powerState === 'stopped' || powerState === 'stopping') {
- return t('Cluster is stopped. Please start the cluster before deploying.');
- }
- if (powerState === 'deallocated' || powerState === 'deallocating') {
- return t('Cluster is deallocated. Please start the cluster before deploying.');
- }
- return '';
- };
+ {/* Registration error */}
+ {error && (
+
+ {error}
+
+ )}
- // Convert subscriptions to SearchableSelectOption format
- const subscriptionOptions: SearchableSelectOption[] = subscriptions.map(sub => ({
- value: sub.id,
- label: sub.name,
- subtitle: `Tenant: ${sub.tenantName} - (${sub.tenant}) • Status: ${sub.status}`,
- }));
+ {/* Registration success */}
+ {success && (
+
+ {success}
+
+ )}
- // Convert clusters to SearchableSelectOption format
- const clusterOptions: SearchableSelectOption[] = clusters.map(cluster => ({
- value: cluster.name,
- label: cluster.name,
- subtitle: `${cluster.location} • ${cluster.version} • ${cluster.nodeCount} nodes • ${cluster.status}`,
- }));
+ {/* Register button — hidden once registration succeeds */}
+ {!success && (
+
+ ) : (
+
+ )
+ }
+ disabled={loading}
+ aria-busy={loading || undefined}
+ >
+ {loading ? `${t('Registering cluster')}...` : t('Register Cluster')}
+
+ )}
+
+ );
+}
- const selectedCluster =
- formData.cluster && clusters.find(cluster => cluster.name === formData.cluster);
+// ---------------------------------------------------------------------------
+// BasicsStep component (pure presentation)
+// ---------------------------------------------------------------------------
- const isClusterMissing =
- selectedCluster &&
- Object.values(headlampClusters).find((it: any) => it.name === selectedCluster.name) ===
- undefined;
+/**
+ * Basics step of the Create AKS Project wizard.
+ *
+ * Collects the project name, description, Azure subscription, and AKS cluster.
+ * Also surfaces pre-flight warnings and errors for the AKS Preview extension,
+ * the ManagedNamespacePreview feature flag, cluster readiness, cluster
+ * capabilities, and namespace name availability.
+ *
+ * All stateful logic (focus management, auto-select, option mapping, cluster
+ * state derivation) lives in {@link useBasicsStep}. The `RegisterCluster`
+ * sub-component's async flow lives in {@link useRegisterCluster}.
+ */
+export const BasicsStep: React.FC = props => {
+ const { t } = useTranslation();
+ const {
+ formData,
+ validation,
+ loading = false,
+ error = null,
+ loadingClusters,
+ clusterError,
+ extensionStatus,
+ featureStatus,
+ namespaceStatus,
+ clusterCapabilities,
+ capabilitiesLoading,
+ onInstallExtension,
+ onRegisterFeature,
+ onRetrySubscriptions,
+ onRetryClusters,
+ onRefreshCapabilities,
+ } = props;
+
+ const {
+ projectNameRef,
+ subscriptionOptions,
+ clusterOptions,
+ clusterHelperText,
+ selectedSubscription,
+ selectedCluster,
+ isClusterMissing,
+ nonReadyCluster,
+ handleInputChange,
+ handleClusterChange,
+ } = useBasicsStep(props);
return (
- {error && (
- {}} // Will be handled by parent
- />
- )}
- {/* AKS Preview Extension Check */}
+ {error && {}} />}
+
+ {/* AKS Preview Extension check */}
{extensionStatus.installed === false && (
= ({
}
/>
)}
+
{extensionStatus.showSuccess && (
)}
- {/* ManagedNamespacePreview Feature Check */}
+
+ {/* ManagedNamespacePreview feature flag check */}
{featureStatus.registered === false && (
= ({
}
/>
)}
+
{featureStatus.showSuccess && (
)}
+
{/* Project Name */}
@@ -372,7 +345,7 @@ export const BasicsStep: React.FC = ({
handleClusterChange(value)}
+ onChange={handleClusterChange}
options={clusterOptions}
loading={loadingClusters}
error={!!clusterError}
@@ -389,61 +362,52 @@ export const BasicsStep: React.FC = ({
'No clusters with Azure Entra ID authentication found for this subscription'
)}
showSearch
- helperText={getClusterHelperText(t, loadingClusters, clusters.length, totalClusterCount)}
+ helperText={clusterHelperText}
/>
+ {/* Register cluster if it's missing from the kubeconfig */}
{formData.subscription && selectedCluster && isClusterMissing && (
s.id === formData.subscription)?.tenant}
+ tenantId={selectedSubscription?.tenant}
/>
)}
- {/* This shows a warning if the cluster isn't in a ready state*/}
- {formData.cluster &&
- clusters.length > 0 &&
- (() => {
- const selectedCluster = clusters.find(c => c.name === formData.cluster);
- if (selectedCluster && isClusterNonReady(selectedCluster)) {
- const stateMessage = getClusterStateMessage(selectedCluster);
- return (
-
-
-
- {t('Cluster Not Ready')}: {stateMessage}
-
-
- }
- // refresh button to reload clusters
- action={
-
- }
- />
+ {/* Cluster readiness warning */}
+ {nonReadyCluster && (
+
+
+
+ {t('Cluster Not Ready')}: {nonReadyCluster.message}
+
- );
- }
- return null;
- })()}
+ }
+ action={
+
+ }
+ />
+
+ )}
{/* Cluster capability warnings */}
{validation.warnings.length > 0 && (
@@ -455,7 +419,8 @@ export const BasicsStep: React.FC = ({
))}
>
)}
- {/* Cluster configure panel for enabling missing addons */}
+
+ {/* Configure panel for enabling missing addons */}
{formData.cluster && clusterCapabilities && hasConfigurableAddons(clusterCapabilities) && (
= ({
subscriptionId={formData.subscription}
resourceGroup={formData.resourceGroup}
clusterName={formData.cluster}
- onConfigured={() => {
- if (onRefreshCapabilities) {
- onRefreshCapabilities();
- }
- }}
+ onConfigured={() => onRefreshCapabilities?.()}
/>
)}
+
+ {/* Capabilities loading indicator */}
{capabilitiesLoading && formData.cluster && (
@@ -479,6 +442,7 @@ export const BasicsStep: React.FC = ({
)}
+ {/* Cluster fetch error */}
{clusterError && (
= ({
);
};
-
-function RegisterCluster({
- cluster,
- resourceGroup,
- subscription,
- tenantId,
-}: {
- cluster: string;
- resourceGroup: string;
- subscription: string;
- tenantId?: string;
-}) {
- const { t } = useTranslation();
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState();
- const [success, setSuccess] = useState();
-
- const handleRegister = async () => {
- if (!cluster || !subscription) {
- return;
- }
-
- setLoading(true);
- setError(undefined);
- setSuccess(undefined);
-
- try {
- // Register the cluster by running az aks get-credentials and setting up kubeconfig
- if (DEBUG) console.debug('[AKS] Registering cluster...');
- const result = await registerAKSCluster(
- subscription,
- resourceGroup,
- cluster,
- undefined,
- tenantId
- );
- if (DEBUG) console.debug('[AKS] Register cluster result:', result.success);
- if (!result.success) {
- setError(result.message);
- setLoading(false);
- return;
- }
-
- if (DEBUG) console.debug('[AKS] Cluster registered successfully.', result.message);
- setSuccess(t("Cluster '{{cluster}}' successfully merged in kubeconfig", { cluster }));
- setLoading(false);
- } catch (err) {
- console.error('Error registering AKS cluster:', err);
- setError(
- t('Failed to register cluster: {{message}}', {
- message: err instanceof Error ? err.message : t('Unknown error'),
- })
- );
- setLoading(false);
- }
- };
-
- return (
-
- {/* Show error alert for missing cluster when no success */}
- {!success && (
-
-
- {t('Selected cluster is missing from the kubeconfig. Register it before proceeding.')}
-
-
- )}
-
- {/* Show registration error if any */}
- {error && (
- setError(undefined)}>
- {error}
-
- )}
-
- {/* Show success message */}
- {success && (
- setSuccess(undefined)}>
- {success}
-
- )}
-
- {/* Hide button when success is shown */}
- {!success && (
-
- ) : (
-
- )
- }
- disabled={loading}
- aria-busy={loading || undefined}
- >
- {loading ? `${t('Registering cluster')}...` : t('Register Cluster')}
-
- )}
-
- );
-}
diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/components/SearchableSelect.tsx b/plugins/aks-desktop/src/components/CreateAKSProject/components/SearchableSelect.tsx
index b0a2169cb..2db84ff53 100644
--- a/plugins/aks-desktop/src/components/CreateAKSProject/components/SearchableSelect.tsx
+++ b/plugins/aks-desktop/src/components/CreateAKSProject/components/SearchableSelect.tsx
@@ -107,7 +107,7 @@ export const SearchableSelect: React.FC = ({
/>
)}
loading={loading}
- onChange={(e, newValue) => onChange(newValue?.value)}
+ onChange={(e, newValue) => onChange(newValue?.value ?? '')}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
return (
diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.test.ts b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.test.ts
new file mode 100644
index 000000000..e31dcba38
--- /dev/null
+++ b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.test.ts
@@ -0,0 +1,484 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache 2.0.
+
+// @vitest-environment jsdom
+
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+// ---------------------------------------------------------------------------
+// Mocks (must be hoisted before any imports that pull in the real modules)
+// ---------------------------------------------------------------------------
+
+const mockUseClustersConf = vi.hoisted(() => vi.fn());
+const mockUseAzureAuth = vi.hoisted(() => vi.fn());
+
+vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
+ K8s: {
+ useClustersConf: mockUseClustersConf,
+ },
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock('../../../hooks/useAzureAuth', () => ({
+ useAzureAuth: mockUseAzureAuth,
+}));
+
+// Import after mocks are in place
+import type { BasicsStepProps } from '../types';
+import {
+ getClusterHelperText,
+ getClusterStateMessage,
+ isClusterNonReady,
+ useBasicsStep,
+} from './useBasicsStep';
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const SUBSCRIPTION = {
+ id: 'sub-123',
+ name: 'Production',
+ tenant: 'tenant-abc',
+ tenantName: 'Contoso',
+ status: 'Enabled',
+};
+
+const CLUSTER_RUNNING = {
+ name: 'aks-prod',
+ location: 'eastus',
+ version: '1.28.5',
+ nodeCount: 3,
+ status: 'Succeeded',
+ resourceGroup: 'rg-prod',
+ powerState: 'Running',
+};
+
+const CLUSTER_UPDATING = {
+ ...CLUSTER_RUNNING,
+ status: 'Updating',
+};
+
+function makeProps(overrides: Partial = {}): BasicsStepProps {
+ return {
+ formData: {
+ projectName: 'my-project',
+ description: '',
+ subscription: '',
+ cluster: '',
+ resourceGroup: '',
+ ingress: 'AllowSameNamespace',
+ egress: 'AllowAll',
+ cpuRequest: 2000,
+ memoryRequest: 4096,
+ cpuLimit: 2000,
+ memoryLimit: 4096,
+ userAssignments: [],
+ },
+ onFormDataChange: vi.fn(),
+ validation: { isValid: true, errors: [], warnings: [] },
+ loading: false,
+ error: null,
+ subscriptions: [SUBSCRIPTION],
+ clusters: [CLUSTER_RUNNING],
+ loadingClusters: false,
+ clusterError: null,
+ totalClusterCount: null,
+ extensionStatus: { installed: true, installing: false, error: null, showSuccess: false },
+ featureStatus: {
+ registered: true,
+ state: 'Registered',
+ registering: false,
+ error: null,
+ showSuccess: false,
+ },
+ namespaceStatus: { exists: null, checking: false, error: null },
+ clusterCapabilities: null,
+ capabilitiesLoading: false,
+ onInstallExtension: vi.fn(),
+ onRegisterFeature: vi.fn(),
+ onRetrySubscriptions: vi.fn(),
+ onRetryClusters: vi.fn(),
+ onRefreshCapabilities: vi.fn(),
+ ...overrides,
+ };
+}
+
+const t = (key: string) => key;
+
+// ---------------------------------------------------------------------------
+// Helper function tests (pure, no hooks)
+// ---------------------------------------------------------------------------
+
+describe('getClusterHelperText', () => {
+ test('returns Entra ID note while loading', () => {
+ const result = getClusterHelperText(t, true, 0, null);
+ expect(result).toBe('Only clusters with Azure Entra ID authentication are shown.');
+ });
+
+ test('reports zero clusters found when list is empty and nothing hidden', () => {
+ const result = getClusterHelperText(t, false, 0, 0);
+ expect(result).toContain('No eligible clusters found');
+ expect(result).not.toContain('hidden');
+ });
+
+ test('appends hidden count suffix when totalClusterCount > clusterCount', () => {
+ const result = getClusterHelperText(t, false, 2, 5);
+ // The stub t() does not interpolate — assert on the key text and the suffix
+ expect(result).toContain('eligible cluster(s) found');
+ expect(result).toContain('hidden');
+ });
+
+ test('reports eligible count when clusters are found', () => {
+ const result = getClusterHelperText(t, false, 3, 3);
+ expect(result).toContain('eligible cluster(s) found');
+ expect(result).not.toContain('hidden');
+ });
+
+ test('does not append hidden suffix when totalClusterCount is null', () => {
+ const result = getClusterHelperText(t, false, 2, null);
+ expect(result).not.toContain('hidden');
+ });
+});
+
+describe('isClusterNonReady', () => {
+ test.each([
+ ['Updating', 'Running'],
+ ['Upgrading', 'Running'],
+ ['Deleting', 'Running'],
+ ['Creating', 'Running'],
+ ['Failed', 'Running'],
+ ])('returns true for provisioning state "%s"', (status, powerState) => {
+ expect(isClusterNonReady({ ...CLUSTER_RUNNING, status, powerState })).toBe(true);
+ });
+
+ test.each([
+ ['Succeeded', 'Stopping'],
+ ['Succeeded', 'Stopped'],
+ ['Succeeded', 'Deallocating'],
+ ['Succeeded', 'Deallocated'],
+ ])('returns true for power state "%s"', (status, powerState) => {
+ expect(isClusterNonReady({ ...CLUSTER_RUNNING, status, powerState })).toBe(true);
+ });
+
+ test('returns false for a healthy running cluster', () => {
+ expect(isClusterNonReady(CLUSTER_RUNNING)).toBe(false);
+ });
+
+ test('is case-insensitive for provisioning state', () => {
+ expect(isClusterNonReady({ ...CLUSTER_RUNNING, status: 'FAILED' })).toBe(true);
+ });
+
+ test('is case-insensitive for power state', () => {
+ expect(isClusterNonReady({ ...CLUSTER_RUNNING, powerState: 'STOPPED' })).toBe(true);
+ });
+});
+
+describe('getClusterStateMessage', () => {
+ test('returns updating message for Updating provisioning state', () => {
+ const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Updating' }, t);
+ expect(msg).toBe('Cluster is currently updating. Deployment may fail.');
+ });
+
+ test('returns same updating message for Upgrading state', () => {
+ const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Upgrading' }, t);
+ expect(msg).toBe('Cluster is currently updating. Deployment may fail.');
+ });
+
+ test('returns deleting message', () => {
+ const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Deleting' }, t);
+ expect(msg).toBe('Cluster is being deleted. Cannot deploy to this cluster.');
+ });
+
+ test('returns creating message', () => {
+ const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Creating' }, t);
+ expect(msg).toBe('Cluster is still being created. Please wait until creation completes.');
+ });
+
+ test('returns failed message', () => {
+ const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, status: 'Failed' }, t);
+ expect(msg).toBe('Cluster is in a failed state. Please check Azure portal.');
+ });
+
+ test('returns stopped message for Stopped power state', () => {
+ const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, powerState: 'Stopped' }, t);
+ expect(msg).toBe('Cluster is stopped. Please start the cluster before deploying.');
+ });
+
+ test('returns stopped message for Stopping power state', () => {
+ const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, powerState: 'Stopping' }, t);
+ expect(msg).toBe('Cluster is stopped. Please start the cluster before deploying.');
+ });
+
+ test('returns deallocated message', () => {
+ const msg = getClusterStateMessage({ ...CLUSTER_RUNNING, powerState: 'Deallocated' }, t);
+ expect(msg).toBe('Cluster is deallocated. Please start the cluster before deploying.');
+ });
+
+ test('returns empty string for a healthy cluster', () => {
+ const msg = getClusterStateMessage(CLUSTER_RUNNING, t);
+ expect(msg).toBe('');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// useBasicsStep hook tests
+// ---------------------------------------------------------------------------
+
+describe('useBasicsStep', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Default: no clusters in headlamp kubeconfig
+ mockUseClustersConf.mockReturnValue({});
+ // Default: not logged in, no subscriptionId
+ mockUseAzureAuth.mockReturnValue({ isLoggedIn: false, isChecking: false });
+ });
+
+ test('maps subscriptions to SearchableSelectOption format', () => {
+ const { result } = renderHook(() => useBasicsStep(makeProps()));
+ expect(result.current.subscriptionOptions).toHaveLength(1);
+ expect(result.current.subscriptionOptions[0]).toMatchObject({
+ value: 'sub-123',
+ label: 'Production',
+ });
+ expect(result.current.subscriptionOptions[0].subtitle).toContain('Contoso');
+ expect(result.current.subscriptionOptions[0].subtitle).toContain('Enabled');
+ });
+
+ test('maps clusters to SearchableSelectOption format', () => {
+ const { result } = renderHook(() => useBasicsStep(makeProps()));
+ expect(result.current.clusterOptions).toHaveLength(1);
+ expect(result.current.clusterOptions[0]).toMatchObject({
+ value: 'aks-prod',
+ label: 'aks-prod',
+ });
+ expect(result.current.clusterOptions[0].subtitle).toContain('eastus');
+ expect(result.current.clusterOptions[0].subtitle).toContain('1.28.5');
+ expect(result.current.clusterOptions[0].subtitle).toContain('nodes');
+ });
+
+ test('selectedSubscription is undefined when no subscription is selected', () => {
+ const { result } = renderHook(() => useBasicsStep(makeProps()));
+ expect(result.current.selectedSubscription).toBeUndefined();
+ });
+
+ test('selectedSubscription returns the matching subscription object', () => {
+ const props = makeProps({
+ formData: { ...makeProps().formData, subscription: 'sub-123' },
+ });
+ const { result } = renderHook(() => useBasicsStep(props));
+ expect(result.current.selectedSubscription).toEqual(SUBSCRIPTION);
+ });
+
+ test('selectedCluster, isClusterMissing, and nonReadyCluster are all falsy when no cluster is selected', () => {
+ const { result } = renderHook(() => useBasicsStep(makeProps()));
+ expect(result.current.selectedCluster).toBeUndefined();
+ expect(result.current.isClusterMissing).toBe(false);
+ expect(result.current.nonReadyCluster).toBeNull();
+ });
+
+ test('selectedCluster returns the matching cluster object', () => {
+ const props = makeProps({
+ formData: {
+ projectName: 'my-project',
+ description: '',
+ subscription: 'sub-123',
+ cluster: 'aks-prod',
+ resourceGroup: 'rg-prod',
+ ingress: 'AllowSameNamespace',
+ egress: 'AllowAll',
+ cpuRequest: 2000,
+ memoryRequest: 4096,
+ cpuLimit: 2000,
+ memoryLimit: 4096,
+ userAssignments: [],
+ },
+ });
+ const { result } = renderHook(() => useBasicsStep(props));
+ expect(result.current.selectedCluster).toEqual(CLUSTER_RUNNING);
+ });
+
+ test('isClusterMissing is true when cluster is selected but absent from headlamp', () => {
+ mockUseClustersConf.mockReturnValue({});
+ const props = makeProps({
+ formData: {
+ projectName: 'my-project',
+ description: '',
+ subscription: 'sub-123',
+ cluster: 'aks-prod',
+ resourceGroup: 'rg-prod',
+ ingress: 'AllowSameNamespace',
+ egress: 'AllowAll',
+ cpuRequest: 2000,
+ memoryRequest: 4096,
+ cpuLimit: 2000,
+ memoryLimit: 4096,
+ userAssignments: [],
+ },
+ });
+ const { result } = renderHook(() => useBasicsStep(props));
+ expect(result.current.isClusterMissing).toBe(true);
+ });
+
+ test('isClusterMissing is false when the cluster is present in headlamp', () => {
+ mockUseClustersConf.mockReturnValue({ 'ctx-1': { name: 'aks-prod' } });
+ const props = makeProps({
+ formData: {
+ projectName: 'my-project',
+ description: '',
+ subscription: 'sub-123',
+ cluster: 'aks-prod',
+ resourceGroup: 'rg-prod',
+ ingress: 'AllowSameNamespace',
+ egress: 'AllowAll',
+ cpuRequest: 2000,
+ memoryRequest: 4096,
+ cpuLimit: 2000,
+ memoryLimit: 4096,
+ userAssignments: [],
+ },
+ });
+ const { result } = renderHook(() => useBasicsStep(props));
+ expect(result.current.isClusterMissing).toBe(false);
+ });
+
+ test('nonReadyCluster is null for a healthy running cluster', () => {
+ const props = makeProps({
+ formData: {
+ projectName: 'my-project',
+ description: '',
+ subscription: 'sub-123',
+ cluster: 'aks-prod',
+ resourceGroup: 'rg-prod',
+ ingress: 'AllowSameNamespace',
+ egress: 'AllowAll',
+ cpuRequest: 2000,
+ memoryRequest: 4096,
+ cpuLimit: 2000,
+ memoryLimit: 4096,
+ userAssignments: [],
+ },
+ });
+ const { result } = renderHook(() => useBasicsStep(props));
+ expect(result.current.nonReadyCluster).toBeNull();
+ });
+
+ test('nonReadyCluster is populated for an updating cluster', () => {
+ const props = makeProps({
+ clusters: [CLUSTER_UPDATING],
+ formData: {
+ projectName: 'my-project',
+ description: '',
+ subscription: 'sub-123',
+ cluster: 'aks-prod',
+ resourceGroup: 'rg-prod',
+ ingress: 'AllowSameNamespace',
+ egress: 'AllowAll',
+ cpuRequest: 2000,
+ memoryRequest: 4096,
+ cpuLimit: 2000,
+ memoryLimit: 4096,
+ userAssignments: [],
+ },
+ });
+ const { result } = renderHook(() => useBasicsStep(props));
+ expect(result.current.nonReadyCluster).not.toBeNull();
+ expect(result.current.nonReadyCluster?.cluster).toEqual(CLUSTER_UPDATING);
+ expect(result.current.nonReadyCluster?.message).toBe(
+ 'Cluster is currently updating. Deployment may fail.'
+ );
+ });
+
+ test('handleInputChange calls onFormDataChange with the correct field patch', () => {
+ const onFormDataChange = vi.fn();
+ const { result } = renderHook(() => useBasicsStep(makeProps({ onFormDataChange })));
+ act(() => result.current.handleInputChange('projectName', 'new-name'));
+ expect(onFormDataChange).toHaveBeenCalledWith({ projectName: 'new-name' });
+ });
+
+ test('handleClusterChange updates both cluster and resourceGroup', () => {
+ const onFormDataChange = vi.fn();
+ const { result } = renderHook(() => useBasicsStep(makeProps({ onFormDataChange })));
+ act(() => result.current.handleClusterChange('aks-prod'));
+ expect(onFormDataChange).toHaveBeenCalledWith({
+ cluster: 'aks-prod',
+ resourceGroup: 'rg-prod',
+ });
+ });
+
+ test('handleClusterChange does nothing when the cluster name is not in the list', () => {
+ const onFormDataChange = vi.fn();
+ const { result } = renderHook(() => useBasicsStep(makeProps({ onFormDataChange })));
+ act(() => result.current.handleClusterChange('nonexistent-cluster'));
+ expect(onFormDataChange).not.toHaveBeenCalled();
+ });
+
+ test('handleClusterChange resets cluster and resourceGroup when called with empty string', () => {
+ const onFormDataChange = vi.fn();
+ const { result } = renderHook(() => useBasicsStep(makeProps({ onFormDataChange })));
+ act(() => result.current.handleClusterChange(''));
+ expect(onFormDataChange).toHaveBeenCalledWith({ cluster: '', resourceGroup: '' });
+ });
+
+ test('auto-selects default subscription when authStatus matches and none is selected', async () => {
+ mockUseAzureAuth.mockReturnValue({
+ isLoggedIn: true,
+ isChecking: false,
+ subscriptionId: 'sub-123',
+ });
+ const onFormDataChange = vi.fn();
+ renderHook(() => useBasicsStep(makeProps({ onFormDataChange })));
+ await waitFor(() => {
+ expect(onFormDataChange).toHaveBeenCalledWith({ subscription: 'sub-123' });
+ });
+ });
+
+ test('does not auto-select subscription when one is already chosen', async () => {
+ mockUseAzureAuth.mockReturnValue({
+ isLoggedIn: true,
+ isChecking: false,
+ subscriptionId: 'sub-123',
+ });
+ const onFormDataChange = vi.fn();
+ renderHook(() =>
+ useBasicsStep(
+ makeProps({
+ onFormDataChange,
+ formData: {
+ projectName: 'my-project',
+ description: '',
+ subscription: 'sub-123',
+ cluster: '',
+ resourceGroup: '',
+ ingress: 'AllowSameNamespace',
+ egress: 'AllowAll',
+ cpuRequest: 2000,
+ memoryRequest: 4096,
+ cpuLimit: 2000,
+ memoryLimit: 4096,
+ userAssignments: [],
+ },
+ })
+ )
+ );
+ // Allow any pending effects to flush before asserting the negative
+ await waitFor(() => {
+ expect(onFormDataChange).not.toHaveBeenCalled();
+ });
+ });
+
+ test('does not auto-select when authStatus subscriptionId is not in the list', async () => {
+ mockUseAzureAuth.mockReturnValue({
+ isLoggedIn: true,
+ isChecking: false,
+ subscriptionId: 'sub-999',
+ });
+ const onFormDataChange = vi.fn();
+ renderHook(() => useBasicsStep(makeProps({ onFormDataChange })));
+ // Allow any pending effects to flush before asserting the negative
+ await waitFor(() => {
+ expect(onFormDataChange).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.ts b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.ts
new file mode 100644
index 000000000..770cf4d1d
--- /dev/null
+++ b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useBasicsStep.ts
@@ -0,0 +1,296 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache 2.0.
+
+import { K8s, useTranslation } from '@kinvolk/headlamp-plugin/lib';
+import { useEffect, useRef } from 'react';
+import { useAzureAuth } from '../../../hooks/useAzureAuth';
+import type { SearchableSelectOption } from '../components/SearchableSelect';
+import type { AzureCluster, AzureSubscription, FormData } from '../types';
+
+/**
+ * The subset of {@link BasicsStepProps} that {@link useBasicsStep} actually
+ * reads. Keeping the hook's input narrow makes the dependency contract explicit
+ * and simplifies testing.
+ */
+export interface UseBasicsStepInput {
+ formData: FormData;
+ onFormDataChange: (updates: Partial) => void;
+ subscriptions: AzureSubscription[];
+ clusters: AzureCluster[];
+ loadingClusters: boolean;
+ totalClusterCount: number | null;
+}
+
+// ---------------------------------------------------------------------------
+// Pure helper functions (no hooks, fully testable in isolation)
+// ---------------------------------------------------------------------------
+
+/**
+ * Returns the helper text shown below the Cluster select field.
+ *
+ * - While clusters are loading, shows a static note about the Entra ID filter.
+ * - After loading, reports how many eligible clusters were found and how many
+ * were hidden because they lack Azure Entra ID authentication.
+ *
+ * @param t - The i18n translation function.
+ * @param loadingClusters - Whether clusters are currently being fetched.
+ * @param clusterCount - Number of eligible (Entra ID) clusters in the list.
+ * @param totalClusterCount - Total clusters in the subscription before filtering,
+ * or `null` if not yet known.
+ */
+export function getClusterHelperText(
+ t: (key: string, options?: Record) => string,
+ loadingClusters: boolean,
+ clusterCount: number,
+ totalClusterCount: number | null
+): string {
+ if (loadingClusters) {
+ return t('Only clusters with Azure Entra ID authentication are shown.');
+ }
+ const hiddenCount =
+ totalClusterCount !== null && totalClusterCount > clusterCount
+ ? totalClusterCount - clusterCount
+ : 0;
+ const hiddenSuffix =
+ hiddenCount > 0
+ ? ` (${t('{{count}} cluster(s) hidden — no Azure Entra ID', { count: hiddenCount })})`
+ : '';
+ if (clusterCount === 0) {
+ return `${t('No eligible clusters found in this subscription.')}${hiddenSuffix}`;
+ }
+ return `${t('{{count}} eligible cluster(s) found.', { count: clusterCount })}${hiddenSuffix}`;
+}
+
+/**
+ * Returns `true` when the cluster is in a provisioning or power state that
+ * makes deployment unreliable (updating, upgrading, deleting, creating,
+ * failed, stopping, stopped, deallocating, or deallocated).
+ *
+ * @param cluster - The Azure cluster to inspect.
+ */
+export function isClusterNonReady(cluster: AzureCluster): boolean {
+ const provisioningState = cluster.status?.toLowerCase() || '';
+ const powerState = cluster.powerState?.toLowerCase() || '';
+
+ const nonReadyProvisioningStates = ['updating', 'upgrading', 'deleting', 'creating', 'failed'];
+ const nonReadyPowerStates = ['stopping', 'stopped', 'deallocating', 'deallocated'];
+
+ return (
+ nonReadyProvisioningStates.includes(provisioningState) ||
+ nonReadyPowerStates.includes(powerState)
+ );
+}
+
+/**
+ * Returns a human-readable warning message for the cluster's current
+ * non-ready state, or an empty string if the cluster is ready.
+ *
+ * @param cluster - The Azure cluster to inspect.
+ * @param t - The i18n translation function.
+ */
+export function getClusterStateMessage(cluster: AzureCluster, t: (key: string) => string): string {
+ const provisioningState = cluster.status?.toLowerCase() || '';
+ const powerState = cluster.powerState?.toLowerCase() || '';
+
+ if (provisioningState === 'updating' || provisioningState === 'upgrading') {
+ return t('Cluster is currently updating. Deployment may fail.');
+ }
+ if (provisioningState === 'deleting') {
+ return t('Cluster is being deleted. Cannot deploy to this cluster.');
+ }
+ if (provisioningState === 'creating') {
+ return t('Cluster is still being created. Please wait until creation completes.');
+ }
+ if (provisioningState === 'failed') {
+ return t('Cluster is in a failed state. Please check Azure portal.');
+ }
+ if (powerState === 'stopped' || powerState === 'stopping') {
+ return t('Cluster is stopped. Please start the cluster before deploying.');
+ }
+ if (powerState === 'deallocated' || powerState === 'deallocating') {
+ return t('Cluster is deallocated. Please start the cluster before deploying.');
+ }
+ return '';
+}
+
+// ---------------------------------------------------------------------------
+// Hook return type
+// ---------------------------------------------------------------------------
+
+/**
+ * Return type for {@link useBasicsStep}.
+ */
+export interface UseBasicsStepResult {
+ /** Ref attached to the Project Name input to steal focus on mount. */
+ projectNameRef: React.RefObject;
+ /** Subscription list formatted for {@link SearchableSelect}. */
+ subscriptionOptions: SearchableSelectOption[];
+ /** Cluster list formatted for {@link SearchableSelect}. */
+ clusterOptions: SearchableSelectOption[];
+ /** Helper text shown below the Cluster select field. */
+ clusterHelperText: string;
+ /** The currently selected Azure subscription object, or `undefined` if none. */
+ selectedSubscription: AzureSubscription | undefined;
+ /** The currently selected Azure cluster object, or `undefined` if none. */
+ selectedCluster: AzureCluster | undefined;
+ /**
+ * `true` when a cluster is selected but is not present in the headlamp
+ * kubeconfig — the user must register it before proceeding.
+ */
+ isClusterMissing: boolean;
+ /**
+ * When the selected cluster is in a non-ready state, contains the cluster
+ * object and a pre-translated warning message. `null` otherwise.
+ */
+ nonReadyCluster: { cluster: AzureCluster; message: string } | null;
+ /**
+ * Generic field change handler. Calls `onFormDataChange` with a single
+ * key-value patch.
+ */
+ handleInputChange: (field: K, value: FormData[K]) => void;
+ /**
+ * Cluster selection handler. Updates both `cluster` and `resourceGroup`
+ * together so they stay in sync.
+ */
+ handleClusterChange: (clusterName: string) => void;
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+/**
+ * Encapsulates all stateful logic for the Basics step of the Create AKS
+ * Project wizard.
+ *
+ * Responsibilities:
+ * - Focus the Project Name input on mount (when nothing else has focus).
+ * - Auto-select the default subscription once `authStatus.subscriptionId` is
+ * known and matches an entry in the subscription list.
+ * - Derive display-ready option lists for the subscription and cluster
+ * `SearchableSelect` fields.
+ * - Compute the cluster helper text, selected subscription and cluster objects,
+ * missing-cluster flag, and non-ready cluster warning from the current form state.
+ * - Provide `handleInputChange` and `handleClusterChange` callbacks that
+ * delegate to `props.onFormDataChange`.
+ *
+ * @param props - The fields from {@link UseBasicsStepInput} that the hook needs.
+ */
+export function useBasicsStep(props: UseBasicsStepInput): UseBasicsStepResult {
+ const { t } = useTranslation();
+ const {
+ formData,
+ onFormDataChange,
+ subscriptions,
+ clusters,
+ loadingClusters,
+ totalClusterCount,
+ } = props;
+
+ const headlampClusters = K8s.useClustersConf();
+ const authStatus = useAzureAuth();
+
+ // Focus the Project Name input on mount. Only steals focus when nothing
+ // else is focused (activeElement is ) so it doesn't interrupt
+ // interactions that started before the AzureAuthGuard finished mounting.
+ const projectNameRef = useRef(null);
+ useEffect(() => {
+ if (document.activeElement?.tagName === 'BODY') {
+ projectNameRef.current?.focus();
+ }
+ }, []);
+
+ // Auto-select the default subscription exactly once. The ref guards against
+ // re-running when the effect re-fires due to unrelated dependency changes.
+ const autoSelected = useRef(false);
+ useEffect(() => {
+ if (
+ !autoSelected.current &&
+ authStatus?.subscriptionId &&
+ !formData.subscription &&
+ subscriptions &&
+ subscriptions.find(it => it.id === authStatus.subscriptionId)
+ ) {
+ autoSelected.current = true;
+ onFormDataChange({ subscription: authStatus.subscriptionId });
+ }
+ }, [formData.subscription, authStatus?.subscriptionId, subscriptions, onFormDataChange]);
+
+ // ---------------------------------------------------------------------------
+ // Derived values
+ // ---------------------------------------------------------------------------
+
+ const subscriptionOptions: SearchableSelectOption[] = subscriptions.map(sub => ({
+ value: sub.id,
+ label: sub.name,
+ subtitle: `${t('Tenant')}: ${sub.tenantName} - (${sub.tenant}) • ${t('Status')}: ${sub.status}`,
+ }));
+
+ const clusterOptions: SearchableSelectOption[] = clusters.map(cluster => ({
+ value: cluster.name,
+ label: cluster.name,
+ subtitle: `${cluster.location} • ${cluster.version} • ${t('{{count}} nodes', {
+ count: cluster.nodeCount,
+ })} • ${cluster.status}`,
+ }));
+
+ const clusterHelperText = getClusterHelperText(
+ t,
+ loadingClusters,
+ clusters.length,
+ totalClusterCount
+ );
+
+ const selectedSubscription = formData.subscription
+ ? subscriptions.find(s => s.id === formData.subscription)
+ : undefined;
+
+ const selectedCluster = formData.cluster
+ ? clusters.find(c => c.name === formData.cluster)
+ : undefined;
+
+ const isClusterMissing =
+ selectedCluster !== undefined &&
+ Object.values(headlampClusters || {}).find((it: any) => it.name === selectedCluster.name) ===
+ undefined;
+
+ const nonReadyCluster: UseBasicsStepResult['nonReadyCluster'] =
+ selectedCluster && isClusterNonReady(selectedCluster)
+ ? {
+ cluster: selectedCluster,
+ message: getClusterStateMessage(selectedCluster, t),
+ }
+ : null;
+
+ // ---------------------------------------------------------------------------
+ // Callbacks
+ // ---------------------------------------------------------------------------
+
+ const handleInputChange = (field: K, value: FormData[K]) => {
+ onFormDataChange({ [field]: value } as Pick);
+ };
+
+ const handleClusterChange = (clusterName: string) => {
+ if (!clusterName) {
+ onFormDataChange({ cluster: '', resourceGroup: '' });
+ return;
+ }
+ const found = clusters.find(c => c.name === clusterName);
+ if (found) {
+ onFormDataChange({ cluster: clusterName, resourceGroup: found.resourceGroup });
+ }
+ };
+
+ return {
+ projectNameRef,
+ subscriptionOptions,
+ clusterOptions,
+ clusterHelperText,
+ selectedSubscription,
+ selectedCluster,
+ isClusterMissing,
+ nonReadyCluster,
+ handleInputChange,
+ handleClusterChange,
+ };
+}
diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.test.ts b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.test.ts
new file mode 100644
index 000000000..64ec90699
--- /dev/null
+++ b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.test.ts
@@ -0,0 +1,215 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache 2.0.
+
+// @vitest-environment jsdom
+
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const mockRegisterAKSCluster = vi.hoisted(() => vi.fn());
+
+vi.mock('../../../utils/azure/aks', () => ({
+ registerAKSCluster: mockRegisterAKSCluster,
+}));
+
+vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
+ useTranslation: () => ({
+ t: (key: string, opts?: any) => {
+ // Minimal interpolation so success/error messages come through correctly
+ if (!opts) return key;
+ return key.replace(/\{\{(\w+)\}\}/g, (_: string, k: string) => opts[k] ?? k);
+ },
+ }),
+}));
+
+import { useRegisterCluster } from './useRegisterCluster';
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('useRegisterCluster', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ test('starts with loading=false, no error, no success', () => {
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123'));
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeUndefined();
+ expect(result.current.success).toBeUndefined();
+ });
+
+ test('handleRegister sets loading=true while the call is in flight', async () => {
+ let resolveRegister!: (value: { success: boolean; message: string }) => void;
+ const registerPromise = new Promise<{ success: boolean; message: string }>(resolve => {
+ resolveRegister = resolve;
+ });
+ mockRegisterAKSCluster.mockReturnValue(registerPromise);
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123'));
+
+ let handleRegisterPromise!: Promise;
+ act(() => {
+ handleRegisterPromise = result.current.handleRegister();
+ });
+ expect(result.current.loading).toBe(true);
+
+ await act(async () => {
+ resolveRegister({ success: true, message: 'ok' });
+ await handleRegisterPromise;
+ });
+ });
+
+ test('handleRegister sets success message on result.success=true', async () => {
+ mockRegisterAKSCluster.mockResolvedValue({ success: true, message: 'ok' });
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123'));
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBeUndefined();
+ expect(result.current.success).toContain('aks-prod');
+ });
+
+ test('handleRegister sets error when result.success=false', async () => {
+ mockRegisterAKSCluster.mockResolvedValue({ success: false, message: 'credentials expired' });
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123'));
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toBe('credentials expired');
+ expect(result.current.success).toBeUndefined();
+ });
+
+ test('handleRegister sets error on thrown Error', async () => {
+ mockRegisterAKSCluster.mockRejectedValue(new Error('network timeout'));
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123'));
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+
+ expect(result.current.loading).toBe(false);
+ expect(result.current.error).toContain('network timeout');
+ expect(result.current.success).toBeUndefined();
+ });
+
+ test('handleRegister uses "Unknown error" for non-Error rejections', async () => {
+ mockRegisterAKSCluster.mockRejectedValue('something went wrong');
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123'));
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+
+ expect(result.current.error).toContain('Unknown error');
+ });
+
+ test('handleRegister passes subscription, resourceGroup, cluster, and tenantId to registerAKSCluster', async () => {
+ mockRegisterAKSCluster.mockResolvedValue({ success: true, message: '' });
+ const { result } = renderHook(() =>
+ useRegisterCluster('aks-prod', 'rg-prod', 'sub-123', 'tenant-abc')
+ );
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+
+ expect(mockRegisterAKSCluster).toHaveBeenCalledWith(
+ 'sub-123',
+ 'rg-prod',
+ 'aks-prod',
+ undefined,
+ 'tenant-abc'
+ );
+ });
+
+ test('handleRegister does not call registerAKSCluster when cluster is empty', async () => {
+ const { result } = renderHook(() => useRegisterCluster('', 'rg-prod', 'sub-123'));
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+
+ expect(mockRegisterAKSCluster).not.toHaveBeenCalled();
+ });
+
+ test('handleRegister does not call registerAKSCluster when subscription is empty', async () => {
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', ''));
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+
+ expect(mockRegisterAKSCluster).not.toHaveBeenCalled();
+ });
+
+ test('clearError resets error to undefined', async () => {
+ mockRegisterAKSCluster.mockResolvedValue({ success: false, message: 'something failed' });
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123'));
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+ expect(result.current.error).toBeDefined();
+
+ act(() => result.current.clearError());
+ expect(result.current.error).toBeUndefined();
+ });
+
+ test('clearSuccess resets success to undefined', async () => {
+ mockRegisterAKSCluster.mockResolvedValue({ success: true, message: '' });
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123'));
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+ expect(result.current.success).toBeDefined();
+
+ act(() => result.current.clearSuccess());
+ expect(result.current.success).toBeUndefined();
+ });
+
+ test('clears previous error before a new registration attempt', async () => {
+ let resolveSecond!: (value: { success: boolean; message: string }) => void;
+ const secondPromise = new Promise<{ success: boolean; message: string }>(resolve => {
+ resolveSecond = resolve;
+ });
+ mockRegisterAKSCluster
+ .mockResolvedValueOnce({ success: false, message: 'first error' })
+ .mockReturnValueOnce(secondPromise);
+
+ const { result } = renderHook(() => useRegisterCluster('aks-prod', 'rg-prod', 'sub-123'));
+
+ await act(async () => {
+ await result.current.handleRegister();
+ });
+ expect(result.current.error).toBe('first error');
+
+ let secondHandlePromise!: Promise;
+ act(() => {
+ secondHandlePromise = result.current.handleRegister();
+ });
+ // error should be cleared immediately when the second attempt starts
+ expect(result.current.error).toBeUndefined();
+
+ await act(async () => {
+ resolveSecond({ success: true, message: 'ok' });
+ await secondHandlePromise;
+ });
+ });
+});
diff --git a/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.ts b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.ts
new file mode 100644
index 000000000..aeb28d386
--- /dev/null
+++ b/plugins/aks-desktop/src/components/CreateAKSProject/hooks/useRegisterCluster.ts
@@ -0,0 +1,108 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the Apache 2.0.
+
+import { useTranslation } from '@kinvolk/headlamp-plugin/lib';
+import { useState } from 'react';
+import { registerAKSCluster } from '../../../utils/azure/aks';
+
+/** Set to `true` locally to enable verbose debug logging. Never enable in production. */
+const DEBUG = false;
+
+// ---------------------------------------------------------------------------
+// Return type
+// ---------------------------------------------------------------------------
+
+/**
+ * Return type for {@link useRegisterCluster}.
+ */
+export interface UseRegisterClusterResult {
+ /** `true` while the `az aks get-credentials` call is in flight. */
+ loading: boolean;
+ /** Error message from the last failed registration attempt, or `undefined`. */
+ error: string | undefined;
+ /** Success message once registration completes, or `undefined`. */
+ success: string | undefined;
+ /** Initiates the cluster registration flow. */
+ handleRegister: () => Promise;
+ /** Clears the error message (e.g. when the user dismisses the alert). */
+ clearError: () => void;
+ /** Clears the success message (e.g. when the user dismisses the alert). */
+ clearSuccess: () => void;
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+/**
+ * Manages the async flow for registering a missing AKS cluster into the
+ * headlamp kubeconfig via `az aks get-credentials`.
+ *
+ * Encapsulates the loading / error / success state that previously lived
+ * inline in the `RegisterCluster` component so the component can be a pure
+ * presentational function.
+ *
+ * @param cluster - The AKS cluster name to register.
+ * @param resourceGroup - The resource group the cluster belongs to.
+ * @param subscription - The Azure subscription ID.
+ * @param tenantId - Optional tenant ID for multi-tenant environments.
+ */
+export function useRegisterCluster(
+ cluster: string,
+ resourceGroup: string,
+ subscription: string,
+ tenantId?: string
+): UseRegisterClusterResult {
+ const { t } = useTranslation();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(undefined);
+ const [success, setSuccess] = useState(undefined);
+
+ const handleRegister = async () => {
+ if (!cluster || !resourceGroup || !subscription) {
+ return;
+ }
+
+ setLoading(true);
+ setError(undefined);
+ setSuccess(undefined);
+
+ try {
+ if (DEBUG) console.debug('[AKS] Registering cluster...');
+ const result = await registerAKSCluster(
+ subscription,
+ resourceGroup,
+ cluster,
+ undefined,
+ tenantId
+ );
+ if (DEBUG) console.debug('[AKS] Register cluster result:', result.success);
+
+ if (!result.success) {
+ setError(result.message);
+ return;
+ }
+
+ if (DEBUG) console.debug('[AKS] Cluster registered successfully.', result.message);
+ setSuccess(t("Cluster '{{cluster}}' successfully merged in kubeconfig", { cluster }));
+ } catch (err) {
+ console.error('Error registering AKS cluster:', err);
+ setError(
+ t('Failed to register cluster: {{message}}', {
+ message: err instanceof Error ? err.message : t('Unknown error'),
+ })
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return {
+ loading,
+ error,
+ success,
+ handleRegister,
+ clearError: () => setError(undefined),
+ clearSuccess: () => setSuccess(undefined),
+ };
+}
diff --git a/plugins/aks-desktop/src/components/CreateNamespace/CreateNamespace.tsx b/plugins/aks-desktop/src/components/CreateNamespace/CreateNamespace.tsx
index 28197c15a..fa8e4d960 100644
--- a/plugins/aks-desktop/src/components/CreateNamespace/CreateNamespace.tsx
+++ b/plugins/aks-desktop/src/components/CreateNamespace/CreateNamespace.tsx
@@ -164,7 +164,7 @@ function CreateNamespaceContent() {
setNamespaceName(String(value).toLowerCase())}
+ onChange={value => setNamespaceName(value.toLowerCase())}
error={namespaceName.length > 0 && !NAMESPACE_NAME_REGEX.test(namespaceName)}
helperText={
namespaceName.length > 0 && !NAMESPACE_NAME_REGEX.test(namespaceName)
diff --git a/plugins/aks-desktop/src/components/shared/ComputeStep.tsx b/plugins/aks-desktop/src/components/shared/ComputeStep.tsx
index 037ad48e7..f10e526e8 100644
--- a/plugins/aks-desktop/src/components/shared/ComputeStep.tsx
+++ b/plugins/aks-desktop/src/components/shared/ComputeStep.tsx
@@ -47,7 +47,7 @@ export const ComputeStep: React.FC = ({
label={t('CPU Requests')}
type="number"
value={formData.cpuRequest}
- onChange={value => handleInputChange('cpuRequest', value as number)}
+ onChange={value => handleInputChange('cpuRequest', Number(value))}
disabled={loading}
helperText={
getFieldError('cpuRequest') || t('Minimum CPU guaranteed (1000m = 1 CPU core)')
@@ -80,7 +80,7 @@ export const ComputeStep: React.FC = ({
label={t('CPU Limits')}
type="number"
value={formData.cpuLimit}
- onChange={value => handleInputChange('cpuLimit', value as number)}
+ onChange={value => handleInputChange('cpuLimit', Number(value))}
disabled={loading}
helperText={
getFieldError('cpuLimit') || t('Maximum CPU allowed (1000m = 1 CPU core)')
@@ -116,7 +116,7 @@ export const ComputeStep: React.FC = ({
label={t('Memory Requests')}
type="number"
value={formData.memoryRequest}
- onChange={value => handleInputChange('memoryRequest', value as number)}
+ onChange={value => handleInputChange('memoryRequest', Number(value))}
disabled={loading}
helperText={
getFieldError('memoryRequest') || t('Minimum memory guaranteed (1024 MiB = 1 GiB)')
@@ -147,7 +147,7 @@ export const ComputeStep: React.FC = ({
label={t('Memory Limits')}
type="number"
value={formData.memoryLimit}
- onChange={value => handleInputChange('memoryLimit', value as number)}
+ onChange={value => handleInputChange('memoryLimit', Number(value))}
disabled={loading}
helperText={
getFieldError('memoryLimit') || t('Maximum memory allowed (1024 MiB = 1 GiB)')
diff --git a/plugins/aks-desktop/src/components/shared/FormField.tsx b/plugins/aks-desktop/src/components/shared/FormField.tsx
index a418ddd98..d0fbd9814 100644
--- a/plugins/aks-desktop/src/components/shared/FormField.tsx
+++ b/plugins/aks-desktop/src/components/shared/FormField.tsx
@@ -8,7 +8,7 @@ import React from 'react';
export interface FormFieldProps {
label: string;
value: string | number;
- onChange: (value: string | number) => void;
+ onChange: (value: string) => void;
type?: 'text' | 'email' | 'number' | 'textarea';
multiline?: boolean;
rows?: number;
@@ -49,7 +49,7 @@ export const FormField: React.FC = ({
const numValue = parseFloat(event.target.value) || 0;
// Prevent negative values for number inputs
const validValue = Math.max(0, numValue);
- onChange(validValue);
+ onChange(String(validValue));
} else {
onChange(event.target.value);
}