Skip to content

Commit

Permalink
feat: restore items (#25)
Browse files Browse the repository at this point in the history
* feat(web): list deleted items

* feat(web): restore items
  • Loading branch information
KazuyaHara authored Nov 8, 2022
1 parent 50a8995 commit 2aa5787
Show file tree
Hide file tree
Showing 12 changed files with 215 additions and 5 deletions.
38 changes: 37 additions & 1 deletion web/src/adapters/infrastructure/item/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DocumentSnapshot,
FieldValue,
getDoc,
getDocs,
limit,
onSnapshot,
orderBy,
Expand All @@ -16,6 +17,8 @@ import {
Timestamp,
Unsubscribe,
where,
writeBatch,
WriteBatch,
} from 'firebase/firestore';

import { Item } from '../../../domains/item';
Expand All @@ -33,6 +36,8 @@ export interface IItemDriver {
create(data: Item): Promise<void>;
get(id: string): Promise<DocumentSnapshot<ItemData>>;
getId(): string;
listDeleted(): Promise<QuerySnapshot<ItemData>>;
restoreItems(ids: string[]): Promise<void>;
softDelete(id: string): Promise<void>;
subscribe: (
limitNumber: number,
Expand Down Expand Up @@ -70,6 +75,37 @@ export default function itemDriver(): IItemDriver {

const getId = () => doc(itemsRef).id;

const listDeleted = async () =>
getDocs(
query(
itemsRef,
where('deletedAt', '!=', null),
orderBy('deletedAt', 'desc')
) as Query<ItemData>
).catch((error) => {
throw handleFirestoreError(error);
});

const restoreItems = async (ids: string[]) => {
const batchArray = [] as Array<WriteBatch>;
batchArray.push(writeBatch(Firebase.instance.firetore));
let operationCounter = 0;
let batchIndex = 0;

ids.forEach((id) => {
batchArray[batchIndex].set(doc(itemsRef, id), { deletedAt: null }, { merge: true });
operationCounter += 1;
if (operationCounter >= 499) {
batchArray.push(writeBatch(Firebase.instance.firetore));
batchIndex += 1;
operationCounter = 0;
}
});
await Promise.all(batchArray.map(async (batch) => batch.commit())).catch((error) => {
throw handleFirestoreError(error);
});
};

const softDelete = async (id: string) => {
const params: SoftDeleteParams = { deletedAt: serverTimestamp() };
return setDoc(doc(itemsRef, id), params, { merge: true }).catch((error) => {
Expand All @@ -91,5 +127,5 @@ export default function itemDriver(): IItemDriver {
onNext
);

return { create, get, getId, softDelete, subscribe };
return { create, get, getId, listDeleted, restoreItems, softDelete, subscribe };
}
20 changes: 19 additions & 1 deletion web/src/adapters/repositories/item/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ export default function itemRepository(): IItemRepository {

const getId = (): string => itemDriver().getId();

const listDeleted = async (): Promise<Item[]> =>
itemDriver()
.listDeleted()
.then((querySnapshot) =>
querySnapshot.docs.map((doc) => {
const data = doc.data();
return {
id: doc.id,
...data,
date: data.date.toDate(),
createdAt: data.createdAt.toDate(),
updatedAt: data.updatedAt.toDate(),
};
})
);

const restoreItems = (ids: string[]): Promise<void> => itemDriver().restoreItems(ids);

const softDelete = (id: string): Promise<void> => itemDriver().softDelete(id);

const subscribe = (limit: number, onNext: (items: Item[]) => void) =>
Expand All @@ -42,5 +60,5 @@ export default function itemRepository(): IItemRepository {
)
);

return { create, get, getId, softDelete, subscribe };
return { create, get, getId, listDeleted, restoreItems, softDelete, subscribe };
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

import { AutoStories, CameraAlt, Photo } from '@mui/icons-material';
import { AutoStories, CameraAlt, Delete, Photo } from '@mui/icons-material';
import {
Box,
Drawer as MUIDrawer,
Expand Down Expand Up @@ -29,6 +29,7 @@ export const DrawerContent = () => {
to: '/albums',
},
{ icon: <CameraAlt sx={{ color: palette.text.primary }} />, primary: '機材', to: '/gears' },
{ icon: <Delete sx={{ color: palette.text.primary }} />, primary: 'ゴミ箱', to: '/trash' },
];

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';

import { CheckCircle } from '@mui/icons-material';
import { Grid, GridProps } from '@mui/material';

import { ItemWithURL } from '../../../../../../domains/item';
import AspectRetioImage from '../../../atoms/aspectRatioImage';

type Props = Pick<GridProps, 'sx'> & {
items: ItemWithURL[];
onSelectItem: (item: ItemWithURL) => void;
selectedItemIds: string[];
};

export default function ItemList({ items, onSelectItem, selectedItemIds, sx }: Props) {
return (
<Grid container spacing={1} sx={sx}>
{items.map((item) => (
<Grid
item
key={item.id}
onClick={() => onSelectItem(item)}
xs={4}
sm={3}
md={2}
sx={{ cursor: 'pointer', position: 'relative' }}
>
{selectedItemIds.includes(item.id) && (
<CheckCircle sx={{ color: 'white', position: 'absolute', top: 16, right: 8 }} />
)}
<AspectRetioImage borderRadius={1} src={item.url} />
</Grid>
))}
</Grid>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function ItemSectionList({ items, sx }: Props) {
<Box sx={sx}>
{sections.map((section) => (
<Box key={section.title} mb={3}>
<Typography gutterBottom variant="h2">
<Typography gutterBottom variant="h3">
{section.title}
</Typography>
<Grid container spacing={1}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';

import { Restore } from '@mui/icons-material';
import { LoadingButton } from '@mui/lab';
import { Box } from '@mui/material';

type Props = { loading: boolean; onSubmit: () => void; selectedItemIds: string[] };

export default function TrashHeader({ loading, onSubmit, selectedItemIds }: Props) {
return (
<Box display="flex" justifyContent="flex-end" mb={3}>
<LoadingButton
disabled={selectedItemIds.length === 0}
disableElevation
loading={loading}
onClick={onSubmit}
startIcon={<Restore />}
sx={{ borderRadius: 2 }}
variant="contained"
>
選択したメディアを復元する
</LoadingButton>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react';

import { Paper, Typography } from '@mui/material';

import useItemUseCase from '../../../../../../application/useCases/item';
import { ItemWithURL } from '../../../../../../domains/item';
import itemRepository from '../../../../../repositories/item';
import mediumRepository from '../../../../../repositories/medium';
import { useAlertStore } from '../../../../../stores/alert';
import ItemList from '../../../organisms/list/item';
import Loading from '../../loading';
import Header from '../header';

export default function TrashList() {
const { listDeleted: listDeletedMedia, restoreItems } = useItemUseCase(
itemRepository(),
mediumRepository()
);
const [items, setItems] = useState<ItemWithURL[]>();
const [loading, setLoading] = useState(false);
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);

useEffect(() => {
listDeletedMedia().then(setItems);
}, []);

const onSubmit = async () => {
if (!items) return;

setLoading(true);
await restoreItems(selectedItemIds)
.then(() => {
useAlertStore.setState({
message: '選択されたメディアを復元しました',
open: true,
severity: 'success',
});
setItems(items.filter((item) => !selectedItemIds.includes(item.id)));
setSelectedItemIds([]);
})
.catch(({ message }: Error) =>
useAlertStore.setState({ message, open: true, severity: 'error' })
)
.finally(() => setLoading(false));
};

const toggleItem = (item: ItemWithURL) => {
const toggled = selectedItemIds.find((id) => id === item.id)
? selectedItemIds.filter((id) => id !== item.id)
: selectedItemIds.concat(item.id);
setSelectedItemIds(toggled);
};

if (typeof items === 'undefined') return <Loading />;
return (
<>
<Header loading={loading} onSubmit={onSubmit} selectedItemIds={selectedItemIds} />
<Paper sx={{ p: 2 }}>
<Typography variant="h3">
{items.length > 0 ? 'ゴミ箱に移動した項目は30日後に完全に削除されます' : 'ゴミ箱は空です'}
</Typography>
</Paper>
<ItemList
items={items}
selectedItemIds={selectedItemIds}
sx={{ mt: 2 }}
onSelectItem={toggleItem}
/>
</>
);
}
2 changes: 2 additions & 0 deletions web/src/adapters/userInterface/routes/authenticated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import GearList from '../components/pages/gear/list';
import ItemAdd from '../components/pages/item/add';
import ItemGet from '../components/pages/item/get';
import ItemList from '../components/pages/item/list';
import TrashMedia from '../components/pages/trash/media';

export default function Authenticated() {
return (
Expand All @@ -24,6 +25,7 @@ export default function Authenticated() {
{ path: '/media', element: <ItemList /> },
{ path: '/media/add', element: <ItemAdd /> },
{ path: '/media/:id', element: <ItemGet /> },
{ path: '/trash', element: <TrashMedia /> },
{ path: '*', element: <Navigate to="/media" replace /> },
]) || <ItemList />
);
Expand Down
5 changes: 5 additions & 0 deletions web/src/adapters/userInterface/theme/typography.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ const typography = {
fontWeight: 700,
...responsiveFontSizes({ sm: 20, md: 20, lg: 20 }),
},
h3: {
fontSize: pxToRem(12),
fontWeight: 700,
...responsiveFontSizes({ sm: 18, md: 18, lg: 18 }),
},
} as const;

export default typography;
14 changes: 13 additions & 1 deletion web/src/application/useCases/item/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,22 @@ export default function useItemUseCase(
return { ...item, url };
});

const listDeleted = async () =>
itemRepository.listDeleted().then(async (items) =>
Promise.all(
items.map(async (item) => {
const url = await mediumRepository.getURL(item.medium.thumbnail || item.medium.path);
return { ...item, url };
})
)
);

const queueUpload = async (files: File[]) => {
await Promise.all(Array.from(files).map(async (file) => upload(file)));
};

const restoreItems = async (ids: string[]) => itemRepository.restoreItems(ids);

const softDelete = async (id: string) => itemRepository.softDelete(id);

const subscribe = (limit: number, onNext: (items: ItemWithURL[]) => void) =>
Expand Down Expand Up @@ -54,5 +66,5 @@ export default function useItemUseCase(
await itemRepository.create(item);
};

return { get, queueUpload, softDelete, subscribe, upload };
return { get, listDeleted, queueUpload, restoreItems, softDelete, subscribe, upload };
}
2 changes: 2 additions & 0 deletions web/src/interface/repository/item/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export interface IItemRepository {
create(data: Item): Promise<void>;
get(id: string): Promise<Item | null>;
getId(): string;
listDeleted(): Promise<Item[]>;
restoreItems(ids: string[]): Promise<void>;
softDelete(id: string): Promise<void>;
subscribe(limit: number, onNext: (items: Item[]) => void): () => void;
}
2 changes: 2 additions & 0 deletions web/src/interface/useCase/item/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ export type ItemSubmit = { files: File[] };

export interface IItemUseCase {
get(id: string): Promise<ItemWithURL | null>;
listDeleted(): Promise<ItemWithURL[]>;
queueUpload(files: File[]): Promise<void>;
restoreItems(ids: string[]): Promise<void>;
softDelete(id: string): Promise<void>;
subscribe(limit: number, onNext: (items: ItemWithURL[]) => void): () => void;
upload(file: File): Promise<void>;
Expand Down

0 comments on commit 2aa5787

Please sign in to comment.