0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

fix(console): should not display unsaved alert on item deleted (#1507)

This commit is contained in:
Xiao Yijun 2022-07-12 17:11:58 +08:00 committed by GitHub
parent f387652bfd
commit 459af3823c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 231 additions and 306 deletions

View file

@ -4,6 +4,6 @@
font: var(--font-body-medium);
> :not(:first-child) {
margin: _.unit(6) 0;
margin: _.unit(6) 0 0;
}
}

View file

@ -17,6 +17,8 @@ export type ConfirmModalProps = {
confirmButtonText?: AdminConsoleKey;
cancelButtonText?: AdminConsoleKey;
isOpen: boolean;
isConfirmButtonDisabled?: boolean;
isLoading?: boolean;
onCancel: () => void;
onConfirm: () => void;
};
@ -29,6 +31,8 @@ const ConfirmModal = ({
confirmButtonText = 'general.confirm',
cancelButtonText = 'general.cancel',
isOpen,
isConfirmButtonDisabled = false,
isLoading = false,
onCancel,
onConfirm,
}: ConfirmModalProps) => {
@ -43,7 +47,13 @@ const ConfirmModal = ({
footer={
<>
<Button type="outline" title={cancelButtonText} onClick={onCancel} />
<Button type={confirmButtonType} title={confirmButtonText} onClick={onConfirm} />
<Button
type={confirmButtonType}
title={confirmButtonText}
disabled={isConfirmButtonDisabled}
isLoading={isLoading}
onClick={onConfirm}
/>
</>
}
className={classNames(styles.content, className)}

View file

@ -0,0 +1,55 @@
import React, { ReactNode, useState } from 'react';
import ConfirmModal from '@/components/ConfirmModal';
import TextInput from '@/components/TextInput';
type Props = {
isOpen: boolean;
isLoading?: boolean;
children: ReactNode;
expectedInput?: string;
inputPlaceholder?: string;
className?: string;
onCancel: () => void;
onConfirm: () => void;
};
const DeleteConfirmModal = ({
isOpen,
isLoading = false,
expectedInput,
inputPlaceholder,
children,
className,
onCancel,
onConfirm,
}: Props) => {
const [input, setInput] = useState('');
const isConfirmBlocked = Boolean(expectedInput) && input !== expectedInput;
return (
<ConfirmModal
isOpen={isOpen}
isLoading={isLoading}
isConfirmButtonDisabled={isConfirmBlocked}
confirmButtonText="general.delete"
className={className}
onCancel={onCancel}
onConfirm={onConfirm}
>
{children}
{expectedInput && (
<TextInput
autoFocus
value={input}
placeholder={inputPlaceholder}
onChange={(event) => {
setInput(event.currentTarget.value);
}}
/>
)}
</ConfirmModal>
);
};
export default DeleteConfirmModal;

View file

@ -1,5 +1,5 @@
import type { Blocker, Transition } from 'history';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import React, { useCallback, useContext, useLayoutEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UNSAFE_NavigationContext, Navigator } from 'react-router-dom';
@ -22,7 +22,7 @@ const UnsavedChangesAlertModal = ({ hasUnsavedChanges }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
useEffect(() => {
useLayoutEffect(() => {
if (!hasUnsavedChanges) {
return;
}

View file

@ -1,15 +0,0 @@
@use '@/scss/underscore' as _;
.content {
> :not(:first-child) {
margin-top: _.unit(6);
}
.description {
font: var(--font-body-medium);
}
.hightlight {
color: var(--color-primary-50);
}
}

View file

@ -1,81 +0,0 @@
import React, { useState } from 'react';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/components/Button';
import ModalLayout from '@/components/ModalLayout';
import TextInput from '@/components/TextInput';
import useApi from '@/hooks/use-api';
import * as styles from './index.module.scss';
type Props = {
id: string;
name: string;
onClose: () => void;
};
const DeleteForm = ({ id, name, onClose }: Props) => {
const api = useApi();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const [inputName, setInputName] = useState('');
const [loading, setLoading] = useState(false);
const inputMismatched = inputName !== name;
const handleDelete = async () => {
setLoading(true);
try {
await api.delete(`/api/resources/${id}`);
onClose();
navigate(`/api-resources`);
toast.success(t('api_resource_details.api_resource_deleted', { name }));
} finally {
setLoading(false);
}
};
return (
<ModalLayout
title="general.reminder"
footer={
<>
<Button type="outline" title="general.cancel" onClick={onClose} />
<Button
disabled={inputMismatched}
isLoading={loading}
type="danger"
title="general.delete"
onClick={handleDelete}
/>
</>
}
className={styles.content}
onClose={onClose}
>
<div className={styles.description}>
<Trans
t={t}
i18nKey="api_resource_details.delete_description"
values={{ name }}
components={{ span: <span className={styles.hightlight} /> }}
/>
</div>
<TextInput
autoFocus
value={inputName}
placeholder={t('api_resource_details.enter_your_api_resource_name')}
onChange={(event) => {
setInputName(event.currentTarget.value);
}}
/>
</ModalLayout>
);
};
export default DeleteForm;

View file

@ -4,6 +4,20 @@
margin: _.unit(1) 0 0 _.unit(1);
}
.deleteConfirm {
> :not(:first-child) {
margin-top: _.unit(6);
}
.description {
font: var(--font-body-medium);
}
.highlight {
color: var(--color-primary-50);
}
}
.header {
display: flex;
justify-content: space-between;

View file

@ -4,9 +4,8 @@ import classNames from 'classnames';
import React, { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import useSWR from 'swr';
import ApiResourceDark from '@/assets/images/api-resource-dark.svg';
@ -15,6 +14,7 @@ import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
import Button from '@/components/Button';
import Card from '@/components/Card';
import CopyToClipboard from '@/components/CopyToClipboard';
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
import DetailsSkeleton from '@/components/DetailsSkeleton';
import FormField from '@/components/FormField';
import LinkButton from '@/components/LinkButton';
@ -27,9 +27,7 @@ import Back from '@/icons/Back';
import Delete from '@/icons/Delete';
import More from '@/icons/More';
import * as detailsStyles from '@/scss/details.module.scss';
import * as modalStyles from '@/scss/modal.module.scss';
import DeleteForm from './components/DeleteForm';
import * as styles from './index.module.scss';
type FormData = {
@ -41,7 +39,7 @@ const ApiResourceDetails = () => {
const location = useLocation();
const { id } = useParams();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const { data, error, mutate } = useSWR<Resource, RequestError>(id && `/api/resources/${id}`);
const isLoading = !data && !error;
const theme = useTheme();
@ -49,6 +47,9 @@ const ApiResourceDetails = () => {
const isLogtoManagementApiResource = data?.id === managementResource.id;
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleted, setIsDeleted] = useState(false);
const {
handleSubmit,
register,
@ -81,6 +82,25 @@ const ApiResourceDetails = () => {
toast.success(t('general.saved'));
});
const onDelete = async () => {
if (!data || isDeleting) {
return;
}
setIsDeleting(true);
try {
await api.delete(`/api/resources/${data.id}`);
setIsDeleted(true);
setIsDeleting(false);
setIsDeleteFormOpen(false);
toast.success(t('api_resource_details.api_resource_deleted', { name: data.name }));
navigate(`/api-resources`);
} catch {
setIsDeleting(false);
}
};
return (
<div className={detailsStyles.container}>
<LinkButton
@ -117,19 +137,23 @@ const ApiResourceDetails = () => {
{t('general.delete')}
</ActionMenuItem>
</ActionMenu>
<Modal
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
isLoading={isDeleting}
expectedInput={data.name}
className={styles.deleteConfirm}
inputPlaceholder={t('api_resource_details.enter_your_api_resource_name')}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<DeleteForm
id={data.id}
name={data.name}
onClose={() => {
setIsDeleteFormOpen(false);
}}
/>
</Modal>
<div className={styles.description}>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('api_resource_details.delete_description', { name: data.name })}
</Trans>
</div>
</DeleteConfirmModal>
</div>
)}
</Card>
@ -176,7 +200,7 @@ const ApiResourceDetails = () => {
</Card>
</>
)}
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
</div>
);
};

View file

@ -11,9 +11,10 @@ import * as styles from '../index.module.scss';
type Props = {
oidcConfig: SnakeCaseOidcConfig;
defaultData: Application;
isDeleted: boolean;
};
const AdvancedSettings = ({ oidcConfig, defaultData }: Props) => {
const AdvancedSettings = ({ oidcConfig, defaultData, isDeleted }: Props) => {
const {
reset,
formState: { isDirty },
@ -36,7 +37,7 @@ const AdvancedSettings = ({ oidcConfig, defaultData }: Props) => {
variant="border"
/>
</FormField>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
</>
);
};

View file

@ -1,15 +0,0 @@
@use '@/scss/underscore' as _;
.content {
> :not(:first-child) {
margin-top: _.unit(6);
}
.description {
font: var(--font-body-medium);
}
.highlight {
color: var(--color-primary-50);
}
}

View file

@ -1,81 +0,0 @@
import React, { useState } from 'react';
import { toast } from 'react-hot-toast';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/components/Button';
import ModalLayout from '@/components/ModalLayout';
import TextInput from '@/components/TextInput';
import useApi from '@/hooks/use-api';
import * as styles from './index.module.scss';
type Props = {
id: string;
name: string;
onClose: () => void;
};
const DeleteForm = ({ id, name, onClose }: Props) => {
const api = useApi();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const [inputName, setInputName] = useState('');
const [loading, setLoading] = useState(false);
const inputMismatched = inputName !== name;
const handleDelete = async () => {
setLoading(true);
try {
await api.delete(`/api/applications/${id}`);
onClose();
navigate(`/applications`);
toast.success(t('application_details.application_deleted', { name }));
} finally {
setLoading(false);
}
};
return (
<ModalLayout
title="general.reminder"
footer={
<>
<Button type="outline" title="general.cancel" onClick={onClose} />
<Button
disabled={inputMismatched}
isLoading={loading}
type="danger"
title="general.delete"
onClick={handleDelete}
/>
</>
}
className={styles.content}
onClose={onClose}
>
<div className={styles.description}>
<Trans
t={t}
i18nKey="application_details.delete_description"
values={{ name }}
components={{ span: <span className={styles.highlight} /> }}
/>
</div>
<TextInput
autoFocus
value={inputName}
placeholder={t('application_details.enter_your_application_name')}
onChange={(event) => {
setInputName(event.currentTarget.value);
}}
/>
</ModalLayout>
);
};
export default DeleteForm;

View file

@ -18,9 +18,10 @@ type Props = {
applicationType: ApplicationType;
oidcConfig: SnakeCaseOidcConfig;
defaultData: Application;
isDeleted: boolean;
};
const Settings = ({ applicationType, oidcConfig, defaultData }: Props) => {
const Settings = ({ applicationType, oidcConfig, defaultData, isDeleted }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
control,
@ -156,7 +157,7 @@ const Settings = ({ applicationType, oidcConfig, defaultData }: Props) => {
)}
/>
</FormField>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
</>
);
};

View file

@ -4,6 +4,20 @@
margin: _.unit(1) 0 0 _.unit(1);
}
.deleteConfirm {
> :not(:first-child) {
margin-top: _.unit(6);
}
.description {
font: var(--font-body-medium);
}
.highlight {
color: var(--color-primary-50);
}
}
.body {
> :first-child {
margin-top: 0;

View file

@ -3,9 +3,8 @@ import classNames from 'classnames';
import React, { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import Modal from 'react-modal';
import { useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import useSWR from 'swr';
import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
@ -13,6 +12,7 @@ import ApplicationIcon from '@/components/ApplicationIcon';
import Button from '@/components/Button';
import Card from '@/components/Card';
import CopyToClipboard from '@/components/CopyToClipboard';
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
import DetailsSkeleton from '@/components/DetailsSkeleton';
import Drawer from '@/components/Drawer';
import LinkButton from '@/components/LinkButton';
@ -22,12 +22,10 @@ import Back from '@/icons/Back';
import Delete from '@/icons/Delete';
import More from '@/icons/More';
import * as detailsStyles from '@/scss/details.module.scss';
import * as modalStyles from '@/scss/modal.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
import Guide from '../Applications/components/Guide';
import AdvancedSettings from './components/AdvancedSettings';
import DeleteForm from './components/DeleteForm';
import Settings from './components/Settings';
import * as styles from './index.module.scss';
@ -51,7 +49,10 @@ const ApplicationDetails = () => {
const isLoading = (!data && !error) || (!oidcConfig && !fetchOidcConfigError);
const [isReadmeOpen, setIsReadmeOpen] = useState(false);
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleted, setIsDeleted] = useState(false);
const api = useApi();
const navigate = useNavigate();
const formMethods = useForm<Application>();
const {
@ -97,6 +98,23 @@ const ApplicationDetails = () => {
toast.success(t('general.saved'));
});
const onDelete = async () => {
if (!data || isDeleting) {
return;
}
try {
await api.delete(`/api/applications/${data.id}`);
setIsDeleted(true);
setIsDeleting(false);
setIsDeleteFormOpen(false);
toast.success(t('application_details.application_deleted', { name: data.name }));
navigate(`/applications`);
} catch {
setIsDeleting(false);
}
};
const onCloseDrawer = () => {
setIsReadmeOpen(false);
};
@ -150,19 +168,23 @@ const ApplicationDetails = () => {
{t('general.delete')}
</ActionMenuItem>
</ActionMenu>
<Modal
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
isLoading={isDeleting}
expectedInput={data.name}
inputPlaceholder={t('application_details.enter_your_application_name')}
className={styles.deleteConfirm}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<DeleteForm
id={data.id}
name={data.name}
onClose={() => {
setIsDeleteFormOpen(false);
}}
/>
</Modal>
<div className={styles.description}>
<Trans components={{ span: <span className={styles.highlight} /> }}>
{t('application_details.delete_description', { name: data.name })}
</Trans>
</div>
</DeleteConfirmModal>
</div>
</Card>
<Card className={classNames(styles.body, detailsStyles.body)}>
@ -178,13 +200,18 @@ const ApplicationDetails = () => {
<form className={classNames(styles.form, detailsStyles.body)} onSubmit={onSubmit}>
<div className={styles.fields}>
{isAdvancedSettings && (
<AdvancedSettings oidcConfig={oidcConfig} defaultData={data} />
<AdvancedSettings
oidcConfig={oidcConfig}
defaultData={data}
isDeleted={isDeleted}
/>
)}
{!isAdvancedSettings && (
<Settings
applicationType={data.type}
oidcConfig={oidcConfig}
defaultData={data}
isDeleted={isDeleted}
/>
)}
</div>

View file

@ -1,52 +0,0 @@
import React, { useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Button from '@/components/Button';
import ModalLayout from '@/components/ModalLayout';
import useApi from '@/hooks/use-api';
type Props = {
id: string;
onClose: () => void;
};
const DeleteForm = ({ id, onClose }: Props) => {
const api = useApi();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
setLoading(true);
try {
await api.delete(`/api/users/${id}`);
onClose();
navigate('/users');
toast.success(t('user_details.deleted', { name }));
} finally {
setLoading(false);
}
};
return (
<ModalLayout
title="general.reminder"
footer={
<>
<Button type="outline" title="general.cancel" onClick={onClose} />
<Button disabled={loading} type="danger" title="general.delete" onClick={handleDelete} />
</>
}
onClose={onClose}
>
<div>{t('user_details.delete_description')}</div>
</ModalLayout>
);
};
export default DeleteForm;

View file

@ -5,8 +5,8 @@ import React, { useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ConfirmModal from '@/components/ConfirmModal';
import CopyToClipboard from '@/components/CopyToClipboard';
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
import TableError from '@/components/Table/TableError';
import UnnamedTrans from '@/components/UnnamedTrans';
import useApi from '@/hooks/use-api';
@ -142,9 +142,8 @@ const UserConnectors = ({ userId, connectors, onDelete }: Props) => {
</tbody>
</table>
)}
<ConfirmModal
<DeleteConfirmModal
isOpen={deletingConnector !== undefined}
confirmButtonText="general.delete"
onCancel={() => {
setDeletingConnector(undefined);
}}
@ -162,7 +161,7 @@ const UserConnectors = ({ userId, connectors, onDelete }: Props) => {
components={{ name: <UnnamedTrans resource={deletingConnector.name} /> }}
/>
)}
</ConfirmModal>
</DeleteConfirmModal>
</div>
);
};

View file

@ -32,9 +32,10 @@ type Props = {
userData: User;
userFormData: FormData;
onUserUpdated: (user?: User) => void;
isDeleted: boolean;
};
const UserSettings = ({ userData, userFormData, onUserUpdated }: Props) => {
const UserSettings = ({ userData, userFormData, isDeleted, onUserUpdated }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
@ -145,7 +146,7 @@ const UserSettings = ({ userData, userFormData, onUserUpdated }: Props) => {
/>
</div>
</div>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
</form>
);
};

View file

@ -1,19 +1,21 @@
import { User } from '@logto/schemas';
import classNames from 'classnames';
import React, { useMemo, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import ReactModal from 'react-modal';
import { useLocation, useParams, useSearchParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import useSWR from 'swr';
import ActionMenu, { ActionMenuItem } from '@/components/ActionMenu';
import Card from '@/components/Card';
import CopyToClipboard from '@/components/CopyToClipboard';
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
import DetailsSkeleton from '@/components/DetailsSkeleton';
import LinkButton from '@/components/LinkButton';
import TabNav, { TabNavItem } from '@/components/TabNav';
import { generateAvatarPlaceHolderById } from '@/consts/avatars';
import { RequestError } from '@/hooks/use-api';
import useApi, { RequestError } from '@/hooks/use-api';
import Back from '@/icons/Back';
import Delete from '@/icons/Delete';
import More from '@/icons/More';
@ -22,7 +24,6 @@ import * as detailsStyles from '@/scss/details.module.scss';
import * as modalStyles from '@/scss/modal.module.scss';
import CreateSuccess from './components/CreateSuccess';
import DeleteForm from './components/DeleteForm';
import ResetPasswordForm from './components/ResetPasswordForm';
import UserLogs from './components/UserLogs';
import UserSettings from './components/UserSettings';
@ -37,11 +38,15 @@ const UserDetails = () => {
const password = passwordEncoded && atob(passwordEncoded);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isDeleteFormOpen, setIsDeleteFormOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleted, setIsDeleted] = useState(false);
const [isResetPasswordFormOpen, setIsResetPasswordFormOpen] = useState(false);
const [resetResult, setResetResult] = useState<string>();
const { data, error, mutate } = useSWR<User, RequestError>(userId && `/api/users/${userId}`);
const isLoading = !data && !error;
const api = useApi();
const navigate = useNavigate();
const userFormData = useMemo(() => {
if (!data) {
@ -54,6 +59,25 @@ const UserDetails = () => {
};
}, [data]);
const onDelete = async () => {
if (!data || isDeleting) {
return;
}
setIsDeleting(true);
try {
await api.delete(`/api/users/${data.id}`);
setIsDeleted(true);
setIsDeleting(false);
setIsDeleteFormOpen(false);
toast.success(t('user_details.deleted', { name: data.name }));
navigate('/users');
} catch {
setIsDeleting(false);
}
};
return (
<div className={detailsStyles.container}>
<LinkButton
@ -124,18 +148,16 @@ const UserDetails = () => {
}}
/>
</ReactModal>
<ReactModal
<DeleteConfirmModal
isOpen={isDeleteFormOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
isLoading={isDeleting}
onCancel={() => {
setIsDeleteFormOpen(false);
}}
onConfirm={onDelete}
>
<DeleteForm
id={data.id}
onClose={() => {
setIsDeleteFormOpen(false);
}}
/>
</ReactModal>
<div>{t('user_details.delete_description')}</div>
</DeleteConfirmModal>
</div>
</Card>
<Card className={classNames(styles.body, detailsStyles.body)}>
@ -148,6 +170,7 @@ const UserDetails = () => {
<UserSettings
userData={data}
userFormData={userFormData}
isDeleted={isDeleted}
onUserUpdated={(user) => {
void mutate(user);
}}