mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
Merge branch 'develop' of https://github.com/rowyio/rowy into develop
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
set as _set,
|
||||
isEqual,
|
||||
unset,
|
||||
filter,
|
||||
} from "lodash-es";
|
||||
|
||||
import { currentUserAtom } from "@src/atoms/projectScope";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
updateRowData,
|
||||
omitRowyFields,
|
||||
} from "@src/utils/table";
|
||||
import { arrayRemove, arrayUnion } from "firebase/firestore";
|
||||
|
||||
export interface IAddRowOptions {
|
||||
/** The row or array of rows to add */
|
||||
@@ -306,6 +308,10 @@ export interface IUpdateFieldOptions {
|
||||
ignoreRequiredFields?: boolean;
|
||||
/** Optionally, disable checking if the updated value is equal to the current value. By default, we skip the update if they’re equal. */
|
||||
disableCheckEquality?: boolean;
|
||||
/** Optionally, uses firestore's arrayUnion with the given value. Appends given value items to the existing array */
|
||||
useArrayUnion?: boolean;
|
||||
/** Optionally, uses firestore's arrayRemove with the given value. Removes given value items from the existing array */
|
||||
useArrayRemove?: boolean;
|
||||
}
|
||||
/**
|
||||
* Set function updates or deletes a field in a row.
|
||||
@@ -331,6 +337,8 @@ export const updateFieldAtom = atom(
|
||||
deleteField,
|
||||
ignoreRequiredFields,
|
||||
disableCheckEquality,
|
||||
useArrayUnion,
|
||||
useArrayRemove,
|
||||
}: IUpdateFieldOptions
|
||||
) => {
|
||||
const updateRowDb = get(_updateRowDbAtom);
|
||||
@@ -367,8 +375,36 @@ export const updateFieldAtom = atom(
|
||||
_set(update, fieldName, value);
|
||||
}
|
||||
|
||||
const localUpdate = cloneDeep(update);
|
||||
const dbUpdate = cloneDeep(update);
|
||||
// apply arrayUnion
|
||||
if (useArrayUnion) {
|
||||
if (!Array.isArray(update[fieldName]))
|
||||
throw new Error("Field must be an array");
|
||||
|
||||
// use basic array merge on local row value
|
||||
localUpdate[fieldName] = [
|
||||
...(row[fieldName] ?? []),
|
||||
...localUpdate[fieldName],
|
||||
];
|
||||
dbUpdate[fieldName] = arrayUnion(...dbUpdate[fieldName]);
|
||||
}
|
||||
|
||||
//apply arrayRemove
|
||||
if (useArrayRemove) {
|
||||
if (!Array.isArray(update[fieldName]))
|
||||
throw new Error("Field must be an array");
|
||||
|
||||
// use basic array filter on local row value
|
||||
localUpdate[fieldName] = filter(
|
||||
row[fieldName] ?? [],
|
||||
(el) => !find(localUpdate[fieldName], el)
|
||||
);
|
||||
dbUpdate[fieldName] = arrayRemove(...dbUpdate[fieldName]);
|
||||
}
|
||||
|
||||
// Check for required fields
|
||||
const newRowValues = updateRowData(cloneDeep(row), update);
|
||||
const newRowValues = updateRowData(cloneDeep(row), dbUpdate);
|
||||
const requiredFields = ignoreRequiredFields
|
||||
? []
|
||||
: tableColumnsOrdered
|
||||
@@ -383,7 +419,7 @@ export const updateFieldAtom = atom(
|
||||
set(tableRowsLocalAtom, {
|
||||
type: "update",
|
||||
path,
|
||||
row: update,
|
||||
row: localUpdate,
|
||||
deleteFields: deleteField ? [fieldName] : [],
|
||||
});
|
||||
|
||||
@@ -403,7 +439,7 @@ export const updateFieldAtom = atom(
|
||||
else {
|
||||
await updateRowDb(
|
||||
row._rowy_ref.path,
|
||||
omitRowyFields(update),
|
||||
omitRowyFields(dbUpdate),
|
||||
deleteField ? [fieldName] : []
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,3 +238,6 @@ export type AuditChangeFunction = (
|
||||
* @param data - Optional additional data to log
|
||||
*/
|
||||
export const auditChangeAtom = atom<AuditChangeFunction | undefined>(undefined);
|
||||
|
||||
/** Store total number of rows in firestore collection */
|
||||
export const serverDocCountAtom = atom(0)
|
||||
@@ -15,6 +15,8 @@ import useMonacoCustomizations, {
|
||||
} from "./useMonacoCustomizations";
|
||||
import FullScreenButton from "@src/components/FullScreenButton";
|
||||
import { spreadSx } from "@src/utils/ui";
|
||||
import githubLightTheme from "@src/components/CodeEditor/github-light-default.json";
|
||||
import githubDarkTheme from "@src/components/CodeEditor/github-dark-default.json";
|
||||
|
||||
export interface IDiffEditorProps
|
||||
extends Partial<DiffEditorProps>,
|
||||
@@ -73,7 +75,12 @@ export default function DiffEditor({
|
||||
loading={<CircularProgressOptical size={20} sx={{ m: 2 }} />}
|
||||
className="editor"
|
||||
{...props}
|
||||
beforeMount={(monaco) => {
|
||||
monaco.editor.defineTheme("github-light", githubLightTheme as any);
|
||||
monaco.editor.defineTheme("github-dark", githubDarkTheme as any);
|
||||
}}
|
||||
onMount={handleEditorMount}
|
||||
theme={`github-${theme.palette.mode}`}
|
||||
options={
|
||||
{
|
||||
readOnly: disabled,
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function BuildLogsSnack({
|
||||
borderRadius: 1,
|
||||
zIndex: 1,
|
||||
transition: (theme) => theme.transitions.create("height"),
|
||||
height: expanded ? "calc(100% - 300px)" : 300,
|
||||
height: expanded ? "calc(100% - 300px)" : 50,
|
||||
}}
|
||||
>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
@@ -178,7 +178,7 @@ export default function BuildLogsSnack({
|
||||
height={"calc(100% - 25px)"}
|
||||
id="live-stream-scroll-box-snack"
|
||||
>
|
||||
{latestActiveLog && (
|
||||
{latestActiveLog && expanded && (
|
||||
<>
|
||||
{logs?.map((log: any, index: number) => (
|
||||
<BuildLogRow logRecord={log} index={index} key={index} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
tableScope,
|
||||
tableRowsAtom,
|
||||
tableNextPageAtom,
|
||||
serverDocCountAtom
|
||||
} from "@src/atoms/tableScope";
|
||||
import { spreadSx } from "@src/utils/ui";
|
||||
|
||||
@@ -56,18 +57,21 @@ const loadingIcon = (
|
||||
);
|
||||
|
||||
function LoadedRowsStatus() {
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope);
|
||||
const [tableNextPage] = useAtom(tableNextPageAtom, tableScope);
|
||||
const [serverDocCount] = useAtom(serverDocCountAtom, tableScope)
|
||||
const [tableRows] = useAtom(tableRowsAtom, tableScope)
|
||||
|
||||
|
||||
if (tableNextPage.loading)
|
||||
return <StatusText>{loadingIcon}Loading more…</StatusText>;
|
||||
|
||||
|
||||
return (
|
||||
<Tooltip title="Syncing with database in realtime" describeChild>
|
||||
<StatusText>
|
||||
<SyncIcon style={{ transform: "rotate(45deg)" }} />
|
||||
Loaded {!tableNextPage.available && "all "}
|
||||
{tableRows.length} row{tableRows.length !== 1 && "s"}
|
||||
{tableRows.length} {tableNextPage.available && `of ${serverDocCount}`} row{serverDocCount !== 1 && "s"}
|
||||
</StatusText>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -303,7 +303,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
aria-label="Action will run"
|
||||
name="isActionScript"
|
||||
value={
|
||||
config.isActionScript ? "actionScript" : "cloudFunction"
|
||||
config.isActionScript !== false ? "actionScript" : "cloudFunction"
|
||||
}
|
||||
onChange={(e) =>
|
||||
onChange("isActionScript")(
|
||||
@@ -359,7 +359,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{!config.isActionScript ? (
|
||||
{config.isActionScript === false ? (
|
||||
<TextField
|
||||
id="callableName"
|
||||
label="Callable name"
|
||||
@@ -492,7 +492,7 @@ const Settings = ({ config, onChange, fieldName }: ISettingsProps) => {
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
config.isActionScript &&
|
||||
config.isActionScript !== false &&
|
||||
get(config, "undo.enabled") && {
|
||||
id: "undo",
|
||||
title: "Undo action",
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { ISideDrawerFieldProps } from "@src/components/fields/types";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
|
||||
import {
|
||||
alpha,
|
||||
ButtonBase,
|
||||
@@ -15,69 +10,33 @@ import {
|
||||
Chip,
|
||||
} from "@mui/material";
|
||||
import { Upload as UploadIcon } from "@src/assets/icons";
|
||||
import { FileIcon } from ".";
|
||||
|
||||
import { ISideDrawerFieldProps } from "@src/components/fields/types";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
|
||||
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import useFileUpload from "./useFileUpload";
|
||||
import { FileIcon } from ".";
|
||||
|
||||
export default function File_({
|
||||
column,
|
||||
_rowy_ref,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: ISideDrawerFieldProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const { loading, progress, handleDelete, localFiles, dropzoneState } =
|
||||
useFileUpload(_rowy_ref, column.key, { multiple: true });
|
||||
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
|
||||
// Store a preview image locally while uploading
|
||||
const [localFile, setLocalFile] = useState<string>("");
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
|
||||
if (_rowy_ref && file) {
|
||||
upload({
|
||||
docRef: _rowy_ref! as any,
|
||||
fieldName: column.key,
|
||||
files: [file],
|
||||
previousValue: value ?? [],
|
||||
onComplete: (newValue) => {
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
setLocalFile("");
|
||||
},
|
||||
});
|
||||
setLocalFile(file.name);
|
||||
}
|
||||
},
|
||||
[_rowy_ref, value]
|
||||
);
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
const newValue = [...value];
|
||||
const toBeDeleted = newValue.splice(index, 1);
|
||||
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
});
|
||||
const { isDragActive, getRootProps, getInputProps } = dropzoneState;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!disabled && (
|
||||
<ButtonBase
|
||||
disabled={loading}
|
||||
sx={[
|
||||
fieldSx,
|
||||
{
|
||||
@@ -101,13 +60,21 @@ export default function File_({
|
||||
<Typography color="inherit" style={{ flexGrow: 1 }}>
|
||||
Click to upload or drop file here
|
||||
</Typography>
|
||||
<UploadIcon sx={{ ml: 1, mr: 2 / 8 }} />
|
||||
{loading ? (
|
||||
<CircularProgressOptical
|
||||
size={20}
|
||||
variant={progress === 0 ? "indeterminate" : "determinate"}
|
||||
value={progress}
|
||||
/>
|
||||
) : (
|
||||
<UploadIcon sx={{ ml: 1, mr: 2 / 8 }} />
|
||||
)}
|
||||
</ButtonBase>
|
||||
)}
|
||||
|
||||
<Grid container spacing={0.5} style={{ marginTop: 2 }}>
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue, i) => (
|
||||
value.map((file: FileValue) => (
|
||||
<Grid item key={file.name}>
|
||||
<Tooltip
|
||||
title={`File last modified ${format(
|
||||
@@ -128,7 +95,7 @@ export default function File_({
|
||||
body: "This file cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () => handleDelete(i),
|
||||
handleConfirm: () => handleDelete(file),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
@@ -138,15 +105,18 @@ export default function File_({
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{localFile && (
|
||||
<Grid item>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={localFile}
|
||||
deleteIcon={<CircularProgressOptical size={20} color="inherit" />}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{localFiles &&
|
||||
localFiles.map((file) => (
|
||||
<Grid item>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
deleteIcon={
|
||||
<CircularProgressOptical size={20} color="inherit" />
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { IHeavyCellProps } from "@src/components/fields/types";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { findIndex } from "lodash-es";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { alpha, Stack, Grid, Tooltip, Chip, IconButton } from "@mui/material";
|
||||
@@ -12,64 +9,24 @@ import ChipList from "@src/components/Table/formatters/ChipList";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
import { FileIcon } from ".";
|
||||
import { DATE_TIME_FORMAT } from "@src/constants/dates";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import useFileUpload from "./useFileUpload";
|
||||
|
||||
export default function File_({
|
||||
column,
|
||||
row,
|
||||
value,
|
||||
onSubmit,
|
||||
disabled,
|
||||
docRef,
|
||||
}: IHeavyCellProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
const { progress, isLoading } = uploaderState;
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
|
||||
if (file) {
|
||||
upload({
|
||||
docRef: docRef! as any,
|
||||
fieldName: column.key,
|
||||
files: [file],
|
||||
previousValue: value,
|
||||
onComplete: (newValue) => {
|
||||
updateField({
|
||||
path: docRef.path,
|
||||
fieldName: column.key,
|
||||
value: newValue,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[value]
|
||||
);
|
||||
|
||||
const handleDelete = (ref: string) => {
|
||||
const newValue = [...value];
|
||||
const index = findIndex(newValue, ["ref", ref]);
|
||||
const toBeDeleted = newValue.splice(index, 1);
|
||||
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
|
||||
onSubmit(newValue);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
});
|
||||
const { loading, progress, handleDelete, localFiles, dropzoneState } =
|
||||
useFileUpload(docRef, column.key, { multiple: true });
|
||||
|
||||
const { isDragActive, getRootProps, getInputProps } = dropzoneState;
|
||||
const dropzoneProps = getRootProps();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -112,8 +69,13 @@ export default function File_({
|
||||
)}`}
|
||||
>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
icon={<FileIcon />}
|
||||
sx={{
|
||||
"& .MuiChip-label": {
|
||||
lineHeight: 5 / 3,
|
||||
},
|
||||
}}
|
||||
onClick={(e) => {
|
||||
window.open(file.downloadURL);
|
||||
e.stopPropagation();
|
||||
@@ -123,21 +85,32 @@ export default function File_({
|
||||
? undefined
|
||||
: () =>
|
||||
confirm({
|
||||
handleConfirm: () => handleDelete(file.ref),
|
||||
handleConfirm: () => handleDelete(file),
|
||||
title: "Delete file?",
|
||||
body: "This file cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
})
|
||||
}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
))}
|
||||
{localFiles &&
|
||||
localFiles.map((file) => (
|
||||
<Grid item>
|
||||
<Chip
|
||||
icon={<FileIcon />}
|
||||
label={file.name}
|
||||
deleteIcon={
|
||||
<CircularProgressOptical size={20} color="inherit" />
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</ChipList>
|
||||
|
||||
{!isLoading ? (
|
||||
{!loading ? (
|
||||
!disabled && (
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
87
src/components/fields/File/useFileUpload.ts
Normal file
87
src/components/fields/File/useFileUpload.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { some } from "lodash-es";
|
||||
|
||||
import { tableScope, updateFieldAtom } from "@src/atoms/tableScope";
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import { DropzoneOptions, useDropzone } from "react-dropzone";
|
||||
|
||||
export default function useFileUpload(
|
||||
docRef: any,
|
||||
fieldName: string,
|
||||
dropzoneOptions: DropzoneOptions = {}
|
||||
) {
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
|
||||
const [localFiles, setLocalFiles] = useState<File[]>([]);
|
||||
|
||||
const dropzoneState = useDropzone({
|
||||
onDrop: async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setLocalFiles(acceptedFiles);
|
||||
await handleUpload(acceptedFiles);
|
||||
setLocalFiles([]);
|
||||
}
|
||||
},
|
||||
...dropzoneOptions,
|
||||
});
|
||||
|
||||
const uploadingFiles = Object.keys(uploaderState);
|
||||
|
||||
const progress =
|
||||
uploadingFiles.length > 0
|
||||
? uploadingFiles.reduce((sum, fileName) => {
|
||||
const fileState = uploaderState[fileName];
|
||||
return sum + fileState.progress;
|
||||
}, 0) / uploadingFiles.length
|
||||
: 0;
|
||||
|
||||
const loading = some(
|
||||
uploadingFiles,
|
||||
(fileName) => uploaderState[fileName].loading
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
const { uploads, failures } = await upload({
|
||||
docRef,
|
||||
fieldName,
|
||||
files,
|
||||
});
|
||||
updateField({
|
||||
path: docRef.path,
|
||||
fieldName,
|
||||
value: uploads,
|
||||
useArrayUnion: true,
|
||||
});
|
||||
return { uploads, failures };
|
||||
},
|
||||
[docRef, fieldName, updateField, upload]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(file: FileValue) => {
|
||||
updateField({
|
||||
path: docRef.path,
|
||||
fieldName,
|
||||
value: [file],
|
||||
useArrayRemove: true,
|
||||
disableCheckEquality: true,
|
||||
});
|
||||
deleteUpload(file);
|
||||
},
|
||||
[deleteUpload, docRef, fieldName, updateField]
|
||||
);
|
||||
|
||||
return {
|
||||
localFiles,
|
||||
progress,
|
||||
loading,
|
||||
uploaderState,
|
||||
handleUpload,
|
||||
handleDelete,
|
||||
dropzoneState,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { ISideDrawerFieldProps } from "@src/components/fields/types";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
// TODO: GENERALIZE
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
import { assignIn } from "lodash-es";
|
||||
|
||||
import {
|
||||
alpha,
|
||||
@@ -20,12 +17,14 @@ import AddIcon from "@mui/icons-material/AddAPhotoOutlined";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import OpenIcon from "@mui/icons-material/OpenInNewOutlined";
|
||||
|
||||
import { FileValue } from "@src/types/table";
|
||||
import Thumbnail from "@src/components/Thumbnail";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
import { IMAGE_MIME_TYPES } from ".";
|
||||
import { fieldSx, getFieldId } from "@src/components/SideDrawer/utils";
|
||||
import useFileUpload from "@src/components/fields/File/useFileUpload";
|
||||
import { IMAGE_MIME_TYPES } from ".";
|
||||
|
||||
const imgSx = {
|
||||
position: "relative",
|
||||
@@ -84,57 +83,37 @@ export default function Image_({
|
||||
column,
|
||||
_rowy_ref,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled,
|
||||
}: ISideDrawerFieldProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
const { progress } = uploaderState;
|
||||
|
||||
// Store a preview image locally while uploading
|
||||
const [localImage, setLocalImage] = useState<string>("");
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const imageFile = acceptedFiles[0];
|
||||
|
||||
if (_rowy_ref && imageFile) {
|
||||
upload({
|
||||
docRef: _rowy_ref! as any,
|
||||
fieldName: column.key,
|
||||
files: [imageFile],
|
||||
previousValue: value ?? [],
|
||||
onComplete: (newValue) => {
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
setLocalImage("");
|
||||
},
|
||||
});
|
||||
setLocalImage(URL.createObjectURL(imageFile));
|
||||
}
|
||||
},
|
||||
[_rowy_ref, value]
|
||||
);
|
||||
|
||||
const handleDelete = (index: number) => {
|
||||
const newValue = [...value];
|
||||
const toBeDeleted = newValue.splice(index, 1);
|
||||
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
|
||||
onChange(newValue);
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
const {
|
||||
loading,
|
||||
progress,
|
||||
handleDelete,
|
||||
uploaderState,
|
||||
localFiles,
|
||||
dropzoneState,
|
||||
} = useFileUpload(_rowy_ref, column.key, {
|
||||
multiple: true,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
});
|
||||
|
||||
const localImages = useMemo(
|
||||
() =>
|
||||
localFiles.map((file) =>
|
||||
assignIn(file, { localURL: URL.createObjectURL(file) })
|
||||
),
|
||||
[localFiles]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!disabled && (
|
||||
<ButtonBase
|
||||
disabled={loading}
|
||||
sx={[
|
||||
fieldSx,
|
||||
{
|
||||
@@ -160,14 +139,22 @@ export default function Image_({
|
||||
? "Drop image here"
|
||||
: "Click to upload or drop image here"}
|
||||
</Typography>
|
||||
<AddIcon sx={{ ml: 1, mr: 2 / 8 }} />
|
||||
{loading ? (
|
||||
<CircularProgressOptical
|
||||
size={20}
|
||||
variant={progress === 0 ? "indeterminate" : "determinate"}
|
||||
value={progress}
|
||||
/>
|
||||
) : (
|
||||
<AddIcon sx={{ ml: 1, mr: 2 / 8 }} />
|
||||
)}
|
||||
</ButtonBase>
|
||||
)}
|
||||
|
||||
<Grid container spacing={1} style={{ marginTop: 0 }}>
|
||||
{Array.isArray(value) &&
|
||||
value.map((image, i) => (
|
||||
<Grid item key={image.downloadURL}>
|
||||
value.map((image: FileValue) => (
|
||||
<Grid item key={image.name}>
|
||||
{disabled ? (
|
||||
<Tooltip title="Open">
|
||||
<ButtonBase
|
||||
@@ -214,7 +201,7 @@ export default function Image_({
|
||||
body: "This image cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: () => handleDelete(i),
|
||||
handleConfirm: () => handleDelete(image),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -237,29 +224,38 @@ export default function Image_({
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{localImage && (
|
||||
<Grid item>
|
||||
<ButtonBase
|
||||
sx={imgSx}
|
||||
style={{ backgroundImage: `url("${localImage}")` }}
|
||||
className="img"
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={overlaySx}
|
||||
{localImages &&
|
||||
localImages.map((image) => (
|
||||
<Grid item key={image.name}>
|
||||
<ButtonBase
|
||||
sx={imgSx}
|
||||
style={{
|
||||
backgroundImage: `url("${image.localURL}")`,
|
||||
}}
|
||||
className="img"
|
||||
>
|
||||
<CircularProgressOptical
|
||||
color="inherit"
|
||||
size={48}
|
||||
variant={progress === 0 ? "indeterminate" : "determinate"}
|
||||
value={progress}
|
||||
/>
|
||||
</Grid>
|
||||
</ButtonBase>
|
||||
</Grid>
|
||||
)}
|
||||
{uploaderState[image.name] && (
|
||||
<Grid
|
||||
container
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={overlaySx}
|
||||
>
|
||||
<CircularProgressOptical
|
||||
color="inherit"
|
||||
size={48}
|
||||
variant={
|
||||
uploaderState[image.name].progress === 0
|
||||
? "indeterminate"
|
||||
: "determinate"
|
||||
}
|
||||
value={uploaderState[image.name].progress}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</ButtonBase>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { IHeavyCellProps } from "@src/components/fields/types";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { findIndex } from "lodash-es";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { assignIn } from "lodash-es";
|
||||
|
||||
import {
|
||||
alpha,
|
||||
@@ -23,15 +21,11 @@ import Thumbnail from "@src/components/Thumbnail";
|
||||
import CircularProgressOptical from "@src/components/CircularProgressOptical";
|
||||
|
||||
import { projectScope, confirmDialogAtom } from "@src/atoms/projectScope";
|
||||
import {
|
||||
tableSchemaAtom,
|
||||
tableScope,
|
||||
updateFieldAtom,
|
||||
} from "@src/atoms/tableScope";
|
||||
import useUploader from "@src/hooks/useFirebaseStorageUploader";
|
||||
import { IMAGE_MIME_TYPES } from "./index";
|
||||
import { tableSchemaAtom, tableScope } from "@src/atoms/tableScope";
|
||||
import { DEFAULT_ROW_HEIGHT } from "@src/components/Table";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import useFileUpload from "@src/components/fields/File/useFileUpload";
|
||||
import { IMAGE_MIME_TYPES } from "./index";
|
||||
|
||||
// MULTIPLE
|
||||
const imgSx = (rowHeight: number) => ({
|
||||
@@ -88,57 +82,27 @@ const deleteImgHoverSx = {
|
||||
export default function Image_({
|
||||
column,
|
||||
value,
|
||||
onSubmit,
|
||||
disabled,
|
||||
docRef,
|
||||
}: IHeavyCellProps) {
|
||||
const confirm = useSetAtom(confirmDialogAtom, projectScope);
|
||||
const updateField = useSetAtom(updateFieldAtom, tableScope);
|
||||
const [tableSchema] = useAtom(tableSchemaAtom, tableScope);
|
||||
const { uploaderState, upload, deleteUpload } = useUploader();
|
||||
const { progress, isLoading } = uploaderState;
|
||||
|
||||
// Store a preview image locally while uploading
|
||||
const [localImage, setLocalImage] = useState<string>("");
|
||||
const { loading, progress, handleDelete, localFiles, dropzoneState } =
|
||||
useFileUpload(docRef, column.key, {
|
||||
multiple: true,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
});
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
const imageFile = acceptedFiles[0];
|
||||
|
||||
if (imageFile) {
|
||||
upload({
|
||||
docRef: docRef! as any,
|
||||
fieldName: column.key,
|
||||
files: [imageFile],
|
||||
previousValue: value,
|
||||
onComplete: (newValue) => {
|
||||
updateField({
|
||||
path: docRef.path,
|
||||
fieldName: column.key,
|
||||
value: newValue,
|
||||
});
|
||||
setLocalImage("");
|
||||
},
|
||||
});
|
||||
setLocalImage(URL.createObjectURL(imageFile));
|
||||
}
|
||||
},
|
||||
[value]
|
||||
const localImages = useMemo(
|
||||
() =>
|
||||
localFiles.map((file) =>
|
||||
assignIn(file, { localURL: URL.createObjectURL(file) })
|
||||
),
|
||||
[localFiles]
|
||||
);
|
||||
|
||||
const handleDelete = (index: number) => () => {
|
||||
const newValue = [...value];
|
||||
const toBeDeleted = newValue.splice(index, 1);
|
||||
toBeDeleted.length && deleteUpload(toBeDeleted[0]);
|
||||
onSubmit(newValue);
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
multiple: false,
|
||||
accept: IMAGE_MIME_TYPES,
|
||||
});
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = dropzoneState;
|
||||
const dropzoneProps = getRootProps();
|
||||
|
||||
const rowHeight = tableSchema.rowHeight ?? DEFAULT_ROW_HEIGHT;
|
||||
@@ -183,17 +147,17 @@ export default function Image_({
|
||||
>
|
||||
<Grid container spacing={0.5} wrap="nowrap">
|
||||
{Array.isArray(value) &&
|
||||
value.map((file: FileValue, i) => (
|
||||
<Grid item key={file.downloadURL}>
|
||||
value.map((image: FileValue) => (
|
||||
<Grid item key={image.downloadURL}>
|
||||
{disabled ? (
|
||||
<Tooltip title="Open">
|
||||
<ButtonBase
|
||||
sx={imgSx(rowHeight)}
|
||||
className="img"
|
||||
onClick={() => window.open(file.downloadURL, "_blank")}
|
||||
onClick={() => window.open(image.downloadURL, "_blank")}
|
||||
>
|
||||
<Thumbnail
|
||||
imageUrl={file.downloadURL}
|
||||
imageUrl={image.downloadURL}
|
||||
size={thumbnailSize}
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
@@ -224,12 +188,12 @@ export default function Image_({
|
||||
body: "This image cannot be recovered after",
|
||||
confirm: "Delete",
|
||||
confirmColor: "error",
|
||||
handleConfirm: handleDelete(i),
|
||||
handleConfirm: () => handleDelete(image),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Thumbnail
|
||||
imageUrl={file.downloadURL}
|
||||
imageUrl={image.downloadURL}
|
||||
size={thumbnailSize}
|
||||
objectFit="contain"
|
||||
sx={thumbnailSx}
|
||||
@@ -249,24 +213,27 @@ export default function Image_({
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
{localImage && (
|
||||
<Grid item>
|
||||
<Box
|
||||
sx={[
|
||||
imgSx(rowHeight),
|
||||
{
|
||||
boxShadow: (theme) =>
|
||||
`0 0 0 1px ${theme.palette.divider} inset`,
|
||||
},
|
||||
]}
|
||||
style={{ backgroundImage: `url("${localImage}")` }}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{localImages &&
|
||||
localImages.map((image) => (
|
||||
<Grid item>
|
||||
<Box
|
||||
sx={[
|
||||
imgSx(rowHeight),
|
||||
{
|
||||
boxShadow: (theme) =>
|
||||
`0 0 0 1px ${theme.palette.divider} inset`,
|
||||
},
|
||||
]}
|
||||
style={{
|
||||
backgroundImage: `url("${image.localURL}")`,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
{!isLoading ? (
|
||||
{!loading ? (
|
||||
!disabled && (
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -16,25 +16,50 @@ import { projectScope } from "@src/atoms/projectScope";
|
||||
import { firebaseStorageAtom } from "@src/sources/ProjectSourceFirebase";
|
||||
import { WIKI_LINKS } from "@src/constants/externalLinks";
|
||||
import { FileValue } from "@src/types/table";
|
||||
import { generateId } from "@src/utils/table";
|
||||
|
||||
export type UploaderState = {
|
||||
export type UploadState = {
|
||||
progress: number;
|
||||
isLoading: boolean;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const initialState: UploaderState = { progress: 0, isLoading: false };
|
||||
export type UploaderState = {
|
||||
[fileName: string]: UploadState;
|
||||
};
|
||||
|
||||
const uploadReducer = (
|
||||
prevState: UploaderState,
|
||||
newProps: Partial<UploaderState>
|
||||
) => ({ ...prevState, ...newProps });
|
||||
action: {
|
||||
type: "reset" | "file_update";
|
||||
data?: { fileName: string; newProps: Partial<UploadState> };
|
||||
}
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "reset":
|
||||
return {};
|
||||
case "file_update":
|
||||
const { fileName, newProps } = action.data!;
|
||||
const nextState = { ...prevState };
|
||||
nextState[fileName] = {
|
||||
...nextState[fileName],
|
||||
...newProps,
|
||||
};
|
||||
return nextState;
|
||||
}
|
||||
};
|
||||
|
||||
export type UploadProps = {
|
||||
docRef: DocumentReference;
|
||||
fieldName: string;
|
||||
files: File[];
|
||||
previousValue?: FileValue[];
|
||||
onComplete?: (values: FileValue[]) => void;
|
||||
onComplete?: ({
|
||||
uploads,
|
||||
failures,
|
||||
}: {
|
||||
uploads: FileValue[];
|
||||
failures: string[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
// TODO: GENERALIZE INTO ATOM
|
||||
@@ -42,125 +67,129 @@ const useFirebaseStorageUploader = () => {
|
||||
const [firebaseStorage] = useAtom(firebaseStorageAtom, projectScope);
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [uploaderState, uploaderDispatch] = useReducer(uploadReducer, {
|
||||
...initialState,
|
||||
});
|
||||
const [uploaderState, uploaderDispatch] = useReducer(uploadReducer, {});
|
||||
|
||||
const upload = ({
|
||||
docRef,
|
||||
fieldName,
|
||||
files,
|
||||
previousValue,
|
||||
onComplete,
|
||||
}: UploadProps) => {
|
||||
uploaderDispatch({ isLoading: true });
|
||||
const upload = ({ docRef, fieldName, files }: UploadProps) => {
|
||||
const uploads = [] as FileValue[];
|
||||
const failures = [] as string[];
|
||||
const isCompleted = () => uploads.length + failures.length === files.length;
|
||||
|
||||
files.forEach((file) => {
|
||||
const storageRef = ref(
|
||||
firebaseStorage,
|
||||
`${docRef.path}/${fieldName}/${file.name}`
|
||||
);
|
||||
const uploadTask = uploadBytesResumable(storageRef, file);
|
||||
return new Promise((resolve) =>
|
||||
files.forEach((file) => {
|
||||
uploaderDispatch({
|
||||
type: "file_update",
|
||||
data: {
|
||||
fileName: file.name,
|
||||
newProps: { loading: true, progress: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
uploadTask.on(
|
||||
// event
|
||||
"state_changed",
|
||||
// observer
|
||||
(snapshot) => {
|
||||
// Get task progress, including the number of bytes uploaded and the total number of bytes to be uploaded
|
||||
const progress =
|
||||
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
|
||||
uploaderDispatch({ progress });
|
||||
console.log("Upload is " + progress + "% done");
|
||||
const storageRef = ref(
|
||||
firebaseStorage,
|
||||
`${docRef.path}/${fieldName}/${generateId()}-${file.name}`
|
||||
);
|
||||
const uploadTask = uploadBytesResumable(storageRef, file, {
|
||||
cacheControl: "public, max-age=31536000",
|
||||
});
|
||||
uploadTask.on(
|
||||
// event
|
||||
"state_changed",
|
||||
// observer
|
||||
(snapshot) => {
|
||||
// Get task progress, including the number of bytes uploaded and the total number of bytes to be uploaded
|
||||
const progress =
|
||||
(snapshot.bytesTransferred / snapshot.totalBytes) * 100;
|
||||
uploaderDispatch({
|
||||
type: "file_update",
|
||||
data: { fileName: file.name, newProps: { progress } },
|
||||
});
|
||||
},
|
||||
|
||||
switch (snapshot.state) {
|
||||
case "paused":
|
||||
console.log("Upload is paused");
|
||||
break;
|
||||
case "running":
|
||||
console.log("Upload is running");
|
||||
break;
|
||||
}
|
||||
},
|
||||
// error – must be any to access error.code
|
||||
(error: any) => {
|
||||
// A full list of error codes is available at
|
||||
// https://firebase.google.com/docs/storage/web/handle-errors
|
||||
const errorAction = {
|
||||
fileName: file.name,
|
||||
newProps: { loading: false } as Partial<UploadState>,
|
||||
};
|
||||
switch (error.code) {
|
||||
case "storage/unknown":
|
||||
// Unknown error occurred, inspect error.serverResponse
|
||||
enqueueSnackbar("Unknown error occurred", { variant: "error" });
|
||||
errorAction.newProps.error = error.serverResponse;
|
||||
break;
|
||||
|
||||
// error – must be any to access error.code
|
||||
(error: any) => {
|
||||
// A full list of error codes is available at
|
||||
// https://firebase.google.com/docs/storage/web/handle-errors
|
||||
switch (error.code) {
|
||||
case "storage/unknown":
|
||||
// Unknown error occurred, inspect error.serverResponse
|
||||
enqueueSnackbar("Unknown error occurred", { variant: "error" });
|
||||
uploaderDispatch({ error: error.serverResponse });
|
||||
break;
|
||||
case "storage/unauthorized":
|
||||
// User doesn't have permission to access the object
|
||||
enqueueSnackbar("You don’t have permissions to upload files", {
|
||||
variant: "error",
|
||||
action: (
|
||||
<Paper elevation={0} sx={{ borderRadius: 1 }}>
|
||||
<Button
|
||||
color="primary"
|
||||
href={
|
||||
WIKI_LINKS.setupRoles +
|
||||
"#write-firebase-storage-security-rules"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Fix
|
||||
<InlineOpenInNewIcon />
|
||||
</Button>
|
||||
</Paper>
|
||||
),
|
||||
});
|
||||
errorAction.newProps.error = error.code;
|
||||
break;
|
||||
|
||||
case "storage/unauthorized":
|
||||
// User doesn't have permission to access the object
|
||||
enqueueSnackbar("You don’t have permissions to upload files", {
|
||||
variant: "error",
|
||||
action: (
|
||||
<Paper elevation={0} sx={{ borderRadius: 1 }}>
|
||||
<Button
|
||||
color="primary"
|
||||
href={
|
||||
WIKI_LINKS.setupRoles +
|
||||
"#write-firebase-storage-security-rules"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Fix
|
||||
<InlineOpenInNewIcon />
|
||||
</Button>
|
||||
</Paper>
|
||||
),
|
||||
});
|
||||
uploaderDispatch({ error: error.code });
|
||||
break;
|
||||
|
||||
case "storage/canceled":
|
||||
// User canceled the upload
|
||||
uploaderDispatch({ error: error.code });
|
||||
break;
|
||||
|
||||
default:
|
||||
uploaderDispatch({ error: error.code });
|
||||
// Unknown error occurred, inspect error.serverResponse
|
||||
break;
|
||||
}
|
||||
|
||||
uploaderDispatch({ isLoading: false });
|
||||
},
|
||||
|
||||
// complete
|
||||
() => {
|
||||
uploaderDispatch({ isLoading: false });
|
||||
|
||||
// Upload completed successfully, now we can get the download URL
|
||||
getDownloadURL(uploadTask.snapshot.ref).then(
|
||||
(downloadURL: string) => {
|
||||
const newValue: FileValue[] = Array.isArray(previousValue)
|
||||
? [...previousValue]
|
||||
: [];
|
||||
|
||||
newValue.push({
|
||||
ref: uploadTask.snapshot.ref.fullPath,
|
||||
downloadURL,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
lastModifiedTS: file.lastModified,
|
||||
});
|
||||
// STore in the document if docRef provided
|
||||
// if (docRef && docRef.update)docRef.update({ [fieldName]: newValue });
|
||||
// Also call callback if it exists
|
||||
// IMPORTANT: SideDrawer form may not update its local values after this
|
||||
// function updates the doc, so you MUST update it manually
|
||||
// using this callback
|
||||
if (onComplete) onComplete(newValue);
|
||||
case "storage/canceled":
|
||||
default:
|
||||
errorAction.newProps.error = error.code;
|
||||
break;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
failures.push(file.name);
|
||||
uploaderDispatch({ type: "file_update", data: errorAction });
|
||||
if (isCompleted()) resolve(true);
|
||||
},
|
||||
|
||||
// complete
|
||||
() => {
|
||||
uploaderDispatch({
|
||||
type: "file_update",
|
||||
data: {
|
||||
fileName: file.name,
|
||||
newProps: { loading: false },
|
||||
},
|
||||
});
|
||||
|
||||
// Upload completed successfully, now we can get the download URL
|
||||
getDownloadURL(uploadTask.snapshot.ref).then(
|
||||
(downloadURL: string) => {
|
||||
// Store in the document if docRef provided
|
||||
// if (docRef && docRef.update)docRef.update({ [fieldName]: newValue });
|
||||
// Also call callback if it exists
|
||||
// IMPORTANT: SideDrawer form may not update its local values after this
|
||||
// function updates the doc, so you MUST update it manually
|
||||
// using this callback
|
||||
const obj = {
|
||||
ref: uploadTask.snapshot.ref.fullPath,
|
||||
downloadURL,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
lastModifiedTS: file.lastModified,
|
||||
};
|
||||
uploads.push(obj);
|
||||
if (isCompleted()) resolve(true);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
})
|
||||
).then(() => {
|
||||
uploaderDispatch({ type: "reset" });
|
||||
return { uploads, failures };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
QueryConstraint,
|
||||
WhereFilterOp,
|
||||
documentId,
|
||||
getCountFromServer
|
||||
} from "firebase/firestore";
|
||||
import { useErrorHandler } from "react-error-boundary";
|
||||
|
||||
@@ -62,6 +63,8 @@ interface IUseFirestoreCollectionWithAtomOptions<T> {
|
||||
deleteDocAtom?: PrimitiveAtom<DeleteCollectionDocFunction | undefined>;
|
||||
/** Update this atom when we’re loading the next page, and if there is a next page available. Uses same scope as `dataScope`. */
|
||||
nextPageAtom?: PrimitiveAtom<NextPageState>;
|
||||
/** Set this atom's value to the number of docs in the collection on each new snapshot */
|
||||
serverDocCountAtom?: PrimitiveAtom<number> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +96,7 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
|
||||
updateDocAtom,
|
||||
deleteDocAtom,
|
||||
nextPageAtom,
|
||||
serverDocCountAtom
|
||||
} = options || {};
|
||||
|
||||
const [firebaseDb] = useAtom(firebaseDbAtom, projectScope);
|
||||
@@ -116,9 +120,10 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
|
||||
void
|
||||
>(nextPageAtom || (dataAtom as any), dataScope);
|
||||
|
||||
const setServerDocCountAtom = useSetAtom(serverDocCountAtom || (dataAtom as any), dataScope)
|
||||
|
||||
// Store if we’re at the last page to prevent a new query from being created
|
||||
const [isLastPage, setIsLastPage] = useState(false);
|
||||
|
||||
// Create the query and memoize using Firestore’s queryEqual
|
||||
const memoizedQuery = useMemoValue(
|
||||
getQuery<T>(
|
||||
@@ -190,6 +195,12 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
|
||||
available: docs.length >= memoizedQuery.limit,
|
||||
}));
|
||||
}
|
||||
// on each new snapshot, use the query to get and set the document count from the server
|
||||
if (serverDocCountAtom) {
|
||||
getCountFromServer(memoizedQuery.unlimitedQuery).then((value) => {
|
||||
setServerDocCountAtom(value.data().count)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) onError(error as FirestoreError);
|
||||
else handleError(error);
|
||||
@@ -221,6 +232,7 @@ export function useFirestoreCollectionWithAtom<T = TableRow>(
|
||||
handleError,
|
||||
nextPageAtom,
|
||||
setNextPageAtom,
|
||||
setServerDocCountAtom
|
||||
]);
|
||||
|
||||
// Create variable for validity of query to pass to useEffect dependencies
|
||||
@@ -313,14 +325,13 @@ const getQuery = <T>(
|
||||
}
|
||||
|
||||
if (!collectionRef) return null;
|
||||
|
||||
const limit = (page + 1) * pageSize;
|
||||
const firestoreFilters = tableFiltersToFirestoreFilters(filters || []);
|
||||
|
||||
return {
|
||||
query: query<T>(
|
||||
collectionRef,
|
||||
queryLimit((page + 1) * pageSize),
|
||||
queryLimit(limit),
|
||||
...firestoreFilters,
|
||||
...(sorts?.map((order) => orderBy(order.key, order.direction)) || [])
|
||||
),
|
||||
@@ -328,6 +339,7 @@ const getQuery = <T>(
|
||||
limit,
|
||||
firestoreFilters,
|
||||
sorts,
|
||||
unlimitedQuery: query<T>(collectionRef, ...firestoreFilters)
|
||||
};
|
||||
} catch (e) {
|
||||
if (onError) onError(e as FirestoreError);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
_updateRowDbAtom,
|
||||
_deleteRowDbAtom,
|
||||
tableNextPageAtom,
|
||||
serverDocCountAtom
|
||||
} from "@src/atoms/tableScope";
|
||||
import useFirestoreDocWithAtom from "@src/hooks/useFirestoreDocWithAtom";
|
||||
import useFirestoreCollectionWithAtom from "@src/hooks/useFirestoreCollectionWithAtom";
|
||||
@@ -77,6 +78,7 @@ export const TableSourceFirestore = memo(function TableSourceFirestore() {
|
||||
updateDocAtom: _updateRowDbAtom,
|
||||
deleteDocAtom: _deleteRowDbAtom,
|
||||
nextPageAtom: tableNextPageAtom,
|
||||
serverDocCountAtom: serverDocCountAtom
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user