lib form
This commit is contained in:
parent
f5f187ff81
commit
a20ae163f7
|
|
@ -57,7 +57,7 @@ export const Form: FC<FMProps> = (props) => {
|
||||||
field: null,
|
field: null,
|
||||||
},
|
},
|
||||||
has_fields_container: null as any,
|
has_fields_container: null as any,
|
||||||
is_newly_created: false
|
is_newly_created: false,
|
||||||
});
|
});
|
||||||
const form_inner_ref = useRef<HTMLDivElement>(null);
|
const form_inner_ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -174,6 +174,11 @@ export const Form: FC<FMProps> = (props) => {
|
||||||
if (fm.status === "resizing") {
|
if (fm.status === "resizing") {
|
||||||
fm.status = "ready";
|
fm.status = "ready";
|
||||||
}
|
}
|
||||||
|
// useEffect(() => {
|
||||||
|
// setTimeout(() => {
|
||||||
|
// fm.render();
|
||||||
|
// }, 100);
|
||||||
|
// }, []);
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ export const FieldTypeInput: FC<{
|
||||||
case "upload":
|
case "upload":
|
||||||
return (
|
return (
|
||||||
<FieldUpload
|
<FieldUpload
|
||||||
|
arg={arg}
|
||||||
field={field}
|
field={field}
|
||||||
fm={fm}
|
fm={fm}
|
||||||
prop={prop}
|
prop={prop}
|
||||||
|
|
@ -177,6 +178,7 @@ export const FieldTypeInput: FC<{
|
||||||
case "import":
|
case "import":
|
||||||
return (
|
return (
|
||||||
<FieldUpload
|
<FieldUpload
|
||||||
|
arg={arg}
|
||||||
field={field}
|
field={field}
|
||||||
fm={fm}
|
fm={fm}
|
||||||
prop={prop}
|
prop={prop}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,32 @@
|
||||||
import { useLocal } from "@/utils/use-local";
|
import { useLocal } from "@/utils/use-local";
|
||||||
import get from "lodash.get";
|
import get from "lodash.get";
|
||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { FMLocal, FieldLocal } from "../../typings";
|
import { FMLocal, FieldLocal, FieldProp } from "../../typings";
|
||||||
import { PropTypeInput } from "./TypeInput";
|
import { PropTypeInput } from "./TypeInput";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Paperclip,
|
||||||
|
SquareArrowOutUpRight,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Spinner } from "lib/comps/ui/field-loading";
|
||||||
const w = window as unknown as {
|
const w = window as unknown as {
|
||||||
serverurl: string
|
serverurl: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const FieldUpload: FC<{
|
export const FieldUpload: FC<{
|
||||||
field: FieldLocal;
|
field: FieldLocal;
|
||||||
fm: FMLocal;
|
fm: FMLocal;
|
||||||
prop: PropTypeInput;
|
prop: PropTypeInput;
|
||||||
|
styling?: string;
|
||||||
|
arg: FieldProp;
|
||||||
on_change: (e: any) => void | Promise<void>;
|
on_change: (e: any) => void | Promise<void>;
|
||||||
}> = ({ field, fm, prop, on_change }) => {
|
}> = ({ field, fm, prop, on_change, arg }) => {
|
||||||
|
const styling = arg.upload_style ? arg.upload_style : "full";
|
||||||
let type_field = prop.sub_type;
|
let type_field = prop.sub_type;
|
||||||
let value: any = fm.data[field.name];
|
let value: any = fm.data[field.name];
|
||||||
// let type_upload =
|
// let type_upload =
|
||||||
|
|
@ -22,170 +35,301 @@ export const FieldUpload: FC<{
|
||||||
display: false as any,
|
display: false as any,
|
||||||
ref: null as any,
|
ref: null as any,
|
||||||
drop: false as boolean,
|
drop: false as boolean,
|
||||||
|
fase: value ? "preview" : ("start" as "start" | "upload" | "preview"),
|
||||||
|
style: "inline" as "inline" | "full",
|
||||||
});
|
});
|
||||||
let display: any = null;
|
let display: any = null;
|
||||||
const disabled =
|
const disabled =
|
||||||
typeof field.disabled === "function" ? field.disabled() : field.disabled;
|
typeof field.disabled === "function" ? field.disabled() : field.disabled;
|
||||||
|
const on_upload = async (event: any) => {
|
||||||
|
let file = null;
|
||||||
|
try {
|
||||||
|
file = event.target.files[0];
|
||||||
|
} catch (ex) {}
|
||||||
|
if (type_field === "import") {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e: any) => {
|
||||||
|
const binaryStr = e.target.result;
|
||||||
|
const workbook = XLSX.read(binaryStr, { type: "binary" });
|
||||||
|
|
||||||
|
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||||
|
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||||
|
if (typeof on_change === "function") {
|
||||||
|
const res = on_change({
|
||||||
|
value: jsonData,
|
||||||
|
file: file,
|
||||||
|
binnary: e.target.result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsBinaryString(file);
|
||||||
|
} else {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
let url = siteurl("/_upload");
|
||||||
|
if (
|
||||||
|
location.hostname === "prasi.avolut.com" ||
|
||||||
|
location.host === "localhost:4550"
|
||||||
|
) {
|
||||||
|
const newurl = new URL(location.href);
|
||||||
|
newurl.pathname = `/_proxy/${url}`;
|
||||||
|
url = newurl.toString();
|
||||||
|
}
|
||||||
|
input.fase = "upload";
|
||||||
|
input.render();
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const contentType: any = response.headers.get("content-type");
|
||||||
|
let result;
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
result = await response.json();
|
||||||
|
} else if (contentType.includes("text/plain")) {
|
||||||
|
result = await response.text();
|
||||||
|
} else {
|
||||||
|
result = await response.blob();
|
||||||
|
}
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
fm.data[field.name] = `_file${get(result, "[0]")}`;
|
||||||
|
fm.render();
|
||||||
|
setTimeout(() => {
|
||||||
|
input.fase = "preview";
|
||||||
|
input.render();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
input.fase = "start";
|
||||||
|
input.render();
|
||||||
|
alert("Error upload");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
input.fase = "start";
|
||||||
|
input.render();
|
||||||
|
alert("Error upload");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.ref) {
|
||||||
|
input.ref.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="c-flex-grow c-flex-row c-flex c-w-full c-h-full">
|
<div className="c-flex-grow c-flex-row c-flex c-w-full c-h-full c-items-center">
|
||||||
<div
|
{input.fase === "start" ? (
|
||||||
onDrop={(e: any) => {
|
<>
|
||||||
e.preventDefault();
|
<div className="c-flex c-flex-row c-relative c-flex-grow">
|
||||||
input.drop = false;
|
<input
|
||||||
input.render();
|
ref={(ref) => (input.ref = ref)}
|
||||||
}}
|
type="file"
|
||||||
onDragOver={(e: any) => {
|
multiple={false}
|
||||||
// Prevent default behavior (Prevent file from being opened)
|
onChange={on_upload}
|
||||||
e.preventDefault();
|
className={cx(
|
||||||
input.drop = true;
|
"c-absolute c-w-full c-h-full c-cursor-pointer c-top-0 c-left-0 c-hidden"
|
||||||
input.render();
|
)}
|
||||||
}}
|
/>
|
||||||
className={cx(
|
{styling === "inline" ? (
|
||||||
input.drop ? "c-bg-gray-100" : "",
|
<>
|
||||||
"hover:c-bg-gray-100 c-m-1 c-relative c-flex-grow c-p-4 c-items-center c-flex c-flex-row c-text-gray-400 c-border c-border-gray-200 c-border-dashed c-rounded c-cursor-pointer"
|
<div
|
||||||
)}
|
onClick={() => {
|
||||||
>
|
if (input.ref) {
|
||||||
<div className="c-flex-row c-flex c-flex-grow c-space-x-2">
|
console.log(input.ref)
|
||||||
<svg
|
input.ref.click();
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
}
|
||||||
width="24"
|
}}
|
||||||
height="24"
|
className="c-items-center c-flex c-text-base c-px-5 c-py-3 c-outline-none c-rounded c-cursor-pointer "
|
||||||
viewBox="0 0 14 14"
|
>
|
||||||
>
|
<div className="c-flex c-flex-row c-items-center c-px-2">
|
||||||
<g
|
<Upload className="c-h-4 c-w-4" />
|
||||||
fill="none"
|
</div>
|
||||||
stroke="currentColor"
|
<div className="c-flex c-flex-row c-items-center">
|
||||||
strokeLinecap="round"
|
Upload Your File
|
||||||
strokeLinejoin="round"
|
</div>
|
||||||
>
|
</div>
|
||||||
<path d="M3.5 13.5h-2a1 1 0 0 1-1-1v-8h13v8a1 1 0 0 1-1 1h-2" />
|
</>
|
||||||
<path d="M4.5 10L7 7.5L9.5 10M7 7.5v6M11.29 1a1 1 0 0 0-.84-.5h-6.9a1 1 0 0 0-.84.5L.5 4.5h13zM7 .5v4" />
|
) : (
|
||||||
</g>
|
<>
|
||||||
</svg>
|
<div
|
||||||
<div className="c-flex c-flex-col">
|
onDrop={(e: any) => {
|
||||||
<span className="c-font-medium">
|
e.preventDefault();
|
||||||
Drop Your File or{" "}
|
input.drop = false;
|
||||||
<span className="c-underline c-text-blue-500">Browse</span>
|
input.render();
|
||||||
</span>
|
}}
|
||||||
|
onDragOver={(e: any) => {
|
||||||
|
// Prevent default behavior (Prevent file from being opened)
|
||||||
|
e.preventDefault();
|
||||||
|
input.drop = true;
|
||||||
|
input.render();
|
||||||
|
}}
|
||||||
|
className={cx(
|
||||||
|
input.drop ? "c-bg-gray-100" : "",
|
||||||
|
"hover:c-bg-gray-100 c-flex-grow c-m-1 c-relative c-flex-grow c-p-4 c-items-center c-flex c-flex-row c-text-gray-400 c-border c-border-gray-200 c-border-dashed c-rounded c-cursor-pointer"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="c-flex-row c-flex c-flex-grow c-space-x-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3.5 13.5h-2a1 1 0 0 1-1-1v-8h13v8a1 1 0 0 1-1 1h-2" />
|
||||||
|
<path d="M4.5 10L7 7.5L9.5 10M7 7.5v6M11.29 1a1 1 0 0 0-.84-.5h-6.9a1 1 0 0 0-.84.5L.5 4.5h13zM7 .5v4" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div className="c-flex c-flex-col">
|
||||||
|
<span className="c-font-medium">
|
||||||
|
Drop Your File or{" "}
|
||||||
|
<span className="c-underline c-text-blue-500">
|
||||||
|
Browse
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<input
|
) : input.fase === "upload" ? (
|
||||||
ref={(ref) => (input.ref = ref)}
|
<>
|
||||||
type="file"
|
<div className="c-px-2">
|
||||||
multiple
|
<Loader2 className={cx("c-h-5 c-w-5 c-animate-spin")} />
|
||||||
onChange={async (event: any) => {
|
</div>
|
||||||
let file = null;
|
<div className="c-px-2">Uploading</div>
|
||||||
try {
|
</>
|
||||||
file = event.target.files[0];
|
) : input.fase === "preview" ? (
|
||||||
} catch (ex) {}
|
<>
|
||||||
if (type_field === "import") {
|
<div className="c-flex c-flex-row c-p-2 c-items-center">
|
||||||
const reader = new FileReader();
|
<IconFile type={getFileName(siteurl(value)).extension} />
|
||||||
|
</div>
|
||||||
reader.onload = (e: any) => {
|
<div
|
||||||
const binaryStr = e.target.result;
|
className="c-line-clamp-1 c-flex-grow c-items-center"
|
||||||
const workbook = XLSX.read(binaryStr, { type: "binary" });
|
onClick={() => {
|
||||||
|
let url = siteurl(value);
|
||||||
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
|
window.open(url, "_blank");
|
||||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
}}
|
||||||
if (typeof on_change === "function") {
|
>
|
||||||
const res = on_change({ value: jsonData });
|
{getFileName(siteurl(value)).fullname}
|
||||||
}
|
</div>
|
||||||
};
|
<div className="c-flex c-flex-row c-items-center">
|
||||||
reader.readAsBinaryString(file);
|
<div className="c-flex c-flex-row c-space-x-1 c-px-2">
|
||||||
} else {
|
<SquareArrowOutUpRight
|
||||||
const formData = new FormData();
|
className="c-h-5 c-w-5"
|
||||||
formData.append("file", file);
|
onClick={() => {
|
||||||
|
let url = siteurl(value);
|
||||||
let url = siteurl("/_upload");
|
window.open(url, "_blank");
|
||||||
if (location.hostname === 'prasi.avolut.com' || location.host === 'localhost:4550') {
|
}}
|
||||||
const newurl = new URL(location.href);
|
/>
|
||||||
newurl.pathname = `/_proxy/${w.serverurl}/_upload`;
|
<Trash2
|
||||||
url = newurl.toString();
|
className="c-text-red-500 c-h-5 c-w-5"
|
||||||
}
|
onClick={() => {
|
||||||
|
fm.data[field.name] = null;
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const contentType: any = response.headers.get("content-type");
|
|
||||||
let result;
|
|
||||||
if (contentType.includes("application/json")) {
|
|
||||||
result = await response.json();
|
|
||||||
} else if (contentType.includes("text/plain")) {
|
|
||||||
result = await response.text();
|
|
||||||
} else {
|
|
||||||
result = await response.blob();
|
|
||||||
}
|
|
||||||
if (Array.isArray(result)) {
|
|
||||||
fm.data[field.name] = get(result, "[0]");
|
|
||||||
fm.render();
|
fm.render();
|
||||||
} else {
|
}}
|
||||||
alert("Error upload");
|
/>
|
||||||
}
|
</div>
|
||||||
} else {
|
</div>
|
||||||
}
|
</>
|
||||||
}
|
) : (
|
||||||
}}
|
<></>
|
||||||
className={
|
)}
|
||||||
"c-absolute c-w-full c-h-full c-cursor-pointer c-top-0 c-left-0 c-hidden"
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="c-flex-grow c-flex-row c-flex c-w-full c-h-full c-items-center">
|
||||||
|
<div className="c-flex c-flex-row c-p-2 c-items-center">
|
||||||
|
<IconFile
|
||||||
|
type={
|
||||||
|
getFileName("https://www.example.com/path/to/your/file.txt")
|
||||||
|
.extension
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="c-line-clamp-1 c-flex-grow c-items-center">
|
||||||
);
|
{getFileName("https://www.example.com/path/to/your/file.txt").fullname}
|
||||||
// console.log({ prop });
|
</div>
|
||||||
return (
|
<div className="c-flex c-flex-row c-items-center">
|
||||||
<div className="c-flex-grow c-flex-row c-flex c-w-full c-h-full">
|
<div className="c-flex c-flex-row c-space-x-1 c-px-2">
|
||||||
<div
|
<SquareArrowOutUpRight
|
||||||
className={cx(
|
className="c-h-5 c-w-5"
|
||||||
input.display ? "c-hidden" : "",
|
onClick={() => {
|
||||||
"c-flex-grow c-px-2 c-flex c-flex-row c-items-center"
|
let url = siteurl(value);
|
||||||
)}
|
window.open(url, "_blank");
|
||||||
onClick={() => {
|
}}
|
||||||
if (input.ref) {
|
/>
|
||||||
input.display = !input.display;
|
<Trash2
|
||||||
input.ref.focus();
|
className="c-text-red-500 c-h-5 c-w-5"
|
||||||
input.render();
|
onClick={() => {
|
||||||
}
|
fm.data[field.name] = null;
|
||||||
}}
|
fm.render();
|
||||||
>
|
}}
|
||||||
{formatMoney(Number(value) || 0)}
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
ref={(el) => (input.ref = el)}
|
|
||||||
type={"number"}
|
|
||||||
onClick={() => {}}
|
|
||||||
onChange={(ev) => {
|
|
||||||
fm.data[field.name] = ev.currentTarget.value;
|
|
||||||
fm.render();
|
|
||||||
}}
|
|
||||||
value={value}
|
|
||||||
disabled={disabled}
|
|
||||||
className={cx(
|
|
||||||
!input.display ? "c-hidden" : "",
|
|
||||||
"c-flex-1 c-bg-transparent c-outline-none c-px-2 c-text-sm c-w-full"
|
|
||||||
)}
|
|
||||||
spellCheck={false}
|
|
||||||
onFocus={() => {
|
|
||||||
field.focused = true;
|
|
||||||
field.render();
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
console.log("blur");
|
|
||||||
field.focused = false;
|
|
||||||
input.display = !input.display;
|
|
||||||
input.render();
|
|
||||||
field.render();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const formatMoney = (res: number) => {
|
const getFileName = (url: string) => {
|
||||||
const formattedAmount = new Intl.NumberFormat("id-ID", {
|
const fileName = url.substring(url.lastIndexOf("/") + 1);
|
||||||
minimumFractionDigits: 0,
|
const dotIndex = fileName.lastIndexOf(".");
|
||||||
}).format(res);
|
const fullname = fileName;
|
||||||
return formattedAmount;
|
if (dotIndex === -1) {
|
||||||
|
return { name: fileName, extension: "", fullname };
|
||||||
|
}
|
||||||
|
const name = fileName.substring(0, dotIndex);
|
||||||
|
const extension = fileName.substring(dotIndex + 1);
|
||||||
|
return { name, extension, fullname };
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconFile: FC<{ type: string }> = ({ type }) => {
|
||||||
|
if (["xlsx"].includes(type)) {
|
||||||
|
return (
|
||||||
|
<div className="c-flex c-flex-row c-text-[#2a801d]">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="m2.859 2.877l12.57-1.795a.5.5 0 0 1 .571.494v20.848a.5.5 0 0 1-.57.494L2.858 21.123a1 1 0 0 1-.859-.99V3.867a1 1 0 0 1 .859-.99M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4zm-6.8 9L13 8h-2.4L9 10.286L7.4 8H5l2.8 4L5 16h2.4L9 13.714L10.6 16H13z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="c-flex c-flex-row ">
|
||||||
|
<Paperclip className="c-h-5 c-w-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="c-flex c-flex-row c-p-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="m2.859 2.877l12.57-1.795a.5.5 0 0 1 .571.494v20.848a.5.5 0 0 1-.57.494L2.858 21.123a1 1 0 0 1-.859-.99V3.867a1 1 0 0 1 .859-.99M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1h-4zm-6.8 9L13 8h-2.4L9 10.286L7.4 8H5l2.8 4L5 16h2.4L9 13.714L10.6 16H13z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ export type FieldProp = {
|
||||||
model_upload?: "upload" | "import";
|
model_upload?: "upload" | "import";
|
||||||
max_date?: any;
|
max_date?: any;
|
||||||
min_date?: any;
|
min_date?: any;
|
||||||
|
upload_style?: "inline" | "full"
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FMInternal = {
|
export type FMInternal = {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface RowData {
|
||||||
|
id: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualTableProps {
|
||||||
|
data: RowData[];
|
||||||
|
columns: string[];
|
||||||
|
estimatedRowHeight: number;
|
||||||
|
visibleRows: number;
|
||||||
|
resizableColumns?: boolean;
|
||||||
|
pinnedColumns?: string[]; // New prop to specify pinned columns
|
||||||
|
}
|
||||||
|
|
||||||
|
const VirtualTable: React.FC<VirtualTableProps> = ({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
estimatedRowHeight,
|
||||||
|
visibleRows,
|
||||||
|
resizableColumns = false,
|
||||||
|
pinnedColumns = [] // Default to no pinned columns
|
||||||
|
}) => {
|
||||||
|
const [start, setStart] = useState(0);
|
||||||
|
const [rowHeights, setRowHeights] = useState<number[]>([]);
|
||||||
|
const [columnWidths, setColumnWidths] = useState<{ [key: string]: number }>(
|
||||||
|
Object.fromEntries(columns.map(column => [column, 100]))
|
||||||
|
);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);
|
||||||
|
const resizingColumn = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const scrollableColumns = columns.filter(col => !pinnedColumns.includes(col));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const scrollTop = containerRef.current.scrollTop;
|
||||||
|
const newStart = Math.floor(scrollTop / estimatedRowHeight);
|
||||||
|
setStart(newStart);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
containerRef.current?.addEventListener('scroll', handleScroll);
|
||||||
|
return () => containerRef.current?.removeEventListener('scroll', handleScroll);
|
||||||
|
}, [estimatedRowHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const measureRowHeights = () => {
|
||||||
|
const newRowHeights = rowRefs.current.map(
|
||||||
|
(rowRef) => rowRef?.getBoundingClientRect().height || estimatedRowHeight
|
||||||
|
);
|
||||||
|
setRowHeights(newRowHeights);
|
||||||
|
};
|
||||||
|
|
||||||
|
measureRowHeights();
|
||||||
|
window.addEventListener('resize', measureRowHeights);
|
||||||
|
return () => window.removeEventListener('resize', measureRowHeights);
|
||||||
|
}, [data, estimatedRowHeight]);
|
||||||
|
|
||||||
|
const getTotalHeight = () => {
|
||||||
|
return rowHeights.reduce((sum, height) => sum + height, 0) || data.length * estimatedRowHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOffsetForIndex = (index: number) => {
|
||||||
|
return rowHeights.slice(0, index).reduce((sum, height) => sum + height, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleData = data.slice(start, start + visibleRows);
|
||||||
|
|
||||||
|
const handleMouseDown = (column: string) => (e: React.MouseEvent) => {
|
||||||
|
if (resizableColumns) {
|
||||||
|
resizingColumn.current = column;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (resizableColumns && resizingColumn.current) {
|
||||||
|
const newWidth = Math.max(50, e.clientX - (e.target as HTMLElement).getBoundingClientRect().left);
|
||||||
|
setColumnWidths(prev => ({
|
||||||
|
...prev,
|
||||||
|
[resizingColumn.current!]: newWidth
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (resizableColumns) {
|
||||||
|
resizingColumn.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resizableColumns) {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove as any);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove as any);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [resizableColumns]);
|
||||||
|
|
||||||
|
const renderTableContent = (columnSet: string[]) => (
|
||||||
|
<table style={{
|
||||||
|
transform: `translateY(${getOffsetForIndex(start)}px)`,
|
||||||
|
width: '100%',
|
||||||
|
borderCollapse: 'collapse'
|
||||||
|
}}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columnSet.map((column) => (
|
||||||
|
<th key={column} style={{
|
||||||
|
padding: '8px',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
width: columnWidths[column],
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{column}
|
||||||
|
{resizableColumns && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '5px',
|
||||||
|
cursor: 'col-resize',
|
||||||
|
background: 'transparent'
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown(column)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleData.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
ref={(el) => (rowRefs.current[start + index] = el)}
|
||||||
|
>
|
||||||
|
{columnSet.map((column) => (
|
||||||
|
<td key={column} style={{
|
||||||
|
padding: '8px',
|
||||||
|
width: columnWidths[column],
|
||||||
|
maxWidth: columnWidths[column],
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{row[column]}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
height: `${visibleRows * estimatedRowHeight}px`,
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pinnedColumns.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
position: 'sticky',
|
||||||
|
left: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
boxShadow: '2px 0 5px rgba(0,0,0,0.1)'
|
||||||
|
}}>
|
||||||
|
<div style={{ height: `${getTotalHeight()}px`, position: 'relative' }}>
|
||||||
|
{renderTableContent(pinnedColumns)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
overflowX: 'auto',
|
||||||
|
marginLeft: pinnedColumns.length > 0 ? '5px' : '0'
|
||||||
|
}}>
|
||||||
|
<div style={{ height: `${getTotalHeight()}px`, position: 'relative' }}>
|
||||||
|
{renderTableContent(scrollableColumns)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VirtualTable;
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"c-fixed c-inset-0 c-z-50 c-bg-black/80 data-[state=open]:c-animate-in data-[state=closed]:c-animate-out data-[state=closed]:c-fade-out-0 data-[state=open]:c-fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"c-fixed c-left-[50%] c-top-[50%] c-z-50 c-grid c-w-full c-max-w-lg c-translate-x-[-50%] c-translate-y-[-50%] c-gap-4 c-border c-bg-background c-p-6 c-shadow-lg c-duration-200 data-[state=open]:c-animate-in data-[state=closed]:c-animate-out data-[state=closed]:c-fade-out-0 data-[state=open]:c-fade-in-0 data-[state=closed]:c-zoom-out-95 data-[state=open]:c-zoom-in-95 data-[state=closed]:c-slide-out-to-left-1/2 data-[state=closed]:c-slide-out-to-top-[48%] data-[state=open]:c-slide-in-from-left-1/2 data-[state=open]:c-slide-in-from-top-[48%] sm:c-rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"c-flex c-flex-col c-space-y-1.5 c-text-center sm:c-text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"c-flex c-flex-col-reverse sm:c-flex-row sm:c-justify-end sm:c-space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"c-text-lg c-font-semibold c-leading-none c-tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("c-text-sm c-text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/utils"
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cx(
|
||||||
|
"c-relative c-h-4 c-w-full c-overflow-hidden c-rounded-full c-bg-secondary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="c-h-full c-w-full c-flex-1 c-bg-primary c-transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
|
|
@ -11,7 +11,12 @@ export const Accordion = lazify(
|
||||||
export const Popover = lazify(
|
export const Popover = lazify(
|
||||||
async () => (await import("@/comps/custom/Popover")).Popover
|
async () => (await import("@/comps/custom/Popover")).Popover
|
||||||
);
|
);
|
||||||
|
export const Progress = lazify(
|
||||||
|
async () => (await import("@/comps/ui/progress")).Progress
|
||||||
|
);
|
||||||
|
export const Dialog = lazify(
|
||||||
|
async () => (await import("@/comps/ui/dialog")).Dialog
|
||||||
|
);
|
||||||
export const Typeahead = lazify(
|
export const Typeahead = lazify(
|
||||||
async () => (await import("@/comps/ui/typeahead")).Typeahead
|
async () => (await import("@/comps/ui/typeahead")).Typeahead
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue