@onehat/ui
Version:
Base UI for OneHat apps
536 lines (495 loc) • 14.5 kB
JavaScript
import { useState, useEffect, useRef, } from 'react';
import {
Box,
HStack,
Pressable,
Spinner,
Text,
VStack,
} from '@project-components/Gluestack';
import Button from '../../Components/Buttons/Button';
import {
CURRENT_MODE,
UI_MODE_WEB,
UI_MODE_NATIVE,
} from '../../Constants/UiModes.js';
import UiGlobals from '../../UiGlobals.js';
import {
FILE_MODE_IMAGE,
FILE_MODE_FILE,
} from '../../Constants/File.js';
import { Avatar, Dropzone, FileMosaic, FileCard, FileInputButton, } from "@files-ui/react";
import inArray from '../../Functions/inArray.js';
import IconButton from '../../Components/Buttons/IconButton.js';
import Xmark from '../../Components/Icons/Xmark.js';
import Eye from '../../Components/Icons/Eye.js';
import ChevronLeft from '../../Components/Icons/ChevronLeft.js';
import ChevronRight from '../../Components/Icons/ChevronRight.js';
import withAlert from '../../Components/Hoc/withAlert.js';
import withComponent from '../../Components/Hoc/withComponent.js';
import withData from '../../Components/Hoc/withData.js';
import CenterBox from '../../Components/Layout/CenterBox.js';
import downloadInBackground from '../../Functions/downloadInBackground.js';
import downloadWithFetch from '../../Functions/downloadWithFetch.js';
import useForceUpdate from '../../Hooks/useForceUpdate.js';
import _ from 'lodash';
const
EXPANDED_MAX = 100,
COLLAPSED_MAX = 4,
isPwa = typeof window !== 'undefined' && !!window?.navigator?.standalone;
function FileCardCustom(props) {
const {
id,
name: filename,
type: mimetype,
onDelete,
onSee,
downloadUrl,
uploadStatus,
} = props,
isDownloading = uploadStatus && inArray(uploadStatus, ['preparing', 'uploading', 'success']),
isPdf = mimetype === 'application/pdf';
return <Pressable
onPress={() => {
downloadInBackground(downloadUrl);
}}
className="px-3 py-1 items-center flex-row rounded-[5px] border border-primary.700"
>
{isDownloading && <Spinner className="mr-2" />}
{onSee && isPdf && <IconButton mr={1} icon={Eye} onPress={() => onSee(null, id)} />}
<Text>{filename}</Text>
{onDelete && <IconButton ml={1} icon={Xmark} onPress={() => onDelete(id)} />}
</Pressable>;
}
// Note this component uploads only one file per server request---
// it doesn't upload multiple files simultaneously.
function AttachmentsElement(props) {
if (CURRENT_MODE !== UI_MODE_WEB) {
throw new Error('Not yet implemented except for web.');
}
const {
canCrud = true,
_dropZone = {},
_fileMosaic = {},
useFileMosaic = true,
accept, // 'image/*'
maxFiles = null,
disabled = false,
clickable = true,
confirmBeforeDelete = false,
extraUploadData = {},
expandedMax = EXPANDED_MAX,
collapsedMax = COLLAPSED_MAX,
autoUpload = true,
onAfterDropzoneChange, // fn, should return true if it mutated the files array
onUpload,
onDelete,
// withComponent
self,
// parentContainer
selectorSelected,
selectorSelectedField = 'id',
// withData
Repository,
// withAlert
showModal,
updateModalBody,
alert,
confirm,
} = props,
styles = UiGlobals.styles,
model = _.isArray(selectorSelected) && selectorSelected[0] ? selectorSelected[0].repository?.name : selectorSelected?.repository?.name,
modelidCalc = _.isArray(selectorSelected) ? _.map(selectorSelected, (entity) => entity[selectorSelectedField]) : selectorSelected?.[selectorSelectedField],
modelid = useRef(modelidCalc),
forceUpdate = useForceUpdate(),
[isReady, setIsReady] = useState(false),
[isUploading, setIsUploading] = useState(false),
[showAll, setShowAll] = useState(false),
setFilesRaw = useRef([]),
setFiles = (files) => {
setFilesRaw.current = files;
forceUpdate();
},
getFiles = () => {
return setFilesRaw.current;
},
buildFiles = () => {
const files = _.map(Repository.entities, (entity) => {
return {
id: entity.id, // string | number The identifier of the file
// file: null, // File The file object obtained from client drop or selection
name: entity.attachments__filename, // string The name of the file
type: entity.attachments__mimetype, // string The file mime type.
size: entity.attachments__size, // number The size of the file in bytes.
// valid: null, // boolean If present, it will show a valid or rejected message ("valid", "denied"). By default valid is undefined.
// errors: null, // string[] The list of errors according to the validation criteria or the result of the given custom validation function.
// uploadStatus: null, // UPLOADSTATUS The current upload status. (e.g. "uploading").
// uploadMessage: null, // string A message that shows the result of the upload process.
imageUrl: entity.attachments__uri, // string A string representation or web url of the image that will be set to the "src" prop of an <img/> tag. If given, the component will use this image source instead of reading the image file.
downloadUrl: entity.attachments__uri, // string The url to be used to perform a GET request in order to download the file. If defined, the download icon will be shown.
// progress: null, // number The current percentage of upload progress. This value will have a higher priority over the upload progress value calculated inside the component.
// extraUploadData: null, // Record<string, any> The additional data that will be sent to the server when files are uploaded individually
// extraData: null, // Object Any kind of extra data that could be needed.
// serverResponse: null, // ServerResponse The upload response from server.
// xhr: null, // XMLHttpRequest A reference to the XHR object that allows the upload, progress and abort events.
};
});
setFiles(files);
},
clearFiles = () => {
setFiles([]);
},
toggleShowAll = () => {
setShowAll(!showAll);
},
onDropzoneChange = async (files) => {
if (!files.length) {
alert('No files accepted. Perhaps they were too large or the wrong file type?');
return;
}
setFiles(files);
_.each(files, (file) => {
file.extraUploadData = {
model,
modelid: modelid.current,
...extraUploadData,
};
});
if (onAfterDropzoneChange) {
const isChanged = await onAfterDropzoneChange(files);
if (isChanged) {
forceUpdate();
}
}
},
onUploadStart = (files) => {
setIsUploading(true);
},
onUploadFinish = (files) => {
let isDoneUploading = true,
isError = false;
_.each(files, (file) => {
if (!file.xhr || file.xhr.status !== 200) {
isDoneUploading = false;
return false; // break
}
});
if (isDoneUploading) {
_.each(files, (file) => {
if (file.uploadStatus === 'error') {
isError = true;
const msg = file.serverResponse?.payload || 'An error occurred';
alert(msg);
return false;
}
});
if (!isError) {
setIsUploading(false);
Repository.reload();
if (onUpload) {
onUpload(files);
}
}
}
},
onFileDelete = (id) => {
const
files = getFiles(),
file = _.find(files, { id });
if (confirmBeforeDelete) {
confirm('Are you sure you want to delete the file "' + file.name + '"?', () => doDelete(id));
} else {
doDelete(id);
}
},
onDownload = (id, url) => {
if (isPwa) {
// This doesn't work because iOS doesn't allow you to open another window within a PWA.
// downloadWithFetch(url);
alert('Files cannot be downloaded and viewed within an iOS PWA. Please use the Safari browser instead.');
} else {
downloadInBackground(url);
}
},
buildModalBody = (url, id) => {
const files = getFiles();
// This method was abstracted out so showModal/onPrev/onNext can all use it.
// url comes from FileMosaic, which passes in imageUrl,
// whereas FileCardCustom passes in id.
function findFile(url, id) {
if (id) {
return _.find(files, { id });
}
return _.find(files, (file) => file.imageUrl === url);
}
function findPrevFile(url, id) {
const
currentFile = findFile(url, id),
currentIx = _.findIndex(files, currentFile);
if (currentIx > 0) {
return files[currentIx - 1];
}
return null;
}
function findNextFile(url, id) {
const
currentFile = findFile(url, id),
currentIx = _.findIndex(files, currentFile);
if (currentIx < files.length - 1) {
return files[currentIx + 1];
}
return null;
}
const
prevFile = findPrevFile(url, id),
isPrevDisabled = !prevFile,
nextFile = findNextFile(url, id),
isNextDisabled = !nextFile,
onPrev = () => {
const { imageUrl, id } = prevFile;
updateModalBody(buildModalBody(imageUrl, id));
},
onNext = () => {
const { imageUrl, id } = nextFile;
updateModalBody(buildModalBody(imageUrl, id));
};
let isPdf = false,
body = null;
if (id) {
const file = _.find(files, { id });
url = file.imageUrl;
isPdf = true;
} else if (url?.match(/\.pdf$/)) {
isPdf = true;
}
if (isPdf) {
body = <iframe
src={url}
className="w-full h-full"
/>;
} else {
body = <CenterBox className="w-full h-full">
<img src={url} />
</CenterBox>;
}
return <HStack
className="w-full h-full"
>
<IconButton
onPress={onPrev}
className="Lightbox-prevBtn h-full w-[50px]"
icon={ChevronLeft}
isDisabled={isPrevDisabled}
/>
{body}
<IconButton
onPress={onNext}
className="Lightbox-prevBtn h-full w-[50px]"
icon={ChevronRight}
isDisabled={isNextDisabled}
/>
</HStack>;
},
onViewLightbox = (url, id) => {
if (!url && !id) {
alert('Cannot view lightbox until image is uploaded.');
return;
}
showModal({
title: 'Lightbox',
body: buildModalBody(url, id),
canClose: true,
includeCancel: true,
w: 1920,
h: 1080,
});
},
doDelete = (id) => {
const
files = getFiles(),
file = Repository.getById(id);
if (file) {
// if the file exists in the repository, delete it there
Repository.deleteById(id);
Repository.save();
} else {
// simply remove it from the files array
const newFiles = [];
_.each(files, (file) => {
if (file.id !== id) {
newFiles.push(file);
}
});
setFiles(newFiles);
}
if (onDelete) {
onDelete(id);
}
};
if (!_.isEqual(modelidCalc, modelid.current)) {
modelid.current = modelidCalc;
}
useEffect(() => {
if (!model) {
return () => {};
}
(async () => {
if (!_.isArray(modelid.current)) {
// Load Repository
const filters = [
{
name: 'model',
value: model,
},
{
name: 'modelid',
value: modelid.current,
},
];
if (accept) {
let name,
mimetypes;
if (_.isString(accept)) {
if (accept.match(/,/)) {
name = 'mimetype IN';
mimetypes = accept.split(',');
} else {
name = 'mimetype LIKE';
mimetypes = accept.replace('*', '%');
}
} else if (_.isArray(accept)) {
name = 'mimetype IN';
mimetypes = accept;
}
filters.push({
name,
value: mimetypes,
});
}
Repository.filter(filters);
Repository.setPageSize(showAll ? expandedMax : collapsedMax);
await Repository.load();
buildFiles();
} else {
clearFiles();
}
if (!isReady) {
setIsReady(true);
}
})();
Repository.on('load', buildFiles);
return () => {
Repository.off('load', buildFiles);
};
}, [model, modelid.current, showAll]);
if (!isReady) {
return null;
}
if (self) {
self.getFiles = getFiles;
self.setFiles = setFiles;
self.clearFiles = clearFiles;
}
if (canCrud) {
_fileMosaic.onDelete = onFileDelete;
}
let className = `
AttachmentsElement
w-full
h-full
p-1
rounded-[5px]
`;
if (props.className) {
className += ' ' + props.className;
}
const files = getFiles();
let content = <VStack className={className}>
<HStack className="AttachmentsElement-HStack flex-wrap">
{files.length === 0 && <Text className="text-grey-600 italic">No files</Text>}
{files.map((file) => {
let seeProps = {};
if (file.type && (file.type.match(/^image\//) || file.type === 'application/pdf')) {
seeProps = {
onSee: onViewLightbox,
};
}
return <Box
key={file.id}
className="mr-2"
>
{useFileMosaic &&
<FileMosaic
{...file}
backgroundBlurImage={false}
onDownload={onDownload}
{..._fileMosaic}
{...seeProps}
/>}
{!useFileMosaic &&
<FileCardCustom
{...file}
backgroundBlurImage={false}
{..._fileMosaic}
{...seeProps}
/>}
</Box>;
})}
</HStack>
{Repository.total <= collapsedMax ? null :
<Button
onPress={toggleShowAll}
className="AttachmentsElement-toggleShowAll mt-2"
text={'Show ' + (showAll ? ' Less' : ' All ' + Repository.total)}
_text={{
className: `
text-grey-600
italic
text-left
w-full
`,
}}
variant="outline"
/>}
</VStack>;
if (canCrud) {
content = <Dropzone
value={files}
onChange={onDropzoneChange}
accept={accept}
maxFiles={maxFiles}
maxFileSize={styles.ATTACHMENTS_MAX_FILESIZE}
autoClean={true}
uploadConfig={{
url: Repository.api.baseURL + Repository.name + '/uploadAttachment',
method: 'POST',
headers: Repository.headers,
autoUpload,
}}
headerConfig={{
deleteFiles: false,
}}
onUploadStart={onUploadStart}
onUploadFinish={onUploadFinish}
background={styles.ATTACHMENTS_BG}
color={styles.ATTACHMENTS_COLOR}
minHeight={150}
footer={false}
clickable={clickable}
{..._dropZone}
>
{content}
</Dropzone>;
}
return content;
}
function withAdditionalProps(WrappedComponent) {
return (props) => {
return <WrappedComponent
model="Attachments"
uniqueRepository={true}
{...props}
/>;
};
}
export default withComponent(withAdditionalProps(withAlert(withData(AttachmentsElement))));