1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

refactor(ui/modals): replace bootbox with react solution [EE-4541] (#8010)

This commit is contained in:
Chaim Lev-Ari 2023-02-14 13:49:41 +05:30 committed by GitHub
parent 392c7f74b8
commit e66dea44e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
111 changed files with 1330 additions and 1562 deletions

View file

@ -3,8 +3,8 @@ import { useStore } from 'zustand';
import { ContainerGroup } from '@/react/azure/types';
import { Authorized } from '@/react/hooks/useUser';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { confirmDelete } from '@@/modals/confirm';
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
@ -63,7 +63,7 @@ export function ContainersDatatable({ dataset, onRemoveClick }: Props) {
);
async function handleRemoveClick(containerIds: string[]) {
const confirmed = await confirmDeletionAsync(
const confirmed = await confirmDelete(
'Are you sure you want to delete the selected containers?'
);
if (!confirmed) {

View file

@ -22,6 +22,7 @@ type Color =
| 'light'
| 'dangerlight'
| 'warninglight'
| 'warning'
| 'none';
type Size = 'xsmall' | 'small' | 'medium' | 'large';

View file

@ -67,9 +67,10 @@ export function SingleSelect<TValue = string>({
isClearable,
bindToBody,
}: SingleProps<TValue>) {
const selectedValue = value
? _.first(findSelectedOptions<TValue>(options, value))
: null;
const selectedValue =
value || (typeof value === 'number' && value === 0)
? _.first(findSelectedOptions<TValue>(options, value))
: null;
return (
<ReactSelect<Option<TValue>>
@ -94,7 +95,8 @@ function findSelectedOptions<TValue>(
value: TValue | readonly TValue[]
) {
const valueArr = Array.isArray(value) ? value : [value];
return _.compact(
const values = _.compact(
options.flatMap((option) => {
if (isGroup(option)) {
return option.options.find((option) => valueArr.includes(option.value));
@ -107,6 +109,8 @@ function findSelectedOptions<TValue>(
return null;
})
);
return values;
}
export function MultiSelect<TValue = string>({

View file

@ -1,7 +1,6 @@
/* switch box */
.switch,
.bootbox-checkbox-list > .checkbox > label {
.switch {
--switch-size: 24px;
}
@ -13,8 +12,7 @@
display: none;
}
.switch i,
.bootbox-form .checkbox i {
.switch i {
display: inline-block;
vertical-align: middle;
cursor: pointer;
@ -27,8 +25,7 @@
box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, 0.5);
}
.switch i:before,
.bootbox-form .checkbox i:before {
.switch i:before {
display: block;
content: '';
width: var(--switch-size);
@ -38,8 +35,7 @@
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.5);
}
.switch :checked + i,
.bootbox-form .checkbox :checked ~ i {
.switch :checked + i {
padding-right: 0;
padding-left: var(--switch-size);
-webkit-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.5), inset 0 0 40px #337ab7;

View file

@ -0,0 +1,56 @@
import { ReactNode } from 'react';
import { Button } from '@@/buttons';
import { ButtonOptions, ModalType } from './types';
import { openModal } from './open-modal';
import { Modal, OnSubmit } from './Modal';
export interface DialogOptions<T> {
title?: ReactNode;
message: ReactNode;
modalType?: ModalType;
buttons: Array<ButtonOptions<T>>;
}
interface Props<T> extends DialogOptions<T> {
onSubmit: OnSubmit<T>;
}
export function Dialog<T>({
buttons,
message,
title,
onSubmit,
modalType,
}: Props<T>) {
const ariaLabel = requireString(title) || requireString(message) || 'Dialog';
return (
<Modal onDismiss={() => onSubmit()} aria-label={ariaLabel}>
{title && <Modal.Header title={title} modalType={modalType} />}
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
{buttons.map((button, index) => (
<Button
onClick={() => onSubmit(button.value)}
className={button.className}
color={button.color}
key={index}
size="medium"
>
{button.label}
</Button>
))}
</Modal.Footer>
</Modal>
);
}
function requireString(value: ReactNode) {
return typeof value === 'string' ? value : undefined;
}
export async function openDialog<T>(options: DialogOptions<T>) {
return openModal<DialogOptions<T>, T>(Dialog, options);
}

View file

@ -0,0 +1,74 @@
import { ReactNode, useState } from 'react';
import { SwitchField } from '@@/form-components/SwitchField';
import { ModalType, type ButtonOptions } from './types';
import { openModal } from './open-modal';
import { OnSubmit } from './Modal/types';
import { Dialog } from './Dialog';
import { buildCancelButton, buildConfirmButton } from './utils';
function SwitchPrompt({
onSubmit,
title,
confirmButton = buildConfirmButton('OK'),
switchLabel,
modalType,
message,
defaultValue = false,
}: {
onSubmit: OnSubmit<{ value: boolean }>;
title: string;
switchLabel: string;
confirmButton?: ButtonOptions<true>;
modalType?: ModalType;
message?: ReactNode;
defaultValue?: boolean;
}) {
const [value, setValue] = useState(defaultValue);
return (
<Dialog
modalType={modalType}
title={title}
message={
<>
{message && <div className="mb-3">{message}</div>}
<SwitchField
name="value"
label={switchLabel}
checked={value}
onChange={setValue}
/>
</>
}
onSubmit={(confirm) => onSubmit(confirm ? { value } : undefined)}
buttons={[buildCancelButton(), confirmButton]}
/>
);
}
export async function openSwitchPrompt(
title: string,
switchLabel: string,
{
confirmButton,
modalType,
message,
defaultValue,
}: {
confirmButton?: ButtonOptions<true>;
modalType?: ModalType;
message?: ReactNode;
defaultValue?: boolean;
} = {}
) {
return openModal(SwitchPrompt, {
confirmButton,
title,
switchLabel,
modalType,
message,
defaultValue,
});
}

View file

@ -0,0 +1,80 @@
import { openDialog, DialogOptions } from './Dialog';
import { OnSubmit, ModalType } from './Modal';
import { ButtonOptions } from './types';
import { buildCancelButton, buildConfirmButton } from './utils';
export type ConfirmCallback = OnSubmit<boolean>;
export interface ConfirmOptions
extends Omit<DialogOptions<boolean>, 'title' | 'buttons'> {
title: string;
confirmButton?: ButtonOptions<true>;
cancelButtonLabel?: string;
}
export async function openConfirm({
confirmButton = buildConfirmButton(),
cancelButtonLabel,
...options
}: ConfirmOptions) {
const result = await openDialog({
...options,
buttons: [buildCancelButton(cancelButtonLabel), confirmButton],
});
return !!result;
}
export function confirm(options: ConfirmOptions) {
return openConfirm(options);
}
export function confirmDestructive(options: Omit<ConfirmOptions, 'modalType'>) {
return openConfirm({
...options,
modalType: ModalType.Destructive,
});
}
export function confirmWebEditorDiscard() {
return openConfirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message:
'You currently have unsaved changes in the editor. Are you sure you want to leave?',
confirmButton: buildConfirmButton('Yes', 'danger'),
});
}
export function confirmDelete(message: string) {
return confirmDestructive({
title: 'Are you sure?',
message,
confirmButton: buildConfirmButton('Remove', 'danger'),
});
}
export async function confirmUpdate(
message: string,
callback: ConfirmCallback
) {
const result = await openConfirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
message,
confirmButton: buildConfirmButton('Update'),
});
callback(result);
return result;
}
export function confirmChangePassword() {
return openConfirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message:
'You will be logged out after the password change. Do you want to change your password?',
confirmButton: buildConfirmButton('Change'),
});
}

View file

@ -0,0 +1,4 @@
export { Modal } from './Modal';
export { openModal } from './open-modal';
export { ModalType } from './types';
export { type OnSubmit } from './Modal/types';

View file

@ -0,0 +1,29 @@
import { ComponentType } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import '@reach/dialog/styles.css';
import { OnSubmit } from './Modal/types';
let counter = 0;
export async function openModal<TProps, TResult>(
Modal: ComponentType<{ onSubmit: OnSubmit<TResult> } & TProps>,
props: TProps = {} as TProps
) {
const modal = document.createElement('div');
counter += 1;
modal.id = `dialog-${counter}`;
document.body.appendChild(modal);
const result = await new Promise<TResult | undefined>((resolve) => {
render(
// eslint-disable-next-line react/jsx-props-no-spreading
<Modal {...props} onSubmit={(result) => resolve(result)} />,
modal
);
});
unmountComponentAtNode(modal);
document.body.removeChild(modal);
return result;
}

View file

@ -0,0 +1,20 @@
import { ComponentProps } from 'react';
import { Button } from '@@/buttons';
export interface ButtonOptions<TValue = undefined> {
label: string;
className?: string;
color?: ComponentProps<typeof Button>['color'];
value?: TValue;
}
export interface ButtonsOptions<T> {
confirm: ButtonOptions<T>;
cancel?: ButtonOptions<T>;
}
export enum ModalType {
Warn = 'warning',
Destructive = 'error',
}

View file

@ -0,0 +1,20 @@
import { ComponentProps } from 'react';
import { Button } from '@@/buttons';
import { ButtonOptions } from './types';
export function buildConfirmButton(
label = 'Confirm',
color: ComponentProps<typeof Button>['color'] = 'primary'
): ButtonOptions<true> {
return { label, color, value: true };
}
export function buildCancelButton(label = 'Cancel'): ButtonOptions<false> {
return {
label,
color: 'default',
value: false,
};
}

View file

@ -0,0 +1,62 @@
import { useState } from 'react';
import { Modal, OnSubmit, ModalType, openModal } from '@@/modals';
import { Button } from '@@/buttons';
import { SwitchField } from '@@/form-components/SwitchField';
import { TextTip } from '@@/Tip/TextTip';
interface Props {
onSubmit: OnSubmit<{ pullLatest: boolean }>;
cannotPullImage: boolean;
}
function ConfirmRecreationModal({ onSubmit, cannotPullImage }: Props) {
const [pullLatest, setPullLatest] = useState(false);
return (
<Modal
onDismiss={() => onSubmit()}
aria-label="confirm recreate container modal"
>
<Modal.Header title="Are you sure?" modalType={ModalType.Destructive} />
<Modal.Body>
<p>
You&apos;re about to recreate this container and any non-persisted
data will be lost. This container will be removed and another one will
be created using the same configuration.
</p>
<SwitchField
name="pullLatest"
label="Re-pull image"
checked={pullLatest}
onChange={setPullLatest}
disabled={cannotPullImage}
/>
{cannotPullImage && (
<div className="mt-1 text-sm">
<TextTip color="orange">
Cannot re-pull as the image is inaccessible - either it no longer
exists or the tag or name is no longer correct.
</TextTip>
</div>
)}
</Modal.Body>
<Modal.Footer>
<Button onClick={() => onSubmit()} color="default">
Cancel
</Button>
<Button onClick={() => onSubmit({ pullLatest })} color="danger">
Recreate
</Button>
</Modal.Footer>
</Modal>
);
}
export async function confirmContainerRecreation(cannotPullImage: boolean) {
return openModal(ConfirmRecreationModal, {
cannotPullImage,
});
}

View file

@ -11,7 +11,7 @@ import {
import * as notifications from '@/portainer/services/notifications';
import { useAuthorizations, Authorized } from '@/react/hooks/useUser';
import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt';
import { confirmContainerDeletion } from '@/react/docker/containers/common/confirm-container-delete-modal';
import { setPortainerAgentTargetHeader } from '@/portainer/services/http-request.helper';
import {
ContainerId,
@ -242,7 +242,7 @@ export function ContainersDatatableActions({
);
}
function onRemoveClick(selectedItems: DockerContainer[]) {
async function onRemoveClick(selectedItems: DockerContainer[]) {
const isOneContainerRunning = selectedItems.some(
(container) => container.State === 'running'
);
@ -250,14 +250,13 @@ export function ContainersDatatableActions({
const runningTitle = isOneContainerRunning ? 'running' : '';
const title = `You are about to remove one or more ${runningTitle} containers.`;
confirmContainerDeletion(title, (result: string[]) => {
if (!result) {
return;
}
const cleanVolumes = !!result[0];
const result = await confirmContainerDeletion(title);
if (!result) {
return;
}
const { removeVolumes } = result;
removeSelectedContainers(selectedItems, cleanVolumes);
});
removeSelectedContainers(selectedItems, removeVolumes);
}
async function executeActionOnContainerList(

View file

@ -0,0 +1,16 @@
import { ModalType } from '@@/modals';
import { openSwitchPrompt } from '@@/modals/SwitchPrompt';
import { buildConfirmButton } from '@@/modals/utils';
export async function confirmContainerDeletion(title: string) {
const result = await openSwitchPrompt(
title,
'Automatically remove non-persistent volumes',
{
confirmButton: buildConfirmButton('Remove', 'danger'),
modalType: ModalType.Destructive,
}
);
return result ? { removeVolumes: result.value } : undefined;
}

View file

@ -0,0 +1,58 @@
import { useState } from 'react';
import { Registry } from '@/react/portainer/environments/environment.service/registries';
import { Modal, OnSubmit, openModal } from '@@/modals';
import { Button } from '@@/buttons';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
interface Props {
registries: Registry[];
onSubmit: OnSubmit<Registry['Id']>;
defaultValue: Registry['Id'];
}
function RegistrySelectPrompt({ onSubmit, defaultValue, registries }: Props) {
const title = 'Which registry do you want to use?';
const [registryId, setRegistryId] = useState(defaultValue);
const options = registries2Options(registries);
return (
<Modal onDismiss={() => onSubmit()} aria-label={title}>
<Modal.Header title={title} />
<Modal.Body>
<PortainerSelect
onChange={setRegistryId}
value={registryId}
options={options}
/>
</Modal.Body>
<Modal.Footer>
<Button onClick={() => onSubmit()} color="default">
Cancel
</Button>
<Button onClick={() => onSubmit(registryId)} color="primary">
Update
</Button>
</Modal.Footer>
</Modal>
);
}
export function selectRegistry(
registries: Registry[],
defaultValue: Registry['Id']
) {
return openModal(RegistrySelectPrompt, {
registries,
defaultValue,
});
}
function registries2Options(registries: Registry[]) {
return registries.map((r) => ({
label: r.Name,
value: r.Id,
}));
}

View file

@ -0,0 +1,15 @@
import { ModalType } from '@@/modals';
import { ConfirmCallback, openConfirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
export async function confirmImageExport(callback: ConfirmCallback) {
const result = await openConfirm({
modalType: ModalType.Warn,
title: 'Caution',
message:
'The export may take several minutes, do not navigate away whilst the export is in progress.',
confirmButton: buildConfirmButton('Continue'),
});
callback(result);
}

View file

@ -4,13 +4,13 @@ import { useQueryClient } from 'react-query';
import _ from 'lodash';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { AccessControlPanel } from '@/react/portainer/access-control/AccessControlPanel/AccessControlPanel';
import { ResourceControlType } from '@/react/portainer/access-control/types';
import { DockerContainer } from '@/react/docker/containers/types';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { useContainers } from '@/react/docker/containers/queries/containers';
import { confirmDelete } from '@@/modals/confirm';
import { PageHeader } from '@@/PageHeader';
import { useNetwork, useDeleteNetwork } from '../queries';
@ -103,7 +103,7 @@ export function ItemView() {
async function onRemoveNetworkClicked() {
const message = 'Do you want to delete the network?';
const confirmed = await confirmDeletionAsync(message);
const confirmed = await confirmDelete(message);
if (confirmed) {
deleteNetworkMutation.mutate(

View file

@ -0,0 +1,13 @@
import { openSwitchPrompt } from '@@/modals/SwitchPrompt';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
export async function confirmServiceForceUpdate(message: string) {
const result = await openSwitchPrompt('Are you sure?', 'Re-pull image', {
message,
confirmButton: buildConfirmButton('Update'),
modalType: ModalType.Warn,
});
return result ? { pullLatest: result.value } : undefined;
}

View file

@ -0,0 +1,21 @@
import { openSwitchPrompt } from '@@/modals/SwitchPrompt';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
export async function confirmStackUpdate(
message: string,
defaultValue: boolean
) {
const result = await openSwitchPrompt(
'Are you sure?',
'Re-pull image and redeploy',
{
message,
confirmButton: buildConfirmButton('Update'),
modalType: ModalType.Warn,
defaultValue,
}
);
return result ? { pullImage: result.value } : undefined;
}

View file

@ -0,0 +1,64 @@
import { useState } from 'react';
import { Modal, openModal } from '@@/modals';
import { Button } from '@@/buttons';
import { SwitchField } from '@@/form-components/SwitchField';
function UpdateIngressPrompt({
onSubmit,
title,
hasOneIngress,
hasOnePort,
}: {
onSubmit: (value?: { noMatch: boolean }) => void;
title: string;
hasOneIngress: boolean;
hasOnePort: boolean;
}) {
const [value, setValue] = useState(false);
const rulePlural = !hasOneIngress ? 'rules' : 'rule';
const noMatchSentence = !hasOnePort
? `Service ports in this application no longer match the ingress ${rulePlural}.`
: `A service port in this application no longer matches the ingress ${rulePlural} which may break ingress rule paths.`;
const inputLabel = `Update ingress ${rulePlural} to match the service port changes`;
return (
<Modal onDismiss={() => onSubmit()} aria-label={title}>
<Modal.Header title={title} />
<Modal.Body>
<ul className="ml-3">
<li>Updating the application may cause a service interruption.</li>
<li>{noMatchSentence}</li>
</ul>
<SwitchField
name="noMatch"
label={inputLabel}
checked={value}
onChange={setValue}
/>
</Modal.Body>
<Modal.Footer>
<Button onClick={() => onSubmit({ noMatch: value })} color="primary">
Update
</Button>
</Modal.Footer>
</Modal>
);
}
export function confirmUpdateAppIngress(
ingressesToUpdate: Array<unknown>,
servicePortsToUpdate: Array<unknown>
) {
const hasOneIngress = ingressesToUpdate.length === 1;
const hasOnePort = servicePortsToUpdate.length === 1;
return openModal(UpdateIngressPrompt, {
title: 'Are you sure?',
hasOneIngress,
hasOnePort,
});
}

View file

@ -2,13 +2,14 @@ import { useEffect, useState } from 'react';
import { AlertTriangle, Database } from 'lucide-react';
import { useStore } from 'zustand';
import { confirmWarn } from '@/portainer/services/modal.service/confirm';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { Datatable } from '@@/datatables';
import { Button, ButtonGroup } from '@@/buttons';
import { Icon } from '@@/Icon';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { createPersistedStore } from '@@/datatables/types';
import { buildConfirmButton } from '@@/modals/utils';
import { IngressControllerClassMap } from '../types';
@ -158,7 +159,7 @@ export function IngressClassDatatable({
);
}
function updateIngressControllers(
async function updateIngressControllers(
selectedRows: IngressControllerClassMap[],
ingControllerFormValues: IngressControllerClassMap[],
availability: boolean
@ -194,38 +195,32 @@ export function IngressClassDatatable({
);
if (usedControllersToDisallow.length > 0) {
const usedControllerHtmlListItems = usedControllersToDisallow.map(
(controller) => `<li>${controller.ClassName}</li>`
);
const usedControllerHtmlList = `<ul class="ml-6">${usedControllerHtmlListItems.join(
''
)}</ul>`;
confirmWarn({
const confirmed = await confirm({
title: 'Disallow in-use ingress controllers?',
message: `
modalType: ModalType.Warn,
message: (
<div>
<p>There are ingress controllers you want to disallow that are in use:</p>
${usedControllerHtmlList}
<p>No new ingress rules can be created for the disallowed controllers.</p>
</div>`,
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Disallow',
className: 'btn-warning',
},
},
callback: (confirmed) => {
if (confirmed) {
setIngControllerFormValues(updatedIngressControllers);
onChangeControllers(updatedIngressControllers);
}
},
<p>
There are ingress controllers you want to disallow that are in
use:
</p>
<ul className="ml-6">
{usedControllersToDisallow.map((controller) => (
<li key={controller.ClassName}>${controller.ClassName}</li>
))}
</ul>
<p>
No new ingress rules can be created for the disallowed
controllers.
</p>
</div>
),
confirmButton: buildConfirmButton('Disallow', 'warning'),
});
return;
if (!confirmed) {
return;
}
}
setIngControllerFormValues(updatedIngressControllers);
onChangeControllers(updatedIngressControllers);

View file

@ -5,9 +5,9 @@ import { useStore } from 'zustand';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { useAuthorizations, Authorized } from '@/react/hooks/useUser';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import Route from '@/assets/ico/route.svg?c';
import { confirmDelete } from '@@/modals/confirm';
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
@ -110,7 +110,7 @@ export function IngressDatatable() {
}
async function handleRemoveClick(ingresses: SelectedIngress[]) {
const confirmed = await confirmDeletionAsync(
const confirmed = await confirmDelete(
'Are you sure you want to delete the selected ingresses?'
);
if (!confirmed) {

View file

@ -0,0 +1,17 @@
import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
export function confirmRedeploy() {
return confirm({
title: '',
message: (
<>
One or multiple applications are currently using this volume.
<br /> For the change to be taken into account these applications will
need to be redeployed. Do you want us to reschedule it now?
</>
),
confirmButton: buildConfirmButton('Redeploy the applications'),
cancelButtonLabel: "I'll do it later",
});
}

View file

@ -3,8 +3,8 @@ import { Trash2 } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Job } from '@/react/nomad/types';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { confirmDelete } from '@@/modals/confirm';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { deleteJobs } from './delete';
@ -33,7 +33,7 @@ export function JobActions({ selectedItems, refreshData }: Props) {
);
async function handleDeleteClicked() {
const confirmed = await confirmDeletionAsync(
const confirmed = await confirmDelete(
'Are you sure to delete all selected jobs?'
);

View file

@ -6,3 +6,8 @@ import {
export interface TableSettings
extends BasicTableSettings,
RefreshableTableSettings {}
export enum DeployType {
FDO = 'FDO',
MANUAL = 'MANUAL',
}

View file

@ -2,8 +2,15 @@
padding-left: 0.5rem;
}
.dialog {
display: flex;
justify-content: center;
align-items: center;
.checkbox-list {
max-height: 200px;
overflow-y: auto;
background-color: var(--white-color);
border: 0;
border-radius: 4px;
}
:root[theme='dark'] .checkbox-list,
:root[theme='highcontrast'] .checkbox-list {
background-color: var(--bg-modal-content-color);
}

View file

@ -1,7 +1,5 @@
import { X } from 'lucide-react';
import clsx from 'clsx';
import { useState } from 'react';
import { DialogContent, DialogOverlay } from '@reach/dialog';
import { downloadKubeconfigFile } from '@/react/kubernetes/services/kubeconfig.service';
import * as notifications from '@/portainer/services/notifications';
@ -17,12 +15,12 @@ import {
} from '@/react/portainer/environments/queries/useEnvironmentList';
import { useListSelection } from '@/react/hooks/useListSelection';
import { Modal } from '@@/modals';
import { PaginationControls } from '@@/PaginationControls';
import { Checkbox } from '@@/form-components/Checkbox';
import { Button } from '@@/buttons';
import styles from './KubeconfigPrompt.module.css';
import '@reach/dialog/styles.css';
export interface KubeconfigPromptProps {
envQueryParams: Query;
@ -63,90 +61,69 @@ export function KubeconfigPrompt({
.every((env) => selection.includes(env.Id));
return (
<DialogOverlay
className={styles.dialog}
aria-label="Kubeconfig View"
role="dialog"
onDismiss={onClose}
>
<DialogContent className="modal-dialog bg-transparent p-0">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" onClick={onClose}>
<X />
</button>
<h5 className="modal-title">Download kubeconfig file</h5>
</div>
<div className="modal-body">
<form className="bootbox-form">
<div className="bootbox-prompt-message">
<span>
Select the kubernetes environments to add to the kubeconfig
file. You may select across multiple pages.
</span>
<span className="space-left">{expiryQuery.data}</span>
</div>
</form>
<br />
<div className="flex h-8 items-center">
<Checkbox
id="settings-container-truncate-name"
label="Select all (in this page)"
checked={isAllPageSelected}
onChange={handleSelectAll}
/>
</div>
<div className="datatable">
<div className="bootbox-checkbox-list">
{environments
.filter((env) => env.Status <= 2)
.map((env) => (
<div
key={env.Id}
className={clsx(
styles.checkbox,
'flex h-8 items-center pt-1'
)}
>
<Checkbox
id={`${env.Id}`}
label={`${env.Name} (${env.URL})`}
checked={selection.includes(env.Id)}
onChange={() =>
toggleSelection(env.Id, !selection.includes(env.Id))
}
/>
</div>
))}
</div>
<div className="flex w-full justify-end pt-3">
<PaginationControls
showAll={totalCount <= 100}
page={page}
onPageChange={setPage}
pageLimit={pageLimit}
onPageLimitChange={setPageLimit}
totalCount={totalCount}
<Modal aria-label="Kubeconfig View" onDismiss={onClose}>
<Modal.Header title="Download kubeconfig file" />
<Modal.Body>
<div>
<span>
Select the kubernetes environments to add to the kubeconfig file.
You may select across multiple pages.
</span>
<span className="space-left">{expiryQuery.data}</span>
</div>
<div className="mt-2 flex h-8 items-center">
<Checkbox
id="settings-container-truncate-name"
label="Select all (in this page)"
checked={isAllPageSelected}
onChange={handleSelectAll}
/>
</div>
<div className="datatable">
<div className={styles.checkboxList}>
{environments
.filter((env) => env.Status <= 2)
.map((env) => (
<div
key={env.Id}
className={clsx(
styles.checkbox,
'flex h-8 items-center pt-1'
)}
>
<Checkbox
id={`${env.Id}`}
label={`${env.Name} (${env.URL})`}
checked={!!selection[env.Id]}
onChange={() => toggleSelection(env.Id, !selection[env.Id])}
/>
</div>
</div>
</div>
<div className="modal-footer">
<Button onClick={onClose} color="default">
Cancel
</Button>
<Button
onClick={handleDownload}
disabled={selection.length === 0}
>
Download File
</Button>
</div>
))}
</div>
<div className="flex w-full justify-end pt-3">
<PaginationControls
showAll={totalCount <= 100}
page={page}
onPageChange={setPage}
pageLimit={pageLimit}
onPageLimitChange={setPageLimit}
totalCount={totalCount}
/>
</div>
</div>
</DialogContent>
</DialogOverlay>
</Modal.Body>
<Modal.Footer>
<Button onClick={onClose} color="default">
Cancel
</Button>
<Button onClick={handleDownload} disabled={selection.length === 0}>
Download File
</Button>
</Modal.Footer>
</Modal>
);
function handleSelectAll() {

View file

@ -5,10 +5,10 @@ import { Environment } from '@/react/portainer/environments/types';
import { snapshotEndpoints } from '@/react/portainer/environments/environment.service';
import { isEdgeEnvironment } from '@/react/portainer/environments/utils';
import * as notifications from '@/portainer/services/notifications';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { buildTitle } from '@/portainer/services/modal.service/utils';
import { confirm } from '@@/modals/confirm';
import { PageHeader } from '@@/PageHeader';
import { ModalType } from '@@/modals';
import { EnvironmentList } from './EnvironmentList';
import { EdgeLoadingSpinner } from './EdgeLoadingSpinner';
@ -72,15 +72,10 @@ export function HomeView() {
}
async function confirmEndpointSnapshot() {
return confirmAsync({
title: buildTitle('Are you sure?'),
return confirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
message:
'Triggering a manual refresh will poll each environment to retrieve its information, this may take a few moments.',
buttons: {
confirm: {
label: 'Continue',
className: 'btn-primary',
},
},
});
}

View file

@ -4,12 +4,14 @@ import { useMutation } from 'react-query';
import { object } from 'yup';
import { useUser } from '@/react/hooks/useUser';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { notifySuccess } from '@/portainer/services/notifications';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { confirm } from '@@/modals/confirm';
import { Button } from '@@/buttons';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
import { EditDetails } from '../EditDetails';
import { parseAccessControlFormData } from '../utils';
@ -128,15 +130,11 @@ export function AccessControlPanelForm({
}
function confirmAccessControlUpdate() {
return confirmAsync({
return confirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message:
'Changing the ownership of this resource will potentially restrict its management to some users.',
buttons: {
confirm: {
label: 'Change ownership',
className: 'btn-primary',
},
},
confirmButton: buildConfirmButton('Change ownership'),
});
}

View file

@ -0,0 +1,29 @@
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
export function confirmDisassociate() {
const message = (
<>
<p>
Disassociating this Edge environment will mark it as non associated and
will clear the registered Edge ID.
</p>
<p>
Any agent started with the Edge key associated to this environment will
be able to re-associate with this environment.
</p>
<p>
You can re-use the Edge ID and Edge key that you used to deploy the
existing Edge agent to associate a new Edge device to this environment.
</p>
</>
);
return confirm({
title: 'About disassociating',
modalType: ModalType.Warn,
message,
confirmButton: buildConfirmButton('Disassociate'),
});
}

View file

@ -2,9 +2,9 @@ import { Clock, Trash2 } from 'lucide-react';
import { useStore } from 'zustand';
import { notifySuccess } from '@/portainer/services/notifications';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
import { confirmDelete } from '@@/modals/confirm';
import { Datatable } from '@@/datatables';
import { PageHeader } from '@@/PageHeader';
import { Button } from '@@/buttons';
@ -91,7 +91,7 @@ function TableActions({
);
async function handleRemove() {
const confirmed = await confirmDeletionAsync(
const confirmed = await confirmDelete(
'Are you sure you want to remove these?'
);
if (!confirmed) {

View file

@ -1,7 +1,8 @@
import { confirmDestructive } from '@/portainer/services/modal.service/confirm';
import { Settings } from '@/react/portainer/settings/types';
import { confirmDestructive } from '@@/modals/confirm';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { buildConfirmButton } from '@@/modals/utils';
import { PasswordLengthSlider } from './PasswordLengthSlider/PasswordLengthSlider';
import { SaveAuthSettingsButton } from './SaveAuthSettingsButton';
@ -19,22 +20,18 @@ export function InternalAuth({
value,
onChange,
}: Props) {
function onSubmit() {
async function onSubmit() {
if (value.RequiredPasswordLength < 10) {
confirmDestructive({
const confirmed = await confirmDestructive({
title: 'Allow weak passwords?',
message:
'You have set an insecure minimum password length. This could leave your system vulnerable to attack, are you sure?',
buttons: {
confirm: {
label: 'Yes',
className: 'btn-danger',
},
},
callback: function onConfirm(confirmed) {
if (confirmed) onSaveSettings();
},
confirmButton: buildConfirmButton('Yes', 'danger'),
});
if (confirmed) {
onSaveSettings();
}
} else {
onSaveSettings();
}

View file

@ -1,9 +1,10 @@
import { useField } from 'formik';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { confirm } from '@@/modals/confirm';
import { FormControl } from '@@/form-components/FormControl';
import { Switch } from '@@/form-components/SwitchField/Switch';
import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
export function EnabledWaitingRoomSwitch() {
const [inputProps, meta, helpers] = useField<boolean>('TrustOnFirstConnect');
@ -30,20 +31,12 @@ export function EnabledWaitingRoomSwitch() {
return;
}
const confirmed = await confirmAsync({
const confirmed = await confirm({
modalType: ModalType.Warn,
title: 'Disable Edge Environment Waiting Room',
message:
'By disabling the waiting room feature, all devices requesting association will be automatically associated and could pose a security risk. Are you sure?',
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-danger',
},
},
confirmButton: buildConfirmButton('Confirm', 'danger'),
});
helpers.setValue(!!confirmed);

View file

@ -1,11 +1,12 @@
import { useRouter } from '@uirouter/react';
import { Plus } from 'lucide-react';
import { promptAsync } from '@/portainer/services/modal.service/prompt';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { Button } from '@@/buttons';
import { openModal } from '@@/modals';
import { usePublicSettings } from '../../queries';
import { DeployTypePrompt } from './DeployTypePrompt';
enum DeployType {
FDO = 'FDO',
@ -42,30 +43,15 @@ export function AddDeviceButton() {
}
}
function getDeployType(): Promise<DeployType> {
function getDeployType() {
if (!isFDOEnabled) {
return Promise.resolve(DeployType.MANUAL);
}
return promptAsync({
title: 'How would you like to add an Edge Device?',
inputType: 'radio',
inputOptions: [
{
text: 'Provision bare-metal using Intel FDO',
value: DeployType.FDO,
},
{
text: 'Deploy agent manually',
value: DeployType.MANUAL,
},
],
buttons: {
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
}) as Promise<DeployType>;
return askForDeployType();
}
}
function askForDeployType() {
return openModal(DeployTypePrompt, {});
}

View file

@ -0,0 +1,69 @@
import { useState } from 'react';
import { DeployType } from '@/react/nomad/jobs/JobsView/JobsDatatable/types';
import { OnSubmit } from '@@/modals';
import { Dialog } from '@@/modals/Dialog';
import { buildCancelButton, buildConfirmButton } from '@@/modals/utils';
export function DeployTypePrompt({
onSubmit,
}: {
onSubmit: OnSubmit<DeployType>;
}) {
const [deployType, setDeployType] = useState<DeployType>(DeployType.FDO);
return (
<Dialog
title="How would you like to add an Edge Device?"
message={
<>
<RadioInput
name="deployType"
value={DeployType.FDO}
label="Provision bare-metal using Intel FDO"
groupValue={deployType}
onChange={setDeployType}
/>
<RadioInput
name="deployType"
value={DeployType.MANUAL}
onChange={setDeployType}
groupValue={deployType}
label="Deploy agent manually"
/>
</>
}
buttons={[buildCancelButton(), buildConfirmButton()]}
onSubmit={(confirm) => onSubmit(confirm ? deployType : undefined)}
/>
);
}
function RadioInput<T extends number | string>({
value,
onChange,
label,
groupValue,
name,
}: {
value: T;
onChange: (value: T) => void;
label: string;
groupValue: T;
name: string;
}) {
return (
<label className="flex items-center gap-2">
<input
className="!m-0"
type="radio"
name={name}
value={value}
checked={groupValue === value}
onChange={() => onChange(value)}
/>
{label}
</label>
);
}

View file

@ -0,0 +1 @@
export { AddDeviceButton } from './AddDeviceButton';

View file

@ -3,18 +3,16 @@ import { useRouter } from '@uirouter/react';
import { PlusCircle, Trash2 } from 'lucide-react';
import { Profile } from '@/portainer/hostmanagement/fdo/model';
import {
confirmAsync,
confirmDestructiveAsync,
} from '@/portainer/services/modal.service/confirm';
import * as notifications from '@/portainer/services/notifications';
import {
deleteProfile,
duplicateProfile,
} from '@/portainer/hostmanagement/fdo/fdo.service';
import { confirm, confirmDestructive } from '@@/modals/confirm';
import { Link } from '@@/Link';
import { Button } from '@@/buttons';
import { buildConfirmButton } from '@@/modals/utils';
interface Props {
isFDOEnabled: boolean;
@ -56,15 +54,9 @@ export function FDOProfilesDatatableActions({
);
async function onDuplicateProfileClick() {
const confirmed = await confirmAsync({
const confirmed = await confirm({
title: 'Are you sure ?',
message: 'This action will duplicate the selected profile. Continue?',
buttons: {
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
if (!confirmed) {
@ -88,15 +80,10 @@ export function FDOProfilesDatatableActions({
}
async function onDeleteProfileClick() {
const confirmed = await confirmDestructiveAsync({
title: 'Are you sure ?',
const confirmed = await confirmDestructive({
title: 'Are you sure?',
message: 'This action will delete the selected profile(s). Continue?',
buttons: {
confirm: {
label: 'Remove',
className: 'btn-danger',
},
},
confirmButton: buildConfirmButton('Remove', 'danger'),
});
if (!confirmed) {

View file

@ -2,7 +2,6 @@ import { useRouter } from '@uirouter/react';
import { useMutation, useQueryClient } from 'react-query';
import { Trash2, Users } from 'lucide-react';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import {
mutationOptions,
@ -10,6 +9,7 @@ import {
withInvalidate,
} from '@/react-tools/react-query';
import { confirmDelete } from '@@/modals/confirm';
import { Button } from '@@/buttons';
import { Widget } from '@@/Widget';
@ -75,7 +75,7 @@ export function Details({ team, memberships, isAdmin }: Props) {
);
async function handleDeleteClick() {
const confirmed = await confirmDeletionAsync(
const confirmed = await confirmDelete(
`Do you want to delete this team? Users in this team will not be deleted.`
);
if (!confirmed) {

View file

@ -7,8 +7,8 @@ import { notifySuccess } from '@/portainer/services/notifications';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import { Team, TeamId } from '@/react/portainer/users/teams/types';
import { deleteTeam } from '@/react/portainer/users/teams/teams.service';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { confirmDelete } from '@@/modals/confirm';
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { buildNameColumn } from '@@/datatables/NameCell';
@ -86,7 +86,7 @@ function useRemoveMutation() {
return { handleRemove };
async function handleRemove(teams: TeamId[]) {
const confirmed = await confirmDeletionAsync(
const confirmed = await confirmDelete(
'Are you sure you want to remove the selected teams?'
);

View file

@ -1,10 +1,10 @@
import { useState } from 'react';
import { Database, Hash, Server, Tag, Wrench } from 'lucide-react';
import { DialogOverlay } from '@reach/dialog';
import { useSystemStatus } from '@/react/portainer/system/useSystemStatus';
import { useSystemVersion } from '@/react/portainer/system/useSystemVersion';
import { Modal } from '@@/modals';
import { Button } from '@@/buttons';
import styles from './Footer.module.css';
@ -48,77 +48,66 @@ function BuildInfoModal({ closeModal }: { closeModal: () => void }) {
const { ServerVersion, DatabaseVersion, Build } = versionQuery.data;
return (
<DialogOverlay className={styles.dialog} isOpen>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" onClick={closeModal}>
×
</button>
<h5 className="modal-title">Portainer {Edition}</h5>
</div>
<div className="modal-body">
<div className={styles.versionInfo}>
<table>
<tbody>
<tr>
<td>
<span className="inline-flex items-center">
<Server size="13" className="space-right" />
Server Version: {ServerVersion}
</span>
</td>
<td>
<span className="inline-flex items-center">
<Database size="13" className="space-right" />
Database Version: {DatabaseVersion}
</span>
</td>
</tr>
<tr>
<td>
<span className="inline-flex items-center">
<Hash size="13" className="space-right" />
CI Build Number: {Build.BuildNumber}
</span>
</td>
<td>
<span>
<Tag size="13" className="space-right" />
Image Tag: {Build.ImageTag}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div className={styles.toolsList}>
<span className="inline-flex items-center">
<Wrench size="13" className="space-right" />
Compilation tools:
</span>
<Modal onDismiss={closeModal} aria-label="build-info-modal">
<Modal.Header title={`Portainer ${Edition}`} />
<Modal.Body>
<div className={styles.versionInfo}>
<table>
<tbody>
<tr>
<td>
<span className="inline-flex items-center">
<Server size="13" className="space-right" />
Server Version: {ServerVersion}
</span>
</td>
<td>
<span className="inline-flex items-center">
<Database size="13" className="space-right" />
Database Version: {DatabaseVersion}
</span>
</td>
</tr>
<tr>
<td>
<span className="inline-flex items-center">
<Hash size="13" className="space-right" />
CI Build Number: {Build.BuildNumber}
</span>
</td>
<td>
<span>
<Tag size="13" className="space-right" />
Image Tag: {Build.ImageTag}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div className={styles.toolsList}>
<span className="inline-flex items-center">
<Wrench size="13" className="space-right" />
Compilation tools:
</span>
<div className={styles.tools}>
<span className="text-muted small">
Nodejs v{Build.NodejsVersion}
</span>
<span className="text-muted small">
Yarn v{Build.YarnVersion}
</span>
<span className="text-muted small">
Webpack v{Build.WebpackVersion}
</span>
<span className="text-muted small">Go v{Build.GoVersion}</span>
</div>
</div>
</div>
<div className="modal-footer">
<Button className="bootbox-accept" onClick={closeModal}>
Ok
</Button>
<div className={styles.tools}>
<span className="text-muted small">
Nodejs v{Build.NodejsVersion}
</span>
<span className="text-muted small">Yarn v{Build.YarnVersion}</span>
<span className="text-muted small">
Webpack v{Build.WebpackVersion}
</span>
<span className="text-muted small">Go v{Build.GoVersion}</span>
</div>
</div>
</div>
</DialogOverlay>
</Modal.Body>
<Modal.Footer>
<Button className="w-full" onClick={closeModal}>
Ok
</Button>
</Modal.Footer>
</Modal>
);
}