ソースを参照

SONAR-20366 Migrate quality profile create modal to MIUI

tags/10.3.0.82913
7PH 8ヶ月前
コミット
32ac811eb5

+ 89
- 0
server/sonar-web/design-system/src/components/input/FileInput.tsx ファイルの表示

@@ -0,0 +1,89 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import { useCallback, useRef, useState } from 'react';
import { Note } from '../Text';
import { ButtonSecondary } from '../buttons/ButtonSecondary';

interface Props {
chooseLabel: string;
className?: string;
clearLabel: string;
id?: string;
name?: string;
noFileLabel: string;
onFileSelected?: (file?: File) => void;
required?: boolean;
}

export function FileInput(props: Readonly<Props>) {
const { className, id, name, onFileSelected, required } = props;
const { chooseLabel, clearLabel, noFileLabel } = props;

const [selectedFileName, setSelectedFileName] = useState<string | undefined>(undefined);
const fileInputRef = useRef<HTMLInputElement>(null);

const handleFileInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
onFileSelected?.(file);
setSelectedFileName(file?.name);
},
[onFileSelected],
);

const handleFileInputReset = useCallback(() => {
if (fileInputRef.current) {
onFileSelected?.(undefined);
fileInputRef.current.value = '';
setSelectedFileName(undefined);
}
}, [fileInputRef, onFileSelected]);

const handleFileInputClick = useCallback(() => {
fileInputRef.current?.click();
}, [fileInputRef]);

return (
<div className={classNames('sw-flex sw-items-center sw-gap-2', className)}>
{selectedFileName ? (
<>
<ButtonSecondary onClick={handleFileInputReset}>{clearLabel}</ButtonSecondary>
<Note>{selectedFileName}</Note>
</>
) : (
<>
<ButtonSecondary onClick={handleFileInputClick}>{chooseLabel}</ButtonSecondary>
<Note>{noFileLabel}</Note>
</>
)}
<input
data-testid="file-input"
hidden
id={id}
name={name}
onChange={handleFileInputChange}
ref={fileInputRef}
required={required}
type="file"
/>
</div>
);
}

+ 50
- 0
server/sonar-web/design-system/src/components/input/__tests__/FileInput-test.tsx ファイルの表示

@@ -0,0 +1,50 @@
/*
* SonarQube
* Copyright (C) 2009-2023 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { fireEvent, screen } from '@testing-library/react';
import { render } from '../../../helpers/testUtils';
import { FCProps } from '../../../types/misc';
import { FileInput } from '../FileInput';

it('should correclty choose a file and reset it', async () => {
const file = new File([''], 'file.txt', { type: 'text/plain' });
const onFileSelected = jest.fn();
const { user } = setupWithProps({ onFileSelected });

expect(screen.getByRole('button')).toHaveTextContent('Choose');
expect(screen.getByText('No file selected')).toBeVisible();

await user.click(screen.getByRole('button'));
fireEvent.change(screen.getByTestId('file-input'), {
target: { files: [file] },
});
expect(onFileSelected).toHaveBeenCalledWith(file);
expect(screen.getByText('file.txt')).toBeVisible();
expect(screen.getByRole('button')).toHaveTextContent('Clear');

await user.click(screen.getByRole('button'));
expect(screen.getByText('No file selected')).toBeVisible();
expect(onFileSelected).toHaveBeenCalledWith(undefined);
});

function setupWithProps(props: Partial<FCProps<typeof FileInput>> = {}) {
return render(
<FileInput chooseLabel="Choose" clearLabel="Clear" noFileLabel="No file selected" {...props} />,
);
}

+ 1
- 0
server/sonar-web/design-system/src/components/input/index.ts ファイルの表示

@@ -21,6 +21,7 @@ export * from './Checkbox';
export * from './DatePicker';
export * from './DateRangePicker';
export * from './DiscreetSelect';
export * from './FileInput';
export * from './FormField';
export * from './InputField';
export * from './InputMultiSelect';

+ 12
- 1
server/sonar-web/src/main/js/api/mocks/QualityProfilesServiceMock.ts ファイルの表示

@@ -305,7 +305,18 @@ export default class QualityProfilesServiceMock {
}

handleGetImporters = () => {
return this.reply([]);
return this.reply([
{
key: 'sonar-importer-a',
name: 'Importer A',
languages: ['c'],
},
{
key: 'sonar-importer-b',
name: 'Importer B',
languages: ['c'],
},
]);
};

handleCopyProfile = (fromKey: string, name: string): Promise<Profile> => {

+ 30
- 5
server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfilesApp-it.tsx ファイルの表示

@@ -23,7 +23,7 @@ import selectEvent from 'react-select-event';
import QualityProfilesServiceMock from '../../../api/mocks/QualityProfilesServiceMock';
import { mockPaging, mockRule } from '../../../helpers/testMocks';
import { renderAppRoutes } from '../../../helpers/testReactTestingUtils';
import { byRole } from '../../../helpers/testSelector';
import { byRole, byText } from '../../../helpers/testSelector';
import routes from '../routes';

jest.mock('../../../api/quality-profiles');
@@ -93,14 +93,16 @@ const ui = {
}),
listLinkJavaQualityProfile: byRole('link', { name: 'java quality profile' }),
returnToList: byRole('link', { name: 'quality_profiles.page' }),
languageSelect: byRole('combobox', { name: 'language field_required' }),
languageSelect: byRole('combobox', { name: 'language' }),
profileExtendSelect: byRole('combobox', {
name: 'quality_profiles.creation.choose_parent_quality_profile field_required',
name: 'quality_profiles.creation.choose_parent_quality_profile',
}),
profileCopySelect: byRole('combobox', {
name: 'quality_profiles.creation.choose_copy_quality_profile field_required',
name: 'quality_profiles.creation.choose_copy_quality_profile',
}),
nameCreatePopupInput: byRole('textbox', { name: 'name field_required' }),
nameCreatePopupInput: byRole('textbox', { name: 'name required' }),
importerA: byText('Importer A'),
importerB: byText('Importer B'),
comparisonDiffTableHeading: (rulesQuantity: number, profileName: string) =>
byRole('columnheader', {
name: `quality_profiles.x_rules_only_in.${rulesQuantity}.${profileName}`,
@@ -253,6 +255,29 @@ describe('Create', () => {

expect(await ui.headingNewCQualityProfile.find()).toBeInTheDocument();
});

it('should render importers', async () => {
const user = userEvent.setup();
serviceMock.setAdmin();
renderQualityProfiles();

await act(async () => {
await user.click(await ui.createButton.find());
});
await user.click(ui.blankRadio.get());
await selectEvent.select(ui.languageSelect.get(), 'C');

expect(ui.importerA.get()).toBeInTheDocument();
expect(ui.importerB.get()).toBeInTheDocument();

await user.click(ui.copyRadio.get());
expect(ui.importerA.query()).not.toBeInTheDocument();
expect(ui.importerB.query()).not.toBeInTheDocument();

await user.click(ui.extendRadio.get());
expect(ui.importerA.query()).not.toBeInTheDocument();
expect(ui.importerB.query()).not.toBeInTheDocument();
});
});

it('should be able to restore a quality profile', async () => {

+ 198
- 206
server/sonar-web/src/main/js/apps/quality-profiles/home/CreateProfileForm.tsx ファイルの表示

@@ -17,27 +17,34 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { FlagMessage } from 'design-system';
import {
ButtonPrimary,
FileInput,
FlagMessage,
FormField,
InputField,
LabelValueSelectOption,
LightLabel,
Modal,
Note,
PopupZLevel,
SearchSelectDropdown,
SelectionCard,
Spinner,
} from 'design-system';
import { sortBy } from 'lodash';
import * as React from 'react';
import { useRef } from 'react';
import { useIntl } from 'react-intl';
import { SingleValue } from 'react-select';
import {
changeProfileParent,
copyProfile,
createQualityProfile,
getImporters,
} from '../../../api/quality-profiles';
import Modal from '../../../components/controls/Modal';
import RadioCard from '../../../components/controls/RadioCard';
import Select from '../../../components/controls/Select';
import ValidationInput from '../../../components/controls/ValidationInput';
import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
import { Location } from '../../../components/hoc/withRouter';
import CopyQualityProfileIcon from '../../../components/icons/CopyQualityProfileIcon';
import ExtendQualityProfileIcon from '../../../components/icons/ExtendQualityProfileIcon';
import NewQualityProfileIcon from '../../../components/icons/NewQualityProfileIcon';
import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
import Spinner from '../../../components/ui/Spinner';
import { translate } from '../../../helpers/l10n';
import { parseAsOptionalString } from '../../../helpers/query';
import { useProfileInheritanceQuery } from '../../../queries/quality-profiles';
import { Profile, ProfileActionModals } from '../types';
@@ -52,6 +59,9 @@ interface Props {

export default function CreateProfileForm(props: Readonly<Props>) {
const { languages, profiles, onCreate } = props;

const intl = useIntl();

const [importers, setImporters] = React.useState<
Array<{ key: string; languages: string[]; name: string }>
>([]);
@@ -67,6 +77,8 @@ export default function CreateProfileForm(props: Readonly<Props>) {
const [isValidProfile, setIsValidProfile] = React.useState<boolean>();
const [profile, setProfile] = React.useState<Profile>();

const backupForm = useRef<HTMLFormElement>(null);

const fetchImporters = React.useCallback(async () => {
setLoading(true);
try {
@@ -98,8 +110,8 @@ export default function CreateProfileForm(props: Readonly<Props>) {
);

const handleLanguageChange = React.useCallback(
(option: { value: string }) => {
setLanguage(option.value);
(option: SingleValue<LabelValueSelectOption<string>>) => {
setLanguage(option?.value);
setIsValidLanguage(true);
setProfile(undefined);
setIsValidProfile(false);
@@ -108,43 +120,40 @@ export default function CreateProfileForm(props: Readonly<Props>) {
);

const handleQualityProfileChange = React.useCallback(
(option: { value: Profile } | null) => {
(option: SingleValue<LabelValueSelectOption<Profile>>) => {
setProfile(option?.value);
setIsValidProfile(option !== null);
setIsValidProfile(Boolean(option?.value));
},
[setProfile, setIsValidProfile],
);

const handleFormSubmit = React.useCallback(
async (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();

setSubmitting(true);
const profileKey = profile?.key;
try {
if (action === ProfileActionModals.Copy && profileKey && name) {
const profile = await copyProfile(profileKey, name);
onCreate(profile);
} else if (action === ProfileActionModals.Extend) {
const { profile } = await createQualityProfile({ language, name });

const parentProfile = profiles.find((p) => p.key === profileKey);
if (parentProfile) {
await changeProfileParent(profile, parentProfile);
}
const handleFormSubmit = React.useCallback(async () => {
setSubmitting(true);
const profileKey = profile?.key;
try {
if (action === ProfileActionModals.Copy && profileKey && name) {
const profile = await copyProfile(profileKey, name);
onCreate(profile);
} else if (action === ProfileActionModals.Extend) {
const { profile } = await createQualityProfile({ language, name });

onCreate(profile);
} else {
const data = new FormData(event.currentTarget);
const { profile } = await createQualityProfile(data);
onCreate(profile);
const parentProfile = profiles.find((p) => p.key === profileKey);
if (parentProfile) {
await changeProfileParent(profile, parentProfile);
}
} finally {
setSubmitting(false);

onCreate(profile);
} else {
const formData = new FormData(backupForm?.current ? backupForm.current : undefined);
formData.set('language', language ?? '');
formData.set('name', name);
const { profile } = await createQualityProfile(formData);
onCreate(profile);
}
},
[setSubmitting, onCreate, profiles, action, language, name, profile],
);
} finally {
setSubmitting(false);
}
}, [setSubmitting, onCreate, profiles, action, language, name, profile]);

React.useEffect(() => {
fetchImporters();
@@ -175,7 +184,7 @@ export default function CreateProfileForm(props: Readonly<Props>) {
const canSubmit =
(action === undefined && isValidName && isValidLanguage) ||
(action !== undefined && isValidLanguage && isValidName && isValidProfile);
const header = translate('quality_profiles.new_profile');
const header = intl.formatMessage({ id: 'quality_profiles.new_profile' });

const languageQueryFilter = parseAsOptionalString(props.location.query.language);
const selectedLanguage = language ?? languageQueryFilter;
@@ -186,7 +195,7 @@ export default function CreateProfileForm(props: Readonly<Props>) {
const profilesForSelectedLanguage = profiles.filter((p) => p.language === selectedLanguage);
const profileOptions = sortBy(profilesForSelectedLanguage, 'name').map((profile) => ({
label: profile.isBuiltIn
? `${profile.name} (${translate('quality_profiles.built_in')})`
? `${profile.name} (${intl.formatMessage({ id: 'quality_profiles.built_in' })})`
: profile.name,
value: profile,
}));
@@ -196,179 +205,162 @@ export default function CreateProfileForm(props: Readonly<Props>) {
value: l.key,
}));

return (
<Modal contentLabel={header} onRequestClose={props.onClose} size="medium">
<form id="create-profile-form" onSubmit={handleFormSubmit}>
<div className="modal-head">
<h2>{header}</h2>
</div>
function handleSearch<T>(
options: { label: string; value: T }[],
query: string,
cb: (options: LabelValueSelectOption<T>[]) => void,
) {
cb(options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase())));
}

{loading ? (
<div className="modal-body">
<Spinner />
return (
<Modal
headerTitle={header}
onClose={props.onClose}
primaryButton={
!loading && (
<ButtonPrimary
onClick={handleFormSubmit}
disabled={submitting || !canSubmit}
type="submit"
>
{intl.formatMessage({ id: 'create' })}
</ButtonPrimary>
)
}
secondaryButtonLabel={intl.formatMessage({ id: 'cancel' })}
body={
<>
<LightLabel>
{intl.formatMessage({ id: 'quality_profiles.chose_creation_type' })}
</LightLabel>
<div className="sw-mt-4 sw-flex sw-flex-col sw-gap-2">
<SelectionCard
selected={action === ProfileActionModals.Extend}
onClick={handleSelectExtend}
title={intl.formatMessage({ id: 'quality_profiles.creation_from_extend' })}
>
<p className="spacer-bottom">
{intl.formatMessage({ id: 'quality_profiles.creation_from_extend_description_1' })}
</p>
<p>
{intl.formatMessage({ id: 'quality_profiles.creation_from_extend_description_2' })}
</p>
</SelectionCard>
<SelectionCard
selected={action === ProfileActionModals.Copy}
onClick={handleSelectCopy}
title={intl.formatMessage({ id: 'quality_profiles.creation_from_copy' })}
>
<p className="spacer-bottom">
{intl.formatMessage({ id: 'quality_profiles.creation_from_copy_description_1' })}
</p>
<p>
{intl.formatMessage({ id: 'quality_profiles.creation_from_copy_description_2' })}
</p>
</SelectionCard>
<SelectionCard
selected={action === undefined}
onClick={handleSelectBlank}
title={intl.formatMessage({ id: 'quality_profiles.creation_from_blank' })}
>
{intl.formatMessage({ id: 'quality_profiles.creation_from_blank_description' })}
</SelectionCard>
</div>
) : (
<div className="modal-body modal-container">
<fieldset className="modal-field big-spacer-bottom">
<label className="spacer-top">
{translate('quality_profiles.chose_creation_type')}
</label>
<div className="display-flex-row spacer-top">
<RadioCard
noRadio
selected={action === ProfileActionModals.Extend}
onClick={handleSelectExtend}
title={<ExtendQualityProfileIcon size={64} />}
>
<h3 className="spacer-bottom h4">
{translate('quality_profiles.creation_from_extend')}
</h3>
<p className="spacer-bottom">
{translate('quality_profiles.creation_from_extend_description_1')}
</p>
<p>{translate('quality_profiles.creation_from_extend_description_2')}</p>
</RadioCard>
<RadioCard
noRadio
selected={action === ProfileActionModals.Copy}
onClick={handleSelectCopy}
title={<CopyQualityProfileIcon size={64} />}
>
<h3 className="spacer-bottom h4">
{translate('quality_profiles.creation_from_copy')}
</h3>
<p className="spacer-bottom">
{translate('quality_profiles.creation_from_copy_description_1')}
</p>
<p>{translate('quality_profiles.creation_from_copy_description_2')}</p>
</RadioCard>
<RadioCard
noRadio
onClick={handleSelectBlank}
selected={action === undefined}
title={<NewQualityProfileIcon size={64} />}
>
<h3 className="spacer-bottom h4">
{translate('quality_profiles.creation_from_blank')}
</h3>
<p>{translate('quality_profiles.creation_from_blank_description')}</p>
</RadioCard>
{!isLoading && showBuiltInWarning && (
<FlagMessage variant="info" className="sw-block sw-my-4">
<div className="sw-flex sw-flex-col">
{intl.formatMessage({
id: 'quality_profiles.no_built_in_updates_warning.new_profile',
})}
<span className="sw-mt-1">
{intl.formatMessage({
id: 'quality_profiles.no_built_in_updates_warning.new_profile.2',
})}
</span>
</div>
</fieldset>

{!isLoading && showBuiltInWarning && (
<FlagMessage variant="info" className="sw-mb-4">
<div className="sw-flex sw-flex-col">
{translate('quality_profiles.no_built_in_updates_warning.new_profile')}
<span className="sw-mt-1">
{translate('quality_profiles.no_built_in_updates_warning.new_profile.2')}
</span>
</div>
</FlagMessage>
)}

<MandatoryFieldsExplanation className="modal-field" />

<ValidationInput
className="form-field"
labelHtmlFor="create-profile-language-input"
label={translate('language')}
required
isInvalid={isValidLanguage !== undefined && !isValidLanguage}
isValid={!!isValidLanguage}
>
<Select
</FlagMessage>
)}
<div className="sw-my-4">
<MandatoryFieldsExplanation />
</div>
<FormField label={intl.formatMessage({ id: 'language' })} required>
<SearchSelectDropdown
controlAriaLabel={intl.formatMessage({ id: 'language' })}
autoFocus
inputId="create-profile-language-input"
name="language"
onChange={handleLanguageChange}
defaultOptions={languagesOptions}
loadOptions={(inputValue, cb) => handleSearch(languagesOptions, inputValue, cb)}
value={languagesOptions.find((o) => o.value === selectedLanguage)}
zLevel={PopupZLevel.Global}
/>
</FormField>
{action !== undefined && (
<FormField label={intl.formatMessage({ id: 'quality_profiles.parent' })} required>
<SearchSelectDropdown
controlAriaLabel={intl.formatMessage({
id:
action === ProfileActionModals.Copy
? 'quality_profiles.creation.choose_copy_quality_profile'
: 'quality_profiles.creation.choose_parent_quality_profile',
})}
autoFocus
inputId="create-profile-language-input"
name="language"
isClearable={false}
onChange={handleLanguageChange}
options={languagesOptions}
inputId="create-profile-parent-input"
name="parentKey"
onChange={handleQualityProfileChange}
defaultOptions={profileOptions}
loadOptions={(inputValue, cb) => handleSearch(profileOptions, inputValue, cb)}
isSearchable
value={languagesOptions.filter((o) => o.value === selectedLanguage)}
value={profileOptions.find((o) => o.value === profile)}
/>
</ValidationInput>
{action !== undefined && (
<ValidationInput
className="form-field"
labelHtmlFor="create-profile-parent-input"
label={translate(
action === ProfileActionModals.Copy
? 'quality_profiles.creation.choose_copy_quality_profile'
: 'quality_profiles.creation.choose_parent_quality_profile',
)}
required
isInvalid={isValidProfile !== undefined && !isValidProfile}
isValid={!!isValidProfile}
>
<Select
autoFocus
inputId="create-profile-parent-input"
name="parentKey"
isClearable={false}
onChange={handleQualityProfileChange}
options={profileOptions}
isSearchable
value={profileOptions.filter((o) => o.value === profile)}
/>
</ValidationInput>
)}
<ValidationInput
className="form-field"
labelHtmlFor="create-profile-name"
label={translate('name')}
error={translate('quality_profiles.name_invalid')}
</FormField>
)}
<FormField
htmlFor="create-profile-name"
label={intl.formatMessage({ id: 'name' })}
required
>
<InputField
autoFocus
id="create-profile-name"
maxLength={50}
name="name"
onChange={handleNameChange}
required
isInvalid={isValidName !== undefined && !isValidName}
isValid={!!isValidName}
>
<input
autoFocus
id="create-profile-name"
maxLength={100}
name="name"
onChange={handleNameChange}
size={50}
type="text"
value={name}
/>
</ValidationInput>
size="full"
type="text"
value={name}
/>
</FormField>

{action === undefined &&
filteredImporters.map((importer) => (
<div
className="modal-field spacer-bottom js-importer"
data-key={importer.key}
{action === undefined && (
<form ref={backupForm}>
{filteredImporters.map((importer) => (
<FormField
key={importer.key}
htmlFor={'create-profile-form-backup-' + importer.key}
label={importer.name}
>
<label htmlFor={'create-profile-form-backup-' + importer.key}>
{importer.name}
</label>
<input
id={'create-profile-form-backup-' + importer.key}
name={'backup_' + importer.key}
type="file"
<FileInput
id={`create-profile-form-backup-${importer.key}`}
name={`backup_${importer.key}`}
chooseLabel={intl.formatMessage({ id: 'choose_file' })}
clearLabel={intl.formatMessage({ id: 'clear_file' })}
noFileLabel={intl.formatMessage({ id: 'no_file_selected' })}
/>
<p className="note">
{translate('quality_profiles.optional_configuration_file')}
</p>
</div>
))}
</div>
)}

<div className="modal-foot">
{(submitting || isLoading) && <i className="spinner spacer-right" />}
{!loading && (
<SubmitButton disabled={submitting || !canSubmit} id="create-profile-submit">
{translate('create')}
</SubmitButton>
<Note>
{intl.formatMessage({ id: 'quality_profiles.optional_configuration_file' })}
</Note>
</FormField>
))}{' '}
</form>
)}
<ResetButtonLink id="create-profile-cancel" onClick={props.onClose}>
{translate('cancel')}
</ResetButtonLink>
</div>
</form>
</Modal>

<Spinner loading={submitting || isLoading} />
</>
}
/>
);
}

+ 3
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties ファイルの表示

@@ -40,8 +40,10 @@ see_changelog=See Changelog
changelog=Changelog
change_verb=Change
check_all=Check all
choose_file=Choose file
class=Class
classes=Classes
clear_file=Clear file
close=Close
closed=Closed
code=Code
@@ -146,6 +148,7 @@ next=Next
new_name=New name
next_=next
none=None
no_file_selected=No file selected
no_tags=No tags
not_now=Not now
or=Or

読み込み中…
キャンセル
保存