Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
07fa578
chore(gui): add docs reference for phased deployments
mzedel May 27, 2026
3f2e3a0
chore(gui): added shared deployment phase types + modes information &…
mzedel May 27, 2026
ee9afd7
feat(gui): added deployment phase definition input to align w/ update…
mzedel May 26, 2026
1c39855
feat(gui): added support for percentage & device count deployment pha…
mzedel May 26, 2026
754aacd
feat(gui): add support for repeating uniform deployment phase patterns
mzedel May 26, 2026
8758010
refactor(gui): track deployment schedule independent of phase definit…
mzedel May 27, 2026
25aa76a
refactor(gui): integrated custom phase pattern support in phase settings
mzedel Aug 6, 2026
9f55fcc
refactor(gui): allow deployment creation despite faulty phase definit…
mzedel May 27, 2026
30a0707
refactor(gui): integrate rollout modes into deployment creation
mzedel May 27, 2026
24784c9
feat(gui): support device-count rollouts in deployment progress
mzedel Aug 6, 2026
7d87f9b
test(gui): adjust tests for phase rework
mzedel Aug 7, 2026
f0b273b
chore(gui): allowed time format alignment across the ui
mzedel Aug 5, 2026
2c83dc8
chore(gui): allowed deployment creation helper components
mzedel Aug 5, 2026
b39d4f1
chore(gui): extended checkbox configuration options to align w/ under…
mzedel Aug 5, 2026
5ed5b85
chore(gui): aligned deployment creation options w/ updated design
mzedel Aug 4, 2026
b4845e3
refactor(gui): integrated deployment options further into rhf
mzedel Aug 4, 2026
a3c7266
feat(gui): made deployment creation more informative via contextual e…
mzedel Aug 5, 2026
16db497
feat(gui): explained & cleared unavailable deployment options
mzedel Aug 6, 2026
b873f8e
test(gui): aligned deployment creation checks w/ software understandi…
mzedel Aug 4, 2026
ebb132c
chore(gui): aligned snapshots w/ the updated deployment creation drawer
mzedel Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/src/js/common-ui/DocsLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,15 @@ const useStyles = makeStyles()(theme => ({
}));

export const DOCSTIPS = {
deltaArtifacts: { id: 'deltaArtifacts', path: 'artifact-creation/server-side-generation-of-delta-artifacts' },
deviceConfig: { id: 'deviceConfig', path: 'add-ons/configure' },
deviceIdentity: { id: 'deviceIdentity', path: 'client-installation/identity' },
dynamicDeployments: { id: 'dynamicDeployments', path: 'overview/deployment#phased-rollouts-and-dynamic-groups' },
dynamicGroups: { id: 'dynamicGroups', path: 'overview/device-group#dynamic-group' },
hostedRegions: { id: 'hostedRegions', path: 'general/hosted-mender-regions' },
limitedDeployments: { id: 'limitedDeployments', path: 'overview/deployment#deployment-to-dynamic-groups' },
orchestratorManifest: { id: 'orchestratorManifest', path: 'orchestrate-updates/manifest' },
phasedDeployments: { id: 'phasedDeployments', path: 'overview/customize-the-update-process' },
phasedDeployments: { id: 'phasedDeployments', path: 'overview/deployment#phased-rollouts-and-dynamic-groups' },
pausedDeployments: { id: 'pausedDeployments', path: 'overview/customize-the-update-process#synchronized-updates' },
retryDeployments: { id: 'retryDeployments', path: 'overview/deployment' },
releases: { id: 'releases', path: 'overview/artifact' },
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/js/common-ui/Time.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime.js';
import pluralize from 'pluralize';

const defaultDateFormat = 'YYYY-MM-DD';
const defaultTimeFormat = `${defaultDateFormat} HH:mm`;
export const defaultDateFormat = 'YYYY-MM-DD';
export const defaultTimeFormat = `${defaultDateFormat} HH:mm`;

// based on react-time - https://github.com/andreypopp/react-time - which unfortunately is no longer maintained
dayjs.extend(relativeTime);
Expand Down
46 changes: 44 additions & 2 deletions frontend/src/js/common-ui/forms/FormCheckbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,43 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { CSSProperties, MouseEventHandler, ReactNode } from 'react';
import type { Control, FieldValues } from 'react-hook-form';
import { Controller } from 'react-hook-form';

import type { CheckboxProps, FormControlLabelProps } from '@mui/material';
import { Checkbox, FormControlLabel } from '@mui/material';

export const FormCheckbox = ({ className, control, disabled, id, handleClick, style, label, required }) => (
type FormCheckboxSlotProps = {
checkbox?: Partial<CheckboxProps>;
label?: Partial<Omit<FormControlLabelProps, 'control' | 'label'>>;
};

type FormCheckboxProps = {
className?: string;
control?: Control<FieldValues>;
disabled?: boolean;
handleClick?: MouseEventHandler<HTMLButtonElement>;
id: string;
label?: ReactNode;
required?: boolean;
slotProps?: FormCheckboxSlotProps;
style?: CSSProperties;
};

const emptySlotProps: FormCheckboxSlotProps = { label: {}, checkbox: {} };

export const FormCheckbox = ({
className,
control,
disabled,
id,
handleClick,
style,
label,
required,
slotProps: { label: labelProps, checkbox: checkboxProps } = emptySlotProps
}: FormCheckboxProps) => (
<Controller
name={id}
rules={{ required }}
Expand All @@ -24,9 +56,19 @@ export const FormCheckbox = ({ className, control, disabled, id, handleClick, st
<FormControlLabel
className={className}
control={
<Checkbox name={id} onClick={handleClick} disabled={disabled} checked={value} style={style} color="primary" onChange={() => onChange(!value)} />
<Checkbox
name={id}
onClick={handleClick}
disabled={disabled}
checked={value}
style={style}
color="primary"
onChange={() => onChange(!value)}
{...checkboxProps}
/>
}
label={label}
{...labelProps}
/>
)}
/>
Expand Down
158 changes: 87 additions & 71 deletions frontend/src/js/components/deployments/CreateDeployment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,20 @@ import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';

import { defaultState, render } from '@/testUtils';
import GeneralApi from '@northern.tech/store/api/general-api';
import { ALL_DEVICES } from '@northern.tech/store/constants';
import { undefineds } from '@northern.tech/testing/mockData';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';

import CreateDeployment, { defaultValues as formDefaultValues } from './CreateDeployment';
import { DeviceLimit } from './deployment-wizard/DeviceLimit';
import { RolloutPatternSelection, getPhaseDeviceCount, getRemainderPercent, validatePhases } from './deployment-wizard/PhaseSettings';
import { RolloutPatternSelection } from './deployment-wizard/PhaseSettings';
import { ForceDeploy, Retries, RolloutOptions } from './deployment-wizard/RolloutOptions';
import { ScheduleRollout } from './deployment-wizard/ScheduleRollout';
import { Devices, ReleasesWarning, Software } from './deployment-wizard/SoftwareDevices';
import { deploymentErrors } from './deployment-wizard/validation';

const FormWrapper = ({ children, defaultValues = {} }) => {
const methods = useForm({
Expand All @@ -37,6 +42,9 @@ const FormWrapper = ({ children, defaultValues = {} }) => {
return <FormProvider {...methods}>{children}</FormProvider>;
};

// the rollout options exclude each other, so they have to be enabled one at a time to render their expanded state
const expandedDefaultValues = { RolloutOptions: { isPaused: true }, RolloutPatternSelection: { usesPattern: true } };

const preloadedState = {
...defaultState,
app: {
Expand All @@ -48,11 +56,18 @@ const preloadedState = {
}
}
};
const deploymentCreationTime = defaultState.deployments.byId.d1.created;

const renderWrapper = ({ deploymentObject = {}, onScheduleSubmit = vi.fn(), preloadedState: preloadedStateProp = preloadedState }) =>
render(
<LocalizationProvider dateAdapter={AdapterDayjs}>
<CreateDeployment deploymentObject={deploymentObject} onScheduleSubmit={onScheduleSubmit} onValuesChange={vi.fn()} open />
</LocalizationProvider>,
{ preloadedState: preloadedStateProp }
);

describe('CreateDeployment Component', () => {
it('renders correctly', async () => {
const { baseElement } = render(<CreateDeployment deploymentObject={{}} onValuesChange={vi.fn()} open />, { preloadedState });
const { baseElement } = renderWrapper({});
const view = baseElement.getElementsByClassName('MuiDrawer-root')[0];
expect(view).toMatchSnapshot();
expect(view).toEqual(expect.not.stringMatching(undefineds));
Expand All @@ -77,7 +92,7 @@ describe('CreateDeployment Component', () => {
it(`renders ${Component.displayName || Component.name} correctly`, () => {
const { baseElement } = render(
<LocalizationProvider dateAdapter={AdapterDayjs}>
<FormWrapper>
<FormWrapper defaultValues={expandedDefaultValues[Component.name] ?? {}}>
<Component {...props} />
</FormWrapper>
</LocalizationProvider>,
Expand All @@ -91,7 +106,7 @@ describe('CreateDeployment Component', () => {
it(`renders ${Component.displayName || Component.name} correctly as enterprise`, () => {
const { baseElement } = render(
<LocalizationProvider dateAdapter={AdapterDayjs}>
<FormWrapper>
<FormWrapper defaultValues={expandedDefaultValues[Component.name] ?? {}}>
<Component {...props} isEnterprise />
</FormWrapper>
</LocalizationProvider>,
Expand All @@ -105,75 +120,76 @@ describe('CreateDeployment Component', () => {
});
});

describe('utility functions', () => {
describe('getPhaseDeviceCount function', () => {
it('works with empty attributes', async () => {
expect(getPhaseDeviceCount(120, 10, 20, false)).toEqual(12);
expect(getPhaseDeviceCount(120, 10, 20, true)).toEqual(12);
expect(getPhaseDeviceCount(120, null, 20, true)).toEqual(24);
expect(getPhaseDeviceCount(120, null, 20, false)).toEqual(24);
expect(getPhaseDeviceCount(undefined, null, 20, false)).toEqual(0);
});
describe('validation', () => {
it('accepts a click on an incomplete deployment & points out what is missing', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const onScheduleSubmit = vi.fn();
renderWrapper({ onScheduleSubmit });
const submitButton = screen.getByRole('button', { name: /create deployment/i });
expect(submitButton).toBeEnabled();
await user.click(submitButton);
await waitFor(() => expect(screen.getByText(deploymentErrors.release)).toBeVisible());
expect(screen.getByText(deploymentErrors.group)).toBeVisible();
expect(onScheduleSubmit).not.toHaveBeenCalled();
});
describe('getRemainderPercent function', () => {
it('remainder Percent calculated correctly', async () => {
const phases = [
{ batch_size: 10, not: 'interested' },
{ batch_size: 10, not: 'interested' },
{ batch_size: 10, not: 'interested' }
];
expect(getRemainderPercent(phases)).toEqual(80);
expect(
getRemainderPercent([
{ batch_size: 10, not: 'interested' },
{ batch_size: 90, not: 'interested' }
])
).toEqual(90);
expect(
getRemainderPercent([
{ batch_size: 10, not: 'interested' },
{ batch_size: 95, not: 'interested' }
])
).toEqual(90);
// this will be caught in the phase validation - should still be good to be fixed in the future
expect(
getRemainderPercent([
{ batch_size: 50, not: 'interested' },
{ batch_size: 55, not: 'interested' },
{ batch_size: 95, not: 'interested' }
])
).toEqual(-5);
});

it('drops an error once its field is taken care of', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderWrapper({});
await user.click(screen.getByRole('button', { name: /create deployment/i }));
await waitFor(() => expect(screen.getByText(deploymentErrors.group)).toBeVisible());
const groupSelect = screen.getByPlaceholderText(/select a device group/i);
await user.type(groupSelect, 'testGroupDyn');
await user.keyboard('{ArrowDown}{Enter}');
await waitFor(() => expect(screen.queryByText(deploymentErrors.group)).toBeFalsy());
expect(screen.getByText(deploymentErrors.release)).toBeVisible();
});

describe('validatePhases function', () => {
it('works as expected', async () => {
const phases = [
{
batch_size: 10,
delay: 2,
delayUnit: 'hours',
start_ts: deploymentCreationTime
},
{ batch_size: 10, delay: 2, start_ts: deploymentCreationTime },
{ batch_size: 10, start_ts: deploymentCreationTime }
];
expect(validatePhases(undefined, 10000)).toEqual(true);
expect(validatePhases(undefined, 10000)).toEqual(true);
expect(validatePhases(phases, 10)).toEqual(true);
expect(validatePhases(phases, 10)).toEqual(true);
expect(validatePhases([], 10)).toEqual(true);
expect(
validatePhases(
[
{ batch_size: 50, not: 'interested' },
{ batch_size: 55, not: 'interested' },
{ batch_size: 95, not: 'interested' }
],
100
)
).toEqual(false);
});
it('drops an error once the option that caused it is switched off again', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderWrapper({});
await user.click(screen.getByRole('button', { name: /advanced options/i }));
const limitCheckbox = screen.getByRole('checkbox', { name: /maximum number of devices/i });
await user.click(limitCheckbox);
await user.click(screen.getByRole('button', { name: /create deployment/i }));
await waitFor(() => expect(screen.getByText(deploymentErrors.maxDevices)).toBeVisible());
await user.click(limitCheckbox);
await waitFor(() => expect(screen.queryByText(deploymentErrors.maxDevices)).toBeFalsy());
});

it('applies a schedule even without a rollout pattern', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// scheduling is plan gated, so the plain preloadedState would leave the schedule selection disabled
const enterpriseState = {
...defaultState,
app: { ...defaultState.app, features: { ...defaultState.app.features, isEnterprise: true } }
};
renderWrapper({ deploymentObject: { group: ALL_DEVICES, release: defaultState.releases.byId.r1 }, preloadedState: enterpriseState });
await user.click(screen.getByText(/start immediately/i));
await user.click(await screen.findByRole('option', { name: /schedule the start date/i }));
await user.click(await screen.findByRole('gridcell', { name: '28' }));
await user.click(screen.getByRole('button', { name: 'Next' }));
await user.click(await screen.findByRole('button', { name: /ok/i }));
// let the picker dialog finish its exit transition to give the drawer back its visibility
await act(async () => vi.runOnlyPendingTimers());
const post = vi.spyOn(GeneralApi, 'post');
await user.click(screen.getByRole('button', { name: /create deployment/i }));
await waitFor(() =>
expect(post).toHaveBeenCalledWith(
'/api/management/v1/deployments/deployments',
expect.objectContaining({ phases: [{ batch_size: 100, start_ts: expect.stringMatching(/^2019-01-28/) }] })
)
);
});

it('expands the advanced options to show an error hidden in them', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderWrapper({ deploymentObject: { group: 'testGroupDynamic', maxDevices: -90 } });
const accordionToggle = screen.getByRole('button', { name: /advanced options/i });
expect(accordionToggle).toHaveAttribute('aria-expanded', 'false');
await user.click(screen.getByRole('button', { name: /create deployment/i }));
await waitFor(() => expect(accordionToggle).toHaveAttribute('aria-expanded', 'true'));
expect(screen.getByText(deploymentErrors.maxDevices)).toBeVisible();
});
});
});
Loading