First commit:

This commit is contained in:
2024-09-18 17:58:25 +05:30
parent fc9e28da8d
commit 36fc820ebe
36 changed files with 869 additions and 19986 deletions

View File

@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Redux App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

19327
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,17 @@
{
"name": "@armco/react-vite-rtk-template",
"name": "@armco/uploader",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"start": "vite",
"build": "tsc && vite build",
"generate": "plop",
"atom": "plop atom",
"molecule": "plop molecule",
"component": "plop component",
"page": "plop page",
"preview": "vite preview",
"test": "vitest",
"format": "prettier --write .",
"lint": "eslint .",
"type-check": "tsc"
"lint": "eslint ."
},
"dependencies": {
"@reduxjs/toolkit": "^1.8.1",
"react": "^18.2.0",
"react-app-polyfill": "^3.0.0",
"react-dev-utils": "^12.0.1",
"react-dom": "^18.2.0",
"react-redux": "^8.0.1",
"react-router-dom": "^6.13.0"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@testing-library/dom": "^9.2.0",
@@ -40,7 +26,6 @@
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-prettier": "^4.2.1",
"jsdom": "^21.1.0",
"plop": "^3.1.2",
"prettier": "^2.7.1",
"prettier-config-nick": "^1.0.2",
"sass": "^1.63.4",
@@ -48,6 +33,10 @@
"vite": "^4.0.0",
"vitest": "^0.30.1"
},
"peerDependencies": {
"react": ">16.8.0",
"react-dom": ">16.8.0"
},
"eslintConfig": {
"extends": [
"react-app",
@@ -65,7 +54,7 @@
"main": "index.tsx",
"repository": {
"type": "git",
"url": "git+https://github.com/ReStruct-Corporate-Advantage/.git"
"url": "git+https://github.com/ReStruct-Corporate-Advantage/uploader.git"
},
"keywords": [
"components",
@@ -75,7 +64,7 @@
],
"license": "ISC",
"bugs": {
"url": "https://github.com/ReStruct-Corporate-Advantage/react-vite-rtk-template/issues"
"url": "https://github.com/ReStruct-Corporate-Advantage/uploader/issues"
},
"homepage": "https://github.com/ReStruct-Corporate-Advantage/react-vite-rtk-template#readme"
"homepage": "https://github.com/ReStruct-Corporate-Advantage/uploader#readme"
}

View File

@@ -1,3 +0,0 @@
.c-{{pascalCase name}} {
}

View File

@@ -1,8 +0,0 @@
import React from "react"
import {{pascalCase name}} from "./{{pascalCase name}}"
describe("{{pascalCase name}}", () => {
it("renders without error", () => {
})
})

View File

@@ -1,10 +0,0 @@
import React from "react"
import "./{{pascalCase name}}.component.scss"
interface {{pascalCase name}}Props {}
const {{pascalCase name}} = (props: {{pascalCase name}}Props): JSX.Element => {
return <div className="c-{{pascalCase name}}">In Component {{pascalCase name}}</div>
}
export default {{pascalCase name}}

View File

@@ -1,3 +0,0 @@
import {{pascalCase name}} from "./{{pascalCase name}}.jsx"
export default {{pascalCase name}}

View File

@@ -1,3 +0,0 @@
.c-{{pascalCase name}} {
}

View File

@@ -1,18 +0,0 @@
import { createSlice } from "@reduxjs/toolkit"
export interface {{pascalCase name}}State {}
const initialState: {{pascalCase name}}State = {}
export const {{snakeCase name}}Slice = createSlice({
name: "{{snakeCase name}}",
initialState,
reducers: {
increment: (state) => {},
},
extraReducers: (builder) => {},
})
export const { increment } = {{snakeCase name}}Slice.actions
export default {{snakeCase name}}Slice.reducer

View File

@@ -1,8 +0,0 @@
import React from "react"
import {{pascalCase name}} from "./{{pascalCase name}}"
describe("{{pascalCase name}}", () => {
it("renders without error", () => {
})
})

View File

@@ -1,10 +0,0 @@
import React from "react"
import "./{{pascalCase name}}.module.scss"
interface {{pascalCase name}}Props {}
const {{pascalCase name}} = (props: {{pascalCase name}}Props): JSX.Element => {
return <div className="c-{{pascalCase name}}">In Page {{pascalCase name}}</div>
}
export default {{pascalCase name}}

View File

@@ -1,3 +0,0 @@
import {{pascalCase name}} from "./{{pascalCase name}}.jsx"
export default {{pascalCase name}}

View File

@@ -1,5 +0,0 @@
/* PLOP_INJECT_IMPORT */
export {
/* PLOP_INJECT_EXPORT */
}

View File

@@ -1,207 +0,0 @@
module.exports = (plop) => {
plop.setGenerator("component", {
description: "Create a component",
prompts: [
{
type: "input",
name: "name",
message: "What is your component name?",
},
],
actions: [
{
type: "add",
path: "src/app/components/{{pascalCase name}}/{{pascalCase name}}.tsx",
templateFile: "plop-templates/Component/Component.tsx.hbs",
},
{
type: "add",
path: "src/app/components/{{pascalCase name}}/{{pascalCase name}}.test.ts",
templateFile: "plop-templates/Component/Component.test.ts.hbs",
},
{
type: "add",
path: "src/app/components/{{pascalCase name}}/{{pascalCase name}}.component.scss",
templateFile: "plop-templates/Component/Component.component.scss.hbs",
},
{
type: "add",
path: "src/app/components/{{pascalCase name}}/index.ts",
templateFile: "plop-templates/Component/index.ts.hbs",
},
{
type: "add",
path: "src/app/components/index.ts",
templateFile: "plop-templates/injectable-index.ts.hbs",
skipIfExists: true,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_IMPORT */`,
template: `import {{pascalCase name}} from "./{{pascalCase name}}"`,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_EXPORT */`,
template: `\t{{pascalCase name}},`,
},
],
})
plop.setGenerator("atom", {
description: "Create a component",
prompts: [
{
type: "input",
name: "name",
message: "What is your component name?",
},
],
actions: [
{
type: "add",
path: "src/app/components/atoms/{{pascalCase name}}/{{pascalCase name}}.tsx",
templateFile: "plop-templates/Component/Component.tsx.hbs",
},
{
type: "add",
path: "src/app/components/atoms/{{pascalCase name}}/{{pascalCase name}}.test.ts",
templateFile: "plop-templates/Component/Component.test.ts.hbs",
},
{
type: "add",
path: "src/app/components/atoms/{{pascalCase name}}/{{pascalCase name}}.component.scss",
templateFile: "plop-templates/Component/Component.component.scss.hbs",
},
{
type: "add",
path: "src/app/components/atoms/{{pascalCase name}}/index.ts",
templateFile: "plop-templates/Component/index.ts.hbs",
},
{
type: "add",
path: "src/app/components/index.ts",
templateFile: "plop-templates/injectable-index.ts.hbs",
skipIfExists: true,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_IMPORT */`,
template: `import {{pascalCase name}} from "./atoms/{{pascalCase name}}"`,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_EXPORT */`,
template: `\t{{pascalCase name}},`,
},
],
})
plop.setGenerator("molecule", {
description: "Create a rich component",
prompts: [
{
type: "input",
name: "name",
message: "What is your component name?",
},
],
actions: [
{
type: "add",
path: "src/app/components/molecules/{{pascalCase name}}/{{pascalCase name}}.tsx",
templateFile: "plop-templates/Component/Component.tsx.hbs",
},
{
type: "add",
path: "src/app/components/molecules/{{pascalCase name}}/{{pascalCase name}}.test.ts",
templateFile: "plop-templates/Component/Component.test.ts.hbs",
},
{
type: "add",
path: "src/app/components/molecules/{{pascalCase name}}/{{pascalCase name}}.component.scss",
templateFile: "plop-templates/Component/Component.component.scss.hbs",
},
{
type: "add",
path: "src/app/components/molecules/{{pascalCase name}}/index.ts",
templateFile: "plop-templates/Component/index.ts.hbs",
},
{
type: "add",
path: "src/app/components/index.ts",
templateFile: "plop-templates/injectable-index.ts.hbs",
skipIfExists: true,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_IMPORT */`,
template: `import {{pascalCase name}} from "./molecules/{{pascalCase name}}"`,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_EXPORT */`,
template: `\t{{pascalCase name}},`,
},
],
})
plop.setGenerator("page", {
description: "Create a page",
prompts: [
{
type: "input",
name: "name",
message: "What is your page name?",
},
],
actions: [
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/{{pascalCase name}}.tsx",
templateFile: "plop-templates/Page/Page.tsx.hbs",
},
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/{{pascalCase name}}.test.ts",
templateFile: "plop-templates/Page/Page.test.ts.hbs",
},
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/{{pascalCase name}}.module.scss",
templateFile: "plop-templates/Page/Page.module.scss.hbs",
},
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/index.ts",
templateFile: "plop-templates/Page/index.ts.hbs",
},
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/{{pascalCase name}}.slice.ts",
templateFile: "plop-templates/Page/Page.slice.ts.hbs",
},
{
type: "add",
path: "src/app/pages/index.ts",
templateFile: "plop-templates/injectable-index.ts.hbs",
skipIfExists: true,
},
{
type: "append",
path: "src/app/pages/index.ts",
pattern: `/* PLOP_INJECT_IMPORT */`,
template: `import {{pascalCase name}} from "./{{pascalCase name}}"`,
},
{
type: "append",
path: "src/app/pages/index.ts",
pattern: `/* PLOP_INJECT_EXPORT */`,
template: `\t{{pascalCase name}},`,
},
],
})
}

38
src/Uploader.component.scss Executable file
View File

@@ -0,0 +1,38 @@
.ar-Uploader {
.ar-Uploader__file-uploader-progress-bar {
transition: max-height 0.3s, opacity 0.3s, padding 0.3s, margin-bottom 0.3s, background-color 0.3s;
opacity: 0;
max-height: 0;
&.show {
opacity: 1;
max-height: 4rem;
}
&:hover {
background-color: var(--ar-bg-hover-6);
}
}
.ar-Button.loading .pre {
animation: rotating 0.5s linear infinite;
}
&:not(.compact):not(.image-rect):not(.image-circle) .ar-Uploader__file-input {
transition: border 0.3s ease-in-out;
&:has(.ar-Avatar.FAILED) {
border-color: var(--ar-color-danger-soft);
}
border: 1px dashed lightgrey;
border-radius: 0.5rem;
padding: 0.5rem;
background-color: var(--ar-bg-base);
}
.ar-Uploader__upload-prompt-text {
color: var(--ar-color-obscure-4);
}
.ar-Uploader__upload-message {
color: var(--ar-color-obscure-2);
}
}

605
src/Uploader.tsx Executable file
View File

@@ -0,0 +1,605 @@
import {
ChangeEvent,
MouseEvent,
memo,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { v4 as uuid } from "uuid"
import { NativeTypes } from "react-dnd-html5-backend"
import {
ArAlertType,
ArButtonVariants,
ArPopoverSlots,
ArProgress,
ArSizes,
FunctionType,
ImageProps,
UploaderProps,
UploadResult,
UploadResults,
} from "@armco/types"
import {
useAppDispatch,
useUpload,
notify,
Droppable,
Helper,
Button,
Text,
LoadableIcon,
ProgressIndicator,
Tooltip,
Avatar,
Network,
API_CONFIG,
} from "../.."
import "./Uploader.component.scss"
const UploadedEntry = ({
clear,
demo,
disabled,
result,
}: {
clear: FunctionType
demo: boolean
disabled: boolean
result: UploadResult
}) => {
const [hovered, setHovered] = useState<boolean>()
const [displayed, display] = useState<boolean>()
useEffect(() => {
display(true)
}, [])
const isImage = result.type === "image"
const progressRender = (
<ProgressIndicator
label={result.file.name}
preIcon={isImage ? "" : "im.ImAttachment"}
progress={result.progress}
slot={ArPopoverSlots.ANCHOR}
rightEndContent={
(hovered || result.status === ArProgress.FAILED) && (
<LoadableIcon
classes={disabled ? "" : "cursor-pointer"}
color={result.status === ArProgress.FAILED ? "#ff4d4f" : "grey"}
icon="ri.RiDeleteBin6Fill"
onClick={() => {
if (!disabled) {
display(false)
setTimeout(clear, 300)
}
}}
/>
)
}
status={result.status}
thumbnailRenderer={() => {
if (isImage) {
return (
<Avatar
classes="me-3"
item={result}
size={ArSizes.XSMALL}
variant="circle"
viewOnly
/>
)
}
return null
}}
/>
)
const extractError = (errorObj: any) => {
if (typeof errorObj === "string") {
return errorObj
}
return errorObj.error || errorObj.message || "Server Error"
}
return (
<div
className={`ar-Uploader__file-uploader-progress-bar position-relative ${
displayed ? " show p-1 border-radius l1 mb-1 cursor-pointer" : " px-1"
} ${result.status}`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{result.status === ArProgress.FAILED ? (
<Tooltip demo={demo}>
{progressRender}
<span slot={ArPopoverSlots.POPOVER}>
{extractError(result.error)}
</span>
</Tooltip>
) : (
progressRender
)}
</div>
)
}
const Uploader = memo(
(props: UploaderProps): JSX.Element => {
const {
acceptedFileTypes,
acceptedFileCount,
afterUpload,
allowMultiple,
beforeUpload,
demo,
disabled,
uploadEntryRenderer,
errorMessage: externalErrorMessage = "Failed to upload",
endpoint,
fileSizeLimit = "5mb",
files,
handler,
message,
onFileSelected,
avatarSize = ArSizes.REGULAR,
variant = "compact",
theme,
updateEndpoint,
uploadSequential,
uploadButtonText = "Upload",
uploadingButtonText = "Uploading...",
} = props
const inputRef = useRef<HTMLInputElement>(null)
const [localResults, setLocalResults] = useState<UploadResults>()
const [localAllowMultiple, setLocalAllowMultiple] = useState<boolean>()
const [localAcceptedFileTypes, setLocalAcceptedFileTypes] = useState<
string | Array<string>
>()
const [errorMessage, setErrorMessage] = useState<string>()
const externalFiles = useMemo(
() =>
Boolean(localAllowMultiple)
? acceptedFileCount !== undefined
? files?.slice(0, acceptedFileCount)
: files
: files?.slice(0, 1),
[files],
)
const { clear, upload, results } = useUpload(
endpoint || handler || "",
uploadSequential,
externalFiles,
)
const dispatch = useAppDispatch()
const isAvatar = variant === "image-circle" || variant === "image-rect"
useEffect(() => {
setLocalResults(results)
if ((!results || Object.keys(results).length === 0) && inputRef.current) {
inputRef.current.value = ""
}
let hasError
localResults &&
Object.keys(localResults).length > 0 &&
Object.keys(localResults).every((resultKey) => {
const result = localResults[resultKey]
hasError ||= result.status === ArProgress.FAILED
return result.status === ArProgress.FAILED
})
hasError && setErrorMessage(externalErrorMessage)
}, [results])
useEffect(() => {
setLocalAllowMultiple(allowMultiple)
}, [allowMultiple])
useEffect(() => {
setLocalAcceptedFileTypes(acceptedFileTypes)
}, [acceptedFileTypes])
useEffect(() => {
if (isAvatar) {
setLocalAllowMultiple(false)
setLocalAcceptedFileTypes([
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
])
}
}, [isAvatar])
const checkAndUpload = (files: Array<File>) => {
const sizeLimit = Helper.convertToBytes(fileSizeLimit)
let maxFileCount
try {
maxFileCount =
acceptedFileCount !== undefined &&
(typeof acceptedFileCount === "number"
? acceptedFileCount
: parseInt(acceptedFileCount))
} catch (e) {
console.warn(
"Incorrect prop acceptedFileCount supplied, should be a numeric value",
)
}
if (
typeof maxFileCount === "number" &&
files &&
files.length + Object.keys(localResults || {}).length > maxFileCount
) {
setErrorMessage("Exceeded allowed number of files.")
dispatch(
notify({
message: "You've selected more then allowed number of files",
type: ArAlertType.WARNING,
uid: uuid(),
}),
)
} else if (files.findIndex((file) => file.size > sizeLimit) > -1) {
const message = `${
localAllowMultiple
? "One or more files exceed "
: "Selected file exceeds "
}specified size limit of ${fileSizeLimit}`
setErrorMessage(message)
dispatch(
notify({
message,
type: ArAlertType.WARNING,
uid: uuid(),
}),
)
} else if (files && files.length > 0) {
upload(files, afterUpload, localAllowMultiple)
onFileSelected && onFileSelected(files)
}
}
const handleFileChange = (event: ChangeEvent<HTMLInputElement> | any) => {
setErrorMessage("")
const files: Array<File> = Array.from(
(event.target || event.dataTransfer)?.files,
)
if (!beforeUpload || beforeUpload(files, () => checkAndUpload(files))) {
checkAndUpload(files)
}
}
const isLoading =
localResults &&
Object.keys(localResults).findIndex((resultKey) => {
const result = localResults[resultKey]
return result.status === ArProgress.IN_PROGRESS
}) > -1
const allCompleted = !!(
localResults &&
Object.keys(localResults).length > 0 &&
Object.keys(localResults).every((resultKey) => {
const result = localResults[resultKey]
return (
result.status === ArProgress.COMPLETED ||
result.status === ArProgress.FAILED
)
})
)
const onlyResult =
localResults && localResults[Object.keys(localResults)[0]]
const onImageChange = (imageProps: Partial<ImageProps>) => {
if (localResults && onlyResult) {
const fileKey = Object.keys(localResults).find((key) =>
key.startsWith(onlyResult.file.name),
)
if (fileKey) {
const resultClone = { ...localResults[fileKey] }
resultClone.imageProps = imageProps
localResults[fileKey] = resultClone
setLocalResults({ ...localResults })
}
}
}
const onImageCommit = (imageProps: Partial<ImageProps>) => {
if (localResults && onlyResult) {
const fileKey = Object.keys(localResults).find((key) =>
key.startsWith(onlyResult.file.name),
)
if (fileKey) {
const resultClone = localResults[fileKey]
let url = updateEndpoint ? updateEndpoint : endpoint
if (url) {
const environment = process.env.NODE_ENV || "development"
const host = url.startsWith("http")
? ""
: API_CONFIG.STATIC_HOST[environment as keyof object]
url = host + url
Network.put(url, resultClone).then((res) => {
if (res.status === 200) {
dispatch(
notify({
message: "Image updaetd successfully!",
type: ArAlertType.SUCCESS,
uid: uuid(),
}),
)
} else {
dispatch(
notify({
message: "Failed to update image!",
type: ArAlertType.ERROR,
uid: uuid(),
}),
)
}
})
} else {
dispatch(
notify({
message: "Missing image update URL",
type: ArAlertType.WARNING,
uid: uuid(),
}),
)
}
}
}
}
const inputRender = (
<div
className={`ar-Uploader__file-input flex-v-center${
isLoading ? " disabled" : ""
}${
variant !== "compact" && !isLoading && !disabled
? " cursor-pointer"
: ""
}${
variant === "large"
? " flex-h-center flex-column p-3"
: " justify-content-between"
}${variant === "image-circle" ? " border-radius-50" : ""}`}
onClick={() =>
variant !== "compact" &&
!isLoading &&
!disabled &&
inputRef.current?.click()
}
>
{isAvatar ? (
<Avatar
clearImage={clear}
demo={demo}
variant={variant === "image-circle" ? "circle" : "rect"}
item={onlyResult}
onChange={onImageChange}
onCommit={onImageCommit}
size={avatarSize}
uploadButtonText={uploadButtonText}
uploadingButtonText={uploadingButtonText}
/>
) : (
<>
<div className="ar-Uploader__file-input__prompt d-flex flex-column">
<div
className={`d-inline-flex ${
variant === "large"
? " flex-center flex-column"
: "flex-v-center"
}`}
>
{variant === "compact" ? (
<>
<Button
classes={isLoading ? "loading" : ""}
content={
isLoading ? uploadingButtonText : uploadButtonText
}
preIcon={
isLoading
? "ri.RiLoader5Fill"
: allCompleted
? errorMessage
? "fa.FaExclamation"
: "fa.FaCheck"
: "md.MdOutlineFileUpload"
}
onClick={() => inputRef.current?.click()}
theme={theme}
disabled={disabled || isLoading}
/>
{localResults &&
Object.keys(localResults).length > 0 &&
variant === "compact" && (
<>
{localAllowMultiple ? (
<span className="fw-bold flex-v-center ms-3">
{`(${Object.keys(localResults).length} file${
Object.keys(localResults).length > 1 ? "s" : ""
})`}
</span>
) : (
<Text
classes="fw-bold flex-v-center ms-3 text-decoration-underline"
descriptor={{
id: "temp-text-id",
order: 0,
name: "Text",
text: localResults[Object.keys(localResults)[0]]
.file.name,
chunks: {},
}}
overflowEllipsis
overflowTooltip
/>
)}
{
<LoadableIcon
classes={`ms-2 ${
disabled ? "" : "cursor-pointer"
}`}
color="red"
icon="io5.IoClose"
onClick={() => {
if (disabled) {
clear()
if (inputRef.current) {
inputRef.current.value = ""
}
}
}}
/>
}
</>
)}
</>
) : (
<>
<LoadableIcon
classes={variant === "large" ? "mb-2" : "me-3"}
icon="io5.IoFileTrayOutline"
size={variant === "large" ? "3rem" : "1rem"}
color="#1677ff"
/>
<span
className={`ar-Uploader__upload-prompt-text${
variant === "large" ? " h5 mb-0" : ""
}`}
>
Click or drag files here to upload
</span>
</>
)}
</div>
{message && (
<small
className={`ar-Uploader__upload-message text-decoration-underline ${
variant === "large" ? "fw-bold f3 mt-2 mb-2" : "mt-1"
}`}
>
{message}
</small>
)}
</div>
<div className="ar-Uploader__file-input__actions">
{variant !== "compact" &&
localResults &&
Object.keys(localResults).length > 0 && (
<Button
containerClasses={`text-end${
variant === "large" ? " mt-3" : ""
}`}
content="Clear"
variant={ArButtonVariants.DANGER}
size={
variant === "regular" ? ArSizes.XSMALL : ArSizes.SMALL
}
onClick={(e) => {
e.stopPropagation()
clear()
if (inputRef.current) {
inputRef.current.value = ""
}
}}
/>
)}
</div>
</>
)}
<input
className="ar-Uploader__input d-none"
type="file"
onChange={handleFileChange}
ref={inputRef}
accept={
Array.isArray(localAcceptedFileTypes)
? localAcceptedFileTypes.join(",")
: localAcceptedFileTypes
}
disabled={disabled}
multiple={localAllowMultiple}
/>
</div>
)
const errorRender = errorMessage && (
<span className="error my-1 small">{errorMessage}</span>
)
const resultsRender = localResults && variant !== "compact" && (
<div className="ar-Uploader__file-upload-progress mt-2">
{Object.entries(localResults).map(([fileName, result]) =>
uploadEntryRenderer ? (
uploadEntryRenderer(result, () => clear(fileName))
) : (
<UploadedEntry
clear={() => clear(fileName)}
demo={!!demo}
disabled={!!disabled}
key={fileName}
result={result}
/>
),
)}
</div>
)
const uploaderRender = (
<div
className={`ar-Uploader d-flex flex-column${
variant ? " " + variant : ""
}${isAvatar ? " flex-center" : " w-100"}`}
onDragOver={(e: MouseEvent<HTMLDivElement>) => e.preventDefault()}
onDragLeave={(e: MouseEvent<HTMLDivElement>) => e.preventDefault()}
>
{variant !== "compact" ? (
<Droppable
dropHandler={(e) => {
if (inputRef.current && !disabled) {
inputRef.current.files = e.dataTransfer.files
inputRef.current.dispatchEvent(
new Event("change", { bubbles: true, cancelable: true }),
)
}
}}
acceptTypes={[NativeTypes.FILE]}
hideHoverEffect={isAvatar || disabled}
>
{inputRender}
</Droppable>
) : (
inputRender
)}
{!isAvatar && errorRender}
{!isAvatar && resultsRender}
</div>
)
return uploaderRender
},
(prevProps, props) => {
return (
prevProps.acceptedFileTypes === props.acceptedFileTypes &&
prevProps.acceptedFileCount === props.acceptedFileCount &&
prevProps.allowMultiple === props.allowMultiple &&
prevProps.disabled === props.disabled &&
prevProps.errorMessage === props.errorMessage &&
prevProps.endpoint === props.endpoint &&
prevProps.fileSizeLimit === props.fileSizeLimit &&
JSON.stringify(prevProps.files) === JSON.stringify(props.files) &&
prevProps.message === props.message &&
prevProps.avatarSize === props.avatarSize &&
prevProps.variant === props.variant &&
prevProps.theme === props.theme &&
prevProps.uploadSequential === props.uploadSequential &&
prevProps.uploadButtonText === props.uploadButtonText &&
prevProps.uploadingButtonText === props.uploadingButtonText
)
},
)
export default Uploader

View File

@@ -1,12 +0,0 @@
import { useRoutes } from "react-router-dom"
import * as pages from "./pages"
import Helper from "./utils/helper"
import ROUTES from "./routes"
Helper.populateComponentsInRoutes(ROUTES, pages)
interface RouterProps {}
const Router = (props: RouterProps): JSX.Element | null => useRoutes(ROUTES)
export default Router

View File

@@ -1,6 +0,0 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
import type { RootState, AppDispatch } from "./store"
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

View File

@@ -1,3 +0,0 @@
.c-Home {
}

View File

@@ -1,18 +0,0 @@
import { createSlice } from "@reduxjs/toolkit"
export interface HomeState {}
const initialState: HomeState = {}
export const homeSlice = createSlice({
name: "home",
initialState,
reducers: {
increment: (state) => {},
},
extraReducers: (builder) => {},
})
export const { increment } = homeSlice.actions
export default homeSlice.reducer

View File

@@ -1,8 +0,0 @@
import React from "react"
import Home from "./Home"
describe("Home", () => {
it("renders without error", () => {
})
})

View File

@@ -1,14 +0,0 @@
import React from "react"
import "./Home.module.scss"
interface HomeProps {}
const Home = props => {
return (
<div className="c-Home">
In Page Home
</div>
)
}
export default Home

View File

@@ -1,3 +0,0 @@
import Home from "./Home.jsx"
export default Home

View File

@@ -1,7 +0,0 @@
/* PLOP_INJECT_IMPORT */
import Home from "./Home"
export {
/* PLOP_INJECT_EXPORT */
Home,
}

View File

@@ -1,9 +0,0 @@
const ROUTES = [
{
path: "/",
class: "landing",
element: "Home",
},
]
export default ROUTES

View File

@@ -1,18 +0,0 @@
html, body, #root {
height: 100%;
width: 100%;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -1,17 +0,0 @@
import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"
import counterReducer from "../features/counter/counterSlice"
export const store = configureStore({
reducer: {
counter: counterReducer,
},
})
export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>

View File

@@ -1,6 +0,0 @@
interface RouteConfig {
path: String
class?: String
element: String | JSX.Element | null
children?: Array<RouteConfig>
}

View File

@@ -1,15 +0,0 @@
class Helper {
static populateComponentsInRoutes(routes: RouteConfig[], components: any) {
routes &&
routes.forEach((route) => {
const Component: JSX.ElementType =
components[route.element as keyof object]
route.element = <Component />
if (route.children) {
Helper.populateComponentsInRoutes(route.children, components)
}
})
}
}
export default Helper

View File

@@ -1,67 +0,0 @@
import { useState } from "react"
import { useAppSelector, useAppDispatch } from "../../app/hooks"
import {
decrement,
increment,
incrementByAmount,
incrementAsync,
incrementIfOdd,
selectCount,
} from "./counterSlice"
export function Counter() {
const count = useAppSelector(selectCount)
const dispatch = useAppDispatch()
const [incrementAmount, setIncrementAmount] = useState("2")
const incrementValue = Number(incrementAmount) || 0
return (
<div>
<div className="row">
<button
className="button"
aria-label="Decrement value"
onClick={() => dispatch(decrement())}
>
-
</button>
<span className="value">{count}</span>
<button
className="button"
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
</div>
<div className="row">
<input
className="textbox"
aria-label="Set increment amount"
value={incrementAmount}
onChange={(e) => setIncrementAmount(e.target.value)}
/>
<button
className="button"
onClick={() => dispatch(incrementByAmount(incrementValue))}
>
Add Amount
</button>
<button
className="asyncButton"
onClick={() => dispatch(incrementAsync(incrementValue))}
>
Add Async
</button>
<button
className="button"
onClick={() => dispatch(incrementIfOdd(incrementValue))}
>
Add If Odd
</button>
</div>
</div>
)
}

View File

@@ -1,6 +0,0 @@
// A mock function to mimic making an async request for data
export function fetchCount(amount = 1) {
return new Promise<{ data: number }>((resolve) =>
setTimeout(() => resolve({ data: amount }), 500),
)
}

View File

@@ -1,34 +0,0 @@
import counterReducer, {
CounterState,
increment,
decrement,
incrementByAmount,
} from "./counterSlice"
describe("counter reducer", () => {
const initialState: CounterState = {
value: 3,
status: "idle",
}
it("should handle initial state", () => {
expect(counterReducer(undefined, { type: "unknown" })).toEqual({
value: 0,
status: "idle",
})
})
it("should handle increment", () => {
const actual = counterReducer(initialState, increment())
expect(actual.value).toEqual(4)
})
it("should handle decrement", () => {
const actual = counterReducer(initialState, decrement())
expect(actual.value).toEqual(2)
})
it("should handle incrementByAmount", () => {
const actual = counterReducer(initialState, incrementByAmount(2))
expect(actual.value).toEqual(5)
})
})

View File

@@ -1,84 +0,0 @@
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
import { RootState, AppThunk } from "../../app/store"
import { fetchCount } from "./counterAPI"
export interface CounterState {
value: number
status: "idle" | "loading" | "failed"
}
const initialState: CounterState = {
value: 0,
status: "idle",
}
// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched. Thunks are
// typically used to make async requests.
export const incrementAsync = createAsyncThunk(
"counter/fetchCount",
async (amount: number) => {
const response = await fetchCount(amount)
// The value we return becomes the `fulfilled` action payload
return response.data
},
)
export const counterSlice = createSlice({
name: "counter",
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: (state) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn"t actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
},
},
// The `extraReducers` field lets the slice handle actions defined elsewhere,
// including actions generated by createAsyncThunk or in other slices.
extraReducers: (builder) => {
builder
.addCase(incrementAsync.pending, (state) => {
state.status = "loading"
})
.addCase(incrementAsync.fulfilled, (state, action) => {
state.status = "idle"
state.value += action.payload
})
.addCase(incrementAsync.rejected, (state) => {
state.status = "failed"
})
},
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they"re used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state: RootState) => state.counter.value
// We can also write thunks by hand, which may contain both sync and async logic.
// Here"s an example of conditionally dispatching actions based on current state.
export const incrementIfOdd =
(amount: number): AppThunk =>
(dispatch, getState) => {
const currentValue = selectCount(getState())
if (currentValue % 2 === 1) {
dispatch(incrementByAmount(amount))
}
}
export default counterSlice.reducer

View File

@@ -1,19 +0,0 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { BrowserRouter } from "react-router-dom"
import { Provider } from "react-redux"
import { store } from "./app/store"
import Router from "./app/Router"
import "./app/static/styles/global.scss"
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement)
root.render(
<React.StrictMode>
<BrowserRouter>
<Provider store={store}>
<Router />
</Provider>
</BrowserRouter>
</React.StrictMode>,
)

216
src/useUpload.ts Normal file
View File

@@ -0,0 +1,216 @@
// useUpload.ts
import { useEffect, useRef, useState } from "react"
import {
ArProgress,
FunctionType,
UploadHandler,
UploadResults,
} from "@armco/types"
import { Helper, Network } from "../utils"
const SUCCESS_RESULT = {
status: ArProgress.COMPLETED,
progress: 100,
}
const FAILED_RESULT = {
status: ArProgress.FAILED,
progress: 0,
}
const useUpload = (
endpointOrHandler: string | UploadHandler,
isSequential?: boolean,
files?: Array<File | { name: string; url?: string }>,
) => {
const [results, setResults] = useState<UploadResults>()
const clearActionRef = useRef<{ [key: string]: boolean }>({})
const intervalRef = useRef<{ [key: string]: NodeJS.Timeout }>({})
useEffect(() => {
if (files) {
const results: UploadResults = {}
files?.forEach((file) => {
results[Helper.generateFileKey(file)] = {
...file,
progress: 100,
status: ArProgress.COMPLETED,
file: typeof file === "object" ? file : { name: file.name },
}
})
setResults(results)
}
}, [files])
useEffect(() => {
const interval = intervalRef.current
return () => {
Object.keys(interval).forEach((key) => clearInterval(interval[key]))
}
}, [intervalRef])
const clear = (fileId?: string) => {
if (fileId) {
const resultsClone = { ...results }
delete resultsClone[fileId]
setResults(resultsClone)
clearActionRef.current[fileId] = true
clearInterval(intervalRef.current[fileId])
} else {
setResults(undefined)
results &&
Object.keys(results).forEach(
(key) =>
results[key].status === ArProgress.IN_PROGRESS &&
(clearActionRef.current[key] = true),
)
Object.keys(intervalRef.current).forEach((key) =>
clearInterval(intervalRef.current[key]),
)
}
}
const uploadFile = async (file: File) => {
const isHandler = typeof endpointOrHandler === "function"
const handler = isHandler ? (endpointOrHandler as UploadHandler) : null
const fileKey = Helper.generateFileKey(file)
intervalRef.current[fileKey] = setInterval(() => {
setResults((prevResult) => {
const prevResultObj = prevResult && prevResult[fileKey as string]
if (!prevResultObj || prevResultObj.progress >= 95) {
if (intervalRef.current) {
clearInterval(intervalRef.current[fileKey])
}
return prevResult
}
const increment = Math.floor(Math.random() * 8) + 5
return {
...prevResult,
[fileKey]: {
...prevResultObj,
progress: prevResultObj.progress + increment,
},
}
})
}, 500)
if (handler) {
return await handler(file)
.then((res) => {
if (!clearActionRef.current[fileKey]) {
if (!res.ok) {
setResults((prevResult) => {
return {
...prevResult,
[fileKey]: { file, ...FAILED_RESULT },
}
})
}
setResults((prevResult) => ({
...prevResult,
[fileKey]: { file, ...SUCCESS_RESULT },
}))
}
})
.catch(() => {
if (!clearActionRef.current[fileKey]) {
setResults((prevResult) => {
return {
...prevResult,
[fileKey]: { file, ...FAILED_RESULT },
}
})
}
})
.finally(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current[fileKey])
}
})
} else {
return await Network.upload(endpointOrHandler as string, file)
.then((response) => {
if (!clearActionRef.current[fileKey]) {
if (!response.body) {
setResults((prevResult) => {
return {
...prevResult,
[fileKey]: {
file,
...FAILED_RESULT,
error: response.body,
httpStatus: response.status,
},
}
})
return
}
const savedRecord = response.body.saved[0]
setResults((prevResult) => {
return {
...prevResult,
[fileKey]: { ...(savedRecord || {}), file, ...SUCCESS_RESULT },
}
})
} else {
delete clearActionRef.current[fileKey]
delete intervalRef.current[fileKey]
}
})
.catch((error) => {
if (!clearActionRef.current[fileKey]) {
setResults((prevResult) => {
return {
...prevResult,
[fileKey]: { file, ...FAILED_RESULT, error },
}
})
} else {
delete clearActionRef.current[fileKey]
delete intervalRef.current[fileKey]
}
})
.finally(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current[fileKey])
}
})
}
}
const upload = async (
newFiles: File[],
callback?: FunctionType,
shouldAppend?: boolean,
) => {
const newResults: UploadResults = {}
newFiles.forEach((file) => {
const fileKey = Helper.generateFileKey(file)
newResults[fileKey] = {
file,
status: ArProgress.IN_PROGRESS,
progress: isSequential ? 1 : Math.floor(Math.random() * 10),
}
})
setResults(shouldAppend ? { ...results, ...newResults } : newResults)
let promises = []
for (const file of newFiles) {
if (isSequential) {
promises.push(await uploadFile(file))
} else {
promises.push(uploadFile(file))
}
}
await Promise.allSettled(promises).then(
(responses) => callback && callback(responses),
)
}
return { clear, upload, results }
}
export default useUpload