diff --git a/src/components/globalUpdate/CheckboxContainer.ts b/src/components/globalUpdate/CheckboxContainer.ts new file mode 100644 index 00000000..c678d37b --- /dev/null +++ b/src/components/globalUpdate/CheckboxContainer.ts @@ -0,0 +1,17 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { styled } from '@mui/material'; + +// eslint-disable-next-line import/prefer-default-export +export const CheckboxContainer = styled('div')({ + display: 'flex', + flexDirection: 'column', + maxHeight: '170px', + overflow: 'auto', +}); diff --git a/src/components/globalUpdate/GlobalUpdateSettings.tsx b/src/components/globalUpdate/GlobalUpdateSettings.tsx new file mode 100644 index 00000000..a635f2ea --- /dev/null +++ b/src/components/globalUpdate/GlobalUpdateSettings.tsx @@ -0,0 +1,31 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import List from '@mui/material/List'; +import ListSubheader from '@mui/material/ListSubheader'; +import { useTranslation } from 'react-i18next'; +import { GlobalUpdateSettingsCategories } from '@/components/globalUpdate/GlobalUpdateSettingsCategories.tsx'; +import { GlobalUpdateSettingsEntries } from '@/components/globalUpdate/GlobalUpdateSettingsEntries.tsx'; + +// eslint-disable-next-line import/prefer-default-export +export const GlobalUpdateSettings = () => { + const { t } = useTranslation(); + + return ( + + {t('library.settings.global_update.title')} + + } + > + + + + ); +}; diff --git a/src/components/globalUpdate/GlobalUpdateSettingsCategories.tsx b/src/components/globalUpdate/GlobalUpdateSettingsCategories.tsx new file mode 100644 index 00000000..83cc909a --- /dev/null +++ b/src/components/globalUpdate/GlobalUpdateSettingsCategories.tsx @@ -0,0 +1,217 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { useTranslation } from 'react-i18next'; +import { useEffect, useState } from 'react'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemText from '@mui/material/ListItemText'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogActions from '@mui/material/DialogActions'; +import Button from '@mui/material/Button'; +import { t as translate } from 'i18next'; +import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput.tsx'; +import makeToast from '@/components/util/Toast.tsx'; +import { IncludeInUpdate } from '@/lib/graphql/generated/graphql.ts'; +import { TCategory } from '@/typings.ts'; +import requestManager from '@/lib/requests/RequestManager.ts'; +import { CheckboxContainer } from '@/components/globalUpdate/CheckboxContainer.ts'; + +const booleanToIncludeInStatus = (status: boolean | null | undefined): IncludeInUpdate => { + switch (status) { + case false: + return IncludeInUpdate.Exclude; + case true: + return IncludeInUpdate.Include; + case null: + case undefined: + return IncludeInUpdate.Unset; + default: + throw new Error(`booleanToIncludeInStatus: unexpected IncludeInUpdate status "${status}"`); + } +}; + +const includeInUpdateStatusToBoolean = (status: IncludeInUpdate): boolean | null => { + switch (status) { + case IncludeInUpdate.Exclude: + return false; + case IncludeInUpdate.Include: + return true; + case IncludeInUpdate.Unset: + return null; + default: + throw new Error(`includeInUpdateStatusToBoolean: unexpected IncludeInUpdate status "${status}"`); + } +}; + +const getCategoryUpdateInfo = ( + categories: TCategory[], + areIncluded: boolean, + unsetCategories: number, + allCategories: number, + error: any, +) => { + if (error) { + return translate('global.error.label.failed_to_load_data'); + } + if (allCategories === -1) { + return translate('global.label.loading'); + } + + const noSpecificallyIncludedCategories = areIncluded && !categories.length && unsetCategories; + const includesAllCategories = categories.length === allCategories; + if (noSpecificallyIncludedCategories || includesAllCategories) { + return translate('extension.language.all'); + } + + if (!categories.length) { + return translate('global.label.none'); + } + + return categories.map((category) => category.name).join(', '); +}; + +// eslint-disable-next-line import/prefer-default-export +export const GlobalUpdateSettingsCategories = () => { + const { t } = useTranslation(); + + const { data, error: requestError } = requestManager.useGetCategories(); + const categories = data?.categories.nodes; + const [dialogCategories, setDialogCategories] = useState(categories ?? []); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + useEffect(() => { + if (!categories) { + return; + } + + setDialogCategories(categories); + }, [categories]); + + const unsetCategories: TCategory[] = + categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Unset) ?? []; + const excludedCategories: TCategory[] = + categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Exclude) ?? []; + const includedCategories: TCategory[] = + categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Include) ?? []; + const excludedCategoriesText = getCategoryUpdateInfo( + excludedCategories, + false, + unsetCategories.length, + categories?.length ?? -1, + requestError, + ); + const includedCategoriesText = getCategoryUpdateInfo( + includedCategories, + true, + unsetCategories.length, + categories?.length ?? -1, + requestError, + ); + + const updateCategory = (category: TCategory) => + requestManager.updateCategory(category.id, { includeInUpdate: category.includeInUpdate }).response; + + const updateCategories = async () => { + const categoriesToUpdate = dialogCategories.filter((category) => { + const currentCategory = categories?.find((currCategory) => currCategory.id === category.id); + + if (!currentCategory) { + return false; + } + + return currentCategory.includeInUpdate !== category.includeInUpdate; + }); + + setIsDialogOpen(false); + + try { + await Promise.all(categoriesToUpdate.map((category) => updateCategory(category))); + // TODO - update cache immediately + // mutate(categoriesEndpoint, [...dialogCategories], { revalidate: false }); + } catch (error) { + makeToast(t('global.error.label.failed_to_save_changes'), 'error'); + // mutate(categoriesEndpoint, [...categories]); + } + }; + + const closeDialog = () => { + setDialogCategories(categories ?? []); + setIsDialogOpen(false); + }; + + return ( + <> + setIsDialogOpen(true)}> + + + {t('library.settings.global_update.categories.label.include', { + includedCategoriesText, + })} + + + {t('library.settings.global_update.categories.label.exclude', { + excludedCategoriesText, + })} + + + } + secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }} + /> + + + + + {t('category.title.categories')} + + {t('library.settings.global_update.categories.label.info')} + + + {dialogCategories.map((category) => ( + { + const newIncludeState = booleanToIncludeInStatus(checked); + + const categoryIndex = dialogCategories.findIndex( + (category_) => category_ === category, + ); + const updatedDialogCategories: TCategory[] = [ + ...dialogCategories.slice(0, categoryIndex), + { + ...category, + includeInUpdate: newIncludeState, + }, + ...dialogCategories.slice(categoryIndex + 1, dialogCategories.length), + ]; + + setDialogCategories(updatedDialogCategories); + }} + /> + ))} + + + + + + + + + ); +}; diff --git a/src/components/globalUpdate/GlobalUpdateSettingsEntries.tsx b/src/components/globalUpdate/GlobalUpdateSettingsEntries.tsx new file mode 100644 index 00000000..2ad92f47 --- /dev/null +++ b/src/components/globalUpdate/GlobalUpdateSettingsEntries.tsx @@ -0,0 +1,164 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { t as translate } from 'i18next'; +import { useTranslation } from 'react-i18next'; +import { useEffect, useState } from 'react'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemText from '@mui/material/ListItemText'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; +import { GetServerSettingsQuery } from '@/lib/graphql/generated/graphql.ts'; +import { TranslationKey } from '@/typings.ts'; +import requestManager from '@/lib/requests/RequestManager.ts'; +import makeToast from '@/components/util/Toast.tsx'; +import { CheckboxContainer } from '@/components/globalUpdate/CheckboxContainer'; +import CheckboxInput from '@/components/atoms/CheckboxInput'; + +type GlobalUpdateSkipEntriesSettings = Pick< + GetServerSettingsQuery['settings'], + 'excludeUnreadChapters' | 'excludeNotStarted' | 'excludeCompleted' +>; + +const settingToTextMap: { [setting in keyof GlobalUpdateSkipEntriesSettings]: TranslationKey } = { + excludeUnreadChapters: 'library.settings.global_update.entries.label.unread_chapters', + excludeNotStarted: 'library.settings.global_update.entries.label.not_started', + excludeCompleted: 'library.settings.global_update.entries.label.completed', +}; + +const getSkipMangasText = (settings: GlobalUpdateSkipEntriesSettings | undefined, isLoading: boolean, error: any) => { + if (error) { + return translate('global.error.label.failed_to_load_data'); + } + + if (!settings || isLoading) { + return translate('global.label.loading'); + } + + const skipSettings: string[] = []; + + if (settings.excludeUnreadChapters) { + skipSettings.push(translate(settingToTextMap.excludeUnreadChapters) as string); + } + + if (settings.excludeNotStarted) { + skipSettings.push(translate(settingToTextMap.excludeNotStarted) as string); + } + + if (settings.excludeCompleted) { + skipSettings.push(translate(settingToTextMap.excludeCompleted) as string); + } + + const isNothingExcluded = !skipSettings.length; + if (isNothingExcluded) { + skipSettings.push(translate('global.label.none')); + } + + return skipSettings.join(', '); +}; + +const extractSkipEntriesSettings = ( + serverSettings: GetServerSettingsQuery['settings'], +): GlobalUpdateSkipEntriesSettings => ({ + excludeCompleted: serverSettings.excludeCompleted, + excludeNotStarted: serverSettings.excludeNotStarted, + excludeUnreadChapters: serverSettings.excludeUnreadChapters, +}); + +// eslint-disable-next-line import/prefer-default-export +export const GlobalUpdateSettingsEntries = () => { + const { t } = useTranslation(); + const { data, loading, error: requestError } = requestManager.useGetServerSettings(); + const globalUpdateSettings = data ? extractSkipEntriesSettings(data.settings) : undefined; + const [mutateSettings] = requestManager.useUpdateServerSettings(); + + const [dialogSettings, setDialogSettings] = useState( + globalUpdateSettings ?? ({} as GlobalUpdateSkipEntriesSettings), + ); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const skipEntriesText = getSkipMangasText(globalUpdateSettings, loading, requestError); + + const updateSettings = async () => { + const didSettingsChange = + globalUpdateSettings?.excludeCompleted !== dialogSettings.excludeCompleted || + globalUpdateSettings.excludeNotStarted !== dialogSettings.excludeNotStarted || + globalUpdateSettings.excludeUnreadChapters !== dialogSettings.excludeUnreadChapters; + + setIsDialogOpen(false); + + if (!didSettingsChange) { + return; + } + + try { + await mutateSettings({ variables: { input: { settings: dialogSettings } } }); + } catch (error) { + makeToast(t('global.error.label.failed_to_save_changes'), 'error'); + } + }; + + const closeDialog = () => { + setDialogSettings(globalUpdateSettings ?? ({} as GlobalUpdateSkipEntriesSettings)); + setIsDialogOpen(false); + }; + + useEffect(() => { + if (!globalUpdateSettings) { + return; + } + + setDialogSettings(globalUpdateSettings); + }, [ + globalUpdateSettings?.excludeCompleted, + globalUpdateSettings?.excludeNotStarted, + globalUpdateSettings?.excludeUnreadChapters, + ]); + + return ( + <> + setIsDialogOpen(true)}> + setIsDialogOpen(true)} + /> + + + + + + {t('library.settings.global_update.entries.title')} + + + {Object.entries(dialogSettings).map(([setting, value]) => ( + { + setDialogSettings({ + ...dialogSettings, + [setting]: checked, + }); + }} + /> + ))} + + + + + + + + + ); +}; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 39527f13..6ba186ec 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -313,6 +313,14 @@ "info": "Entries in excluded categories will not be updated even if they are also in included categories" } }, + "entries": { + "title": "Skip updating entries", + "label": { + "completed": "With \"Completed\" status", + "not_started": "That haven't been started", + "unread_chapters": "With unread chapter(s)" + } + }, "title": "Global update" }, "title": "Library Settings" diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index ba35c996..4f1abf90 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -83,6 +83,7 @@ import { GetMangaQueryVariables, GetMangasQuery, GetMangasQueryVariables, + GetServerSettingsQuery, GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables, GetSourceQuery, @@ -137,6 +138,8 @@ import { UpdateMangaPatchInput, UpdaterSubscription, UpdaterSubscriptionVariables, + UpdateServerSettingsMutation, + UpdateServerSettingsMutationVariables, UpdateSourcePreferencesMutation, UpdateSourcePreferencesMutationVariables, ValidateBackupQuery, @@ -199,6 +202,8 @@ import { RESTORE_BACKUP } from '@/lib/graphql/mutations/BackupMutation.ts'; import { VALIDATE_BACKUP } from '@/lib/graphql/queries/BackupQuery.ts'; import { DOWNLOAD_STATUS_SUBSCRIPTION } from '@/lib/graphql/subscriptions/DownloaderSubscription.ts'; import { UPDATER_SUBSCRIPTION } from '@/lib/graphql/subscriptions/UpdaterSubscription.ts'; +import { GET_SERVER_SETTINGS } from '@/lib/graphql/queries/SettingsQuery.ts'; +import { UPDATE_SERVER_SETTINGS } from '@/lib/graphql/mutations/SettingsMutation.ts'; enum GQLMethod { QUERY = 'QUERY', @@ -1776,6 +1781,18 @@ export class RequestManager { ): SubscriptionResult { return this.doRequest(GQLMethod.USE_SUBSCRIPTION, UPDATER_SUBSCRIPTION, {}, options); } + + public useGetServerSettings( + options?: QueryHookOptions, + ): AbortableApolloUseQueryResponse { + return this.doRequest(GQLMethod.USE_QUERY, GET_SERVER_SETTINGS, undefined, options); + } + + public useUpdateServerSettings( + options?: MutationHookOptions, + ): AbortableApolloUseMutationResponse { + return this.doRequest(GQLMethod.USE_MUTATION, UPDATE_SERVER_SETTINGS, undefined, options); + } } const requestManager = new RequestManager(); diff --git a/src/lib/requests/client/GraphQLClient.ts b/src/lib/requests/client/GraphQLClient.ts index c2fef6a4..a4f35205 100644 --- a/src/lib/requests/client/GraphQLClient.ts +++ b/src/lib/requests/client/GraphQLClient.ts @@ -26,6 +26,7 @@ const typePolicies: StrictTypedTypePolicies = { GlobalMetaType: { keyFields: ['key'] }, ExtensionType: { keyFields: ['apkName'] }, AboutPayload: { keyFields: [] }, + SettingsType: { keyFields: [] }, Query: { fields: { chapters: { diff --git a/src/screens/settings/LibrarySettings.tsx b/src/screens/settings/LibrarySettings.tsx index cc753005..9133426e 100644 --- a/src/screens/settings/LibrarySettings.tsx +++ b/src/screens/settings/LibrarySettings.tsx @@ -6,88 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useContext, useEffect, useState } from 'react'; -import List from '@mui/material/List'; -import ListItemText from '@mui/material/ListItemText'; -import ListSubheader from '@mui/material/ListSubheader'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogActions from '@mui/material/DialogActions'; -import Button from '@mui/material/Button'; -import ListItemButton from '@mui/material/ListItemButton'; -import DialogTitle from '@mui/material/DialogTitle'; -import { styled } from '@mui/material'; -import { t as translate } from 'i18next'; import { useTranslation } from 'react-i18next'; -import requestManager from '@/lib/requests/RequestManager.ts'; -import makeToast from '@/components/util/Toast'; -import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput'; -import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext'; -import SearchSettings from '@/screens/settings/SearchSettings'; -import { IncludeInUpdate } from '@/lib/graphql/generated/graphql.ts'; -import { TCategory } from '@/typings.ts'; - -const CategoriesDiv = styled('div')({ - display: 'flex', - flexDirection: 'column', - maxHeight: '170px', - overflow: 'auto', -}); - -const booleanToIncludeInStatus = (status: boolean | null | undefined): IncludeInUpdate => { - switch (status) { - case false: - return IncludeInUpdate.Exclude; - case true: - return IncludeInUpdate.Include; - case null: - case undefined: - return IncludeInUpdate.Unset; - default: - throw new Error(`booleanToIncludeInStatus: unexpected IncludeInUpdate status "${status}"`); - } -}; - -const includeInUpdateStatusToBoolean = (status: IncludeInUpdate): boolean | null => { - switch (status) { - case IncludeInUpdate.Exclude: - return false; - case IncludeInUpdate.Include: - return true; - case IncludeInUpdate.Unset: - return null; - default: - throw new Error(`includeInUpdateStatusToBoolean: unexpected IncludeInUpdate status "${status}"`); - } -}; - -const getCategoryUpdateInfo = ( - categories: TCategory[], - areIncluded: boolean, - unsetCategories: number, - allCategories: number, - error: any, -) => { - if (error) { - return translate('global.error.label.failed_to_load_data'); - } - if (allCategories === -1) { - return translate('global.label.loading'); - } - - const noSpecificallyIncludedCategories = areIncluded && !categories.length && unsetCategories; - const includesAllCategories = categories.length === allCategories; - if (noSpecificallyIncludedCategories || includesAllCategories) { - return translate('extension.language.all'); - } - - if (!categories.length) { - return translate('global.label.none'); - } - - return categories.map((category) => category.name).join(', '); -}; +import { useContext, useEffect } from 'react'; +import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx'; +import { GlobalUpdateSettings } from '@/components/globalUpdate/GlobalUpdateSettings.tsx'; +import SearchSettings from '@/screens/settings/SearchSettings.tsx'; export default function LibrarySettings() { const { t } = useTranslation(); @@ -100,153 +23,10 @@ export default function LibrarySettings() { useSetDefaultBackTo('settings'); - const { data, error: requestError } = requestManager.useGetCategories(); - const categories = data?.categories.nodes; - const [dialogCategories, setDialogCategories] = useState(categories ?? []); - const [isDialogOpen, setIsDialogOpen] = useState(false); - - useEffect(() => { - if (!categories) { - return; - } - - setDialogCategories(categories); - }, [categories]); - - const unsetCategories: TCategory[] = - categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Unset) ?? []; - const excludedCategories: TCategory[] = - categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Exclude) ?? []; - const includedCategories: TCategory[] = - categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Include) ?? []; - const excludedCategoriesText = getCategoryUpdateInfo( - excludedCategories, - false, - unsetCategories.length, - categories?.length ?? -1, - requestError, - ); - const includedCategoriesText = getCategoryUpdateInfo( - includedCategories, - true, - unsetCategories.length, - categories?.length ?? -1, - requestError, - ); - - const updateCategory = (category: TCategory) => - requestManager.updateCategory(category.id, { includeInUpdate: category.includeInUpdate }).response; - - const updateCategories = async () => { - const categoriesToUpdate = dialogCategories.filter((category) => { - const currentCategory = categories?.find((currCategory) => currCategory.id === category.id); - - if (!currentCategory) { - return false; - } - - return currentCategory.includeInUpdate !== category.includeInUpdate; - }); - - setIsDialogOpen(false); - - try { - await Promise.all(categoriesToUpdate.map((category) => updateCategory(category))); - // TODO - update cache immediately - // mutate(categoriesEndpoint, [...dialogCategories], { revalidate: false }); - } catch (error) { - makeToast(t('global.error.label.failed_to_save_changes'), 'error'); - // mutate(categoriesEndpoint, [...categories]); - } - }; - - const closeDialog = () => { - setDialogCategories(categories ?? []); - setIsDialogOpen(false); - }; - return ( <> - - {t('search.title.search')} - - } - > - - - - {t('library.settings.global_update.title')} - - } - > - setIsDialogOpen(true)}> - - - {t('library.settings.global_update.categories.label.include', { - includedCategoriesText, - })} - - - {t('library.settings.global_update.categories.label.exclude', { - excludedCategoriesText, - })} - - - } - secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }} - /> - - - - - - {t('category.title.categories')} - - {t('library.settings.global_update.categories.label.info')} - - - {dialogCategories.map((category) => ( - { - const newIncludeState = booleanToIncludeInStatus(checked); - - const categoryIndex = dialogCategories.findIndex( - (category_) => category_ === category, - ); - const updatedDialogCategories: TCategory[] = [ - ...dialogCategories.slice(0, categoryIndex), - { - ...category, - includeInUpdate: newIncludeState, - }, - ...dialogCategories.slice(categoryIndex + 1, dialogCategories.length), - ]; - - setDialogCategories(updatedDialogCategories); - }} - /> - ))} - - - - - - - + + ); } diff --git a/src/screens/settings/SearchSettings.tsx b/src/screens/settings/SearchSettings.tsx index 179b22ab..3474c2f9 100644 --- a/src/screens/settings/SearchSettings.tsx +++ b/src/screens/settings/SearchSettings.tsx @@ -6,11 +6,12 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { ListItem, ListItemText, Switch } from '@mui/material'; +import { List, ListItem, ListItemText, Switch } from '@mui/material'; import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'; import ListItemIcon from '@mui/material/ListItemIcon'; import SearchIcon from '@mui/icons-material/Search'; import { useTranslation } from 'react-i18next'; +import ListSubheader from '@mui/material/ListSubheader'; import { SearchMetadataKeys } from '@/typings'; import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata'; import { useSearchSettings } from '@/util/searchSettings'; @@ -29,18 +30,26 @@ export default function SearchSettings() { ); }; return ( - - - - - - - setSettingValue('ignoreFilters', e.target.checked)} - /> - - + + {t('search.title.search')} + + } + > + + + + + + + setSettingValue('ignoreFilters', e.target.checked)} + /> + + + ); }