Dragndrop feature to move a file into a folder (#138) (#148)

Co-authored-by: Anton SHEPILOV <ashepilov@linagora.com>
Co-authored-by: montaghanmy <monta.ghanmy@gmail.com>
This commit is contained in:
Greemty
2023-09-04 10:36:06 +02:00
committed by GitHub
parent c77bdbf99d
commit 5fd29bb0d6
13 changed files with 422 additions and 63 deletions
+1
View File
@@ -69,6 +69,7 @@
"@babel/preset-env": "^7.8.7", "@babel/preset-env": "^7.8.7",
"@babel/preset-es2017": "^7.0.0-beta.53", "@babel/preset-es2017": "^7.0.0-beta.53",
"@craco/craco": "^6.4.3", "@craco/craco": "^6.4.3",
"@dnd-kit/core": "^6.0.8",
"@fortawesome/fontawesome-svg-core": "^6.1.2", "@fortawesome/fontawesome-svg-core": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.2", "@fortawesome/free-solid-svg-icons": "^6.1.2",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
+1
View File
@@ -214,6 +214,7 @@
"common.access-level_read":"Read", "common.access-level_read":"Read",
"common.access-level_write":"Write", "common.access-level_write":"Write",
"common.access-level_no_access":"No access", "common.access-level_no_access":"No access",
"components.dragndrop_info_move_to":"move to",
"components.SelectorModalContent_move_to":"Move to", "components.SelectorModalContent_move_to":"Move to",
"components.SelectorModalContent_no_items":"No item selected", "components.SelectorModalContent_no_items":"No item selected",
"components.SelectorModalContent_select":"Selected", "components.SelectorModalContent_select":"Selected",
+1
View File
@@ -198,6 +198,7 @@
"common.access-level_read":"Lecture", "common.access-level_read":"Lecture",
"common.access-level_write":"Ecriture", "common.access-level_write":"Ecriture",
"common.access-level_no_access":"Pas d'accès", "common.access-level_no_access":"Pas d'accès",
"components.dragndrop_info_move_to":"déplacé vers",
"components.SelectorModalContent_move_to":"Déplacer vers", "components.SelectorModalContent_move_to":"Déplacer vers",
"components.SelectorModalContent_no_items":"Pas de fichier sélectionné", "components.SelectorModalContent_no_items":"Pas de fichier sélectionné",
"components.SelectorModalContent_select":"sélectionné(s)", "components.SelectorModalContent_select":"sélectionné(s)",
+1
View File
@@ -198,6 +198,7 @@
"common.access-level_read":"Read", "common.access-level_read":"Read",
"common.access-level_write":"Write", "common.access-level_write":"Write",
"common.access-level_no_access":"No access", "common.access-level_no_access":"No access",
"components.dragndrop_info_move_to":"move to",
"components.SelectorModalContent_move_to":"Move to", "components.SelectorModalContent_move_to":"Move to",
"components.SelectorModalContent_no_items":"No item selected", "components.SelectorModalContent_no_items":"No item selected",
"components.SelectorModalContent_select":"Selected", "components.SelectorModalContent_select":"Selected",
@@ -0,0 +1,22 @@
import React, { MouseEventHandler } from 'react';
import {useDraggable} from '@dnd-kit/core';
type DraggableProps={
children: React.ReactNode
id: number
}
export function Draggable(props:DraggableProps) {
const {attributes, listeners, setNodeRef, transform} = useDraggable({
id: `draggable-${props.id+1}`,
data: {
child: props.children
},
});
return (
<div ref={setNodeRef} {...listeners} {...attributes}>
{props.children}
</div>
);
}
@@ -0,0 +1,23 @@
import React from 'react';
import {useDroppable} from '@dnd-kit/core';
type DroppableProps={
children: React.ReactNode
id: any
}
export function Droppable(props:DroppableProps) {
const { isOver, setNodeRef } = useDroppable({
id: `droppable-${props.id}`,
data: {
child: props.children
},
});
return (
<div ref={setNodeRef} className={isOver ? "bg-sky-500/[.18] rounded-t-md rounded-b-md" : ""}>
{props.children}
</div>
);
}
@@ -21,7 +21,7 @@ import {
useOnBuildPeopleContextMenu, useOnBuildPeopleContextMenu,
useOnBuildDateContextMenu, useOnBuildDateContextMenu,
} from './context-menu'; } from './context-menu';
import { DocumentRow } from './documents/document-row'; import {DocumentRow, DocumentRowOverlay} from './documents/document-row';
import { FolderRow } from './documents/folder-row'; import { FolderRow } from './documents/folder-row';
import HeaderPath from './header-path'; import HeaderPath from './header-path';
import { ConfirmDeleteModal } from './modals/confirm-delete'; import { ConfirmDeleteModal } from './modals/confirm-delete';
@@ -36,11 +36,19 @@ import useRouteState from 'app/features/router/hooks/use-route-state';
import { SharedWithMeFilterState } from '@features/drive/state/shared-with-me-filter'; import { SharedWithMeFilterState } from '@features/drive/state/shared-with-me-filter';
import MenusManager from '@components/menus/menus-manager.jsx'; import MenusManager from '@components/menus/menus-manager.jsx';
import Languages from 'features/global/services/languages-service'; import Languages from 'features/global/services/languages-service';
import {DndContext, useSensors, useSensor, PointerSensor, DragOverlay} from '@dnd-kit/core';
import { Droppable } from 'app/features/dragndrop/hook/droppable';
import { Draggable } from 'app/features/dragndrop/hook/draggable';
import { useDriveActions } from '@features/drive/hooks/use-drive-actions';
import { ConfirmModalAtom } from './modals/confirm-move/index';
import { useCurrentUser } from 'app/features/users/hooks/use-current-user'; import { useCurrentUser } from 'app/features/users/hooks/use-current-user';
import { ConfirmModal } from './modals/confirm-move';
export const DriveCurrentFolderAtom = atomFamily< export const DriveCurrentFolderAtom = atomFamily<
string, string,
{ context?: string; initialFolderId: string } { context?: string; initialFolderId: string }
>({ >({
key: 'DriveCurrentFolderAtom', key: 'DriveCurrentFolderAtom',
default: options => options.initialFolderId || 'root', default: options => options.initialFolderId || 'root',
@@ -158,13 +166,78 @@ export default memo(
const buildFileTypeContextMenu = useOnBuildFileTypeContextMenu(); const buildFileTypeContextMenu = useOnBuildFileTypeContextMenu();
const buildPeopleContextMen = useOnBuildPeopleContextMenu(); const buildPeopleContextMen = useOnBuildPeopleContextMenu();
const buildDateContextMenu = useOnBuildDateContextMenu(); const buildDateContextMenu = useOnBuildDateContextMenu();
const setConfirmModalState = useSetRecoilState(ConfirmModalAtom);
const [activeIndex, setActiveIndex] = useState(null);
const [activeChild, setActiveChild] = useState(null);
const {update} = useDriveActions();
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
function handleDragStart(event:any) {
setActiveIndex(event.active.id);
setActiveChild(event.active.data.current.child.props.item);
}
function handleDragEnd(event:any) {
setActiveIndex(null);
setActiveChild(null);
if (event.over){
setConfirmModalState({
open: true,
parent_id: inTrash ? 'root' : event.over.data.current.child.props.item.id,
mode: 'move',
title:
Languages.t('components.item_context_menu.move.modal_header') +
` '${event.active.data.current.child.props.item.name}'`,
onSelected: async ids => {
await update(
{
parent_id: ids[0],
},
event.active.data.current.child.props.item.id,
event.active.data.current.child.props.item.parent_id,
);
},
})
}
}
function draggableMarkup(index: number, child: any) {
const commonProps = {
key: index,
className:
(index === 0 ? 'rounded-t-md ' : '') +
(index === documents.length - 1 ? 'rounded-b-md ' : ''),
item: child,
checked: checked[child.id] || false,
onCheck: (v: boolean) =>
setChecked(_.pickBy({ ...checked, [child.id]: v }, _.identity)),
onBuildContextMenu: () => onBuildContextMenu(details, child),
};
return (
isMobile ? (
<DocumentRow {...commonProps} />
) : (
<Draggable id={index}>
<DocumentRow {...commonProps} />
</Draggable>
)
);
}
return ( return (
<> <>
{viewId == 'shared-with-me' ? ( {viewId == 'shared-with-me' ? (
<> <>
<Suspense fallback={<></>}> <Suspense fallback={<></>}>
<DrivePreview items={documents} /> <DrivePreview items={documents}/>
</Suspense> </Suspense>
<SharedFilesTable /> <SharedFilesTable />
</> </>
@@ -197,8 +270,9 @@ export default memo(
<PropertiesModal /> <PropertiesModal />
<ConfirmDeleteModal /> <ConfirmDeleteModal />
<ConfirmTrashModal /> <ConfirmTrashModal />
<ConfirmModal />
<Suspense fallback={<></>}> <Suspense fallback={<></>}>
<DrivePreview items={documents} /> <DrivePreview items={documents}/>
</Suspense> </Suspense>
<div <div
className={ className={
@@ -297,69 +371,76 @@ export default memo(
</Menu> </Menu>
</div> </div>
<div className="grow overflow-auto">
{folders.length > 0 && (
<>
<Title className="mb-2 block">{Languages.t('scenes.app.drive.folders')}</Title>
{folders.map((child, index) => ( <DndContext sensors={sensors} onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
<FolderRow <div className="grow overflow-auto">
key={index}
className={ {folders.length > 0 && (
(index === 0 ? 'rounded-t-md ' : '') + <>
(index === folders.length - 1 ? 'rounded-b-md ' : '') <Title className="mb-2 block">{Languages.t('scenes.app.drive.folders')}</Title>
}
item={child}
onClick={() => {
return setParentId(child.id);
}}
checked={checked[child.id] || false}
onCheck={v =>
setChecked(_.pickBy({ ...checked, [child.id]: v }, _.identity))
}
onBuildContextMenu={() => onBuildContextMenu(details, child)}
/>
))}
<div className="my-6" />
</>
)}
<Title className="mb-2 block">{Languages.t('scenes.app.drive.documents')}</Title> {folders.map((child, index) => (
<Droppable id={index} key={index}>
<FolderRow
key={index}
className={
(index === 0 ? 'rounded-t-md ' : '') +
(index === folders.length - 1 ? 'rounded-b-md ' : '')
}
item={child}
onClick={() => {
return setParentId(child.id);
}}
checked={checked[child.id] || false}
onCheck={v =>
setChecked(_.pickBy({ ...checked, [child.id]: v }, _.identity))
}
onBuildContextMenu={() => onBuildContextMenu(details, child)}
/>
</Droppable>
))}
<div className="my-6" />
</>
)}
{documents.length === 0 && !loading && ( <Title className="mb-2 block">{Languages.t('scenes.app.drive.documents')}</Title>
<div className="mt-4 text-center border-2 border-dashed rounded-md p-8">
<Subtitle className="block mb-2">
{Languages.t('scenes.app.drive.nothing')}
</Subtitle>
{!inTrash && access != 'read' && (
<>
<Base>{Languages.t('scenes.app.drive.drag_and_drop')}</Base>
<br />
<Button onClick={() => openItemModal()} theme="primary" className="mt-4">
{Languages.t('scenes.app.drive.add_doc')}
</Button>
</>
)}
</div>
)}
{documents.map((child, index) => ( {documents.length === 0 && !loading && (
<DocumentRow <div className="mt-4 text-center border-2 border-dashed rounded-md p-8">
key={index} <Subtitle className="block mb-2">
className={ {Languages.t('scenes.app.drive.nothing')}
(index === 0 ? 'rounded-t-md ' : '') + </Subtitle>
(index === documents.length - 1 ? 'rounded-b-md ' : '') {!inTrash && access != 'read' && (
} <>
item={child} <Base>{Languages.t('scenes.app.drive.drag_and_drop')}</Base>
checked={checked[child.id] || false} <br />
onCheck={v => setChecked(_.pickBy({ ...checked, [child.id]: v }, _.identity))} <Button onClick={() => openItemModal()} theme="primary" className="mt-4">
onBuildContextMenu={() => onBuildContextMenu(details, child)} {Languages.t('scenes.app.drive.add_doc')}
/> </Button>
))} </>
</div> )}
</div> </div>
)}
{documents.map((child, index) => (
draggableMarkup(index, child)
))}
<DragOverlay>
{activeIndex ? (
<DocumentRowOverlay className={
(activeIndex === 0 ? 'rounded-t-md ' : '') +
(activeIndex === documents.length - 1 ? 'rounded-b-md ' : '')}
item={activeChild}
></DocumentRowOverlay>
): null}
</DragOverlay>
</div>
</DndContext>
</div>
</UploadZone> </UploadZone>
)} )}
</> </>
); );
}, },
@@ -11,6 +11,10 @@ export type DriveItemProps = {
onBuildContextMenu: () => Promise<any[]>; onBuildContextMenu: () => Promise<any[]>;
}; };
export type DriveItemOverlayProps = {
item: DriveItem|null;
className: string;
};
export const menuBuilder = async () => {}; export const menuBuilder = async () => {};
export const CheckableIcon = ({ export const CheckableIcon = ({
@@ -19,7 +19,7 @@ import fileUploadApiClient from '@features/files/api/file-upload-api-client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Avatar from '../../../../../atoms/avatar'; import Avatar from '../../../../../atoms/avatar';
import { PublicIcon } from '../components/public-icon'; import { PublicIcon } from '../components/public-icon';
import { CheckableIcon, DriveItemProps } from './common'; import {CheckableIcon, DriveItemOverlayProps, DriveItemProps} from './common';
import { getDevice } from '../../../../../features/global/utils/device'; import { getDevice } from '../../../../../features/global/utils/device';
import './style.scss'; import './style.scss';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@@ -143,3 +143,21 @@ export const DocumentRow = ({
</div> </div>
); );
}; };
export const DocumentRowOverlay = ({
item,
className,
}: DriveItemOverlayProps) => {
return (
<div
className={
'flex flex-row items-center border border-zinc-200 dark:border-zinc-800 -mt-px px-4 py-3 cursor-pointer ' +
(className || '')
}
>
<div className="grow text-ellipsis whitespace-nowrap overflow-hidden">
<Base>{item?.name}</Base>
</div>
</div>
)}
@@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import { Button } from '@atoms/button/button';
import { Modal, ModalContent } from '@atoms/modal';
import { useDriveItem } from '@features/drive/hooks/use-drive-item';
import { DriveItem } from '@features/drive/types';
import { atom, useRecoilState } from 'recoil';
import Languages from '@features/global/services/languages-service';
export type ConfirmModalType = {
open: boolean;
parent_id: string;
mode: 'move' | 'select-file' | 'select-files';
title: string;
onSelected: (ids: string[]) => Promise<void>;
};
export const ConfirmModalAtom = atom<ConfirmModalType>({
key: 'ConfirmModalAtom',
default: {
open: false,
parent_id: '',
mode: 'move',
title: '',
onSelected: async () => {},
},
});
const ConfirmModalContent = () => {
const [state, setState] = useRecoilState(ConfirmModalAtom);
const [selected, setSelected] = useState<DriveItem[]>([]);
const [loading, setLoading] = useState(false);
const parentId = state.parent_id;
const { item: parent } = useDriveItem(parentId);
useEffect(() => {
if (state.mode === 'select-file' && parent) setSelected([]);
if (state.mode === 'move' && parent) setSelected([parent]);
}, [parentId, parent?.id]);
const handleClose = async () => {
setLoading(true);
await state.onSelected(selected.map((i) => i.id));
setState({ ...state, open: false });
setLoading(false);
};
return (
<ModalContent title={state.title}>
<Button
disabled={selected.length === 0}
loading={loading}
theme="primary"
className="float-right"
onClick={handleClose}
>
<>{Languages.t('components.SelectorModalContent_move_to')} '{selected[0]?.name}'</>
</Button>
</ModalContent>
);
};
export const ConfirmModal = () => {
const [state, setState] = useRecoilState(ConfirmModalAtom);
const handleClose = () => {
setState({ ...state, open: false });
};
return (
<Modal open={state.open} onClose={handleClose}>
<ConfirmModalContent key={state.parent_id} />
</Modal>
);
};
+23
View File
@@ -2030,6 +2030,29 @@
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
"@dnd-kit/accessibility@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c"
integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==
dependencies:
tslib "^2.0.0"
"@dnd-kit/core@^6.0.8":
version "6.0.8"
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.8.tgz#040ae13fea9787ee078e5f0361f3b49b07f3f005"
integrity sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==
dependencies:
"@dnd-kit/accessibility" "^3.0.0"
"@dnd-kit/utilities" "^3.2.1"
tslib "^2.0.0"
"@dnd-kit/utilities@^3.2.1":
version "3.2.1"
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.1.tgz#53f9e2016fd2506ec49e404c289392cfff30332a"
integrity sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==
dependencies:
tslib "^2.0.0"
"@emotion/hash@^0.8.0": "@emotion/hash@^0.8.0":
version "0.8.0" version "0.8.0"
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413"
+102
View File
@@ -0,0 +1,102 @@
{
"name": "tdrive",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@dnd-kit/core": "^6.0.8",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz",
"integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.0.8",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz",
"integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==",
"dependencies": {
"@dnd-kit/accessibility": "^3.0.0",
"@dnd-kit/utilities": "^3.2.1",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz",
"integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"node_modules/scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/tslib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"dependencies": {
"@dnd-kit/core": "^6.0.8",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}