feat: enhance form handling and loading states across components
This commit is contained in:
parent
66e2cdb966
commit
643659a26c
|
|
@ -219,6 +219,7 @@ export function Popover({
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className={cx(
|
className={cx(
|
||||||
|
"pointer-events-auto",
|
||||||
popoverClassName
|
popoverClassName
|
||||||
? popoverClassName
|
? popoverClassName
|
||||||
: cx(
|
: cx(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ export const Field: React.FC<{
|
||||||
name: string;
|
name: string;
|
||||||
isBetter?: boolean;
|
isBetter?: boolean;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
|
valueKey?: string;
|
||||||
onLoad?: () => Promise<any> | any;
|
onLoad?: () => Promise<any> | any;
|
||||||
|
onDelete?: (item: any) => Promise<any> | any;
|
||||||
type?:
|
type?:
|
||||||
| "rating"
|
| "rating"
|
||||||
| "color"
|
| "color"
|
||||||
|
|
@ -73,6 +75,8 @@ export const Field: React.FC<{
|
||||||
allowNew,
|
allowNew,
|
||||||
unique = true,
|
unique = true,
|
||||||
tooltip,
|
tooltip,
|
||||||
|
valueKey,
|
||||||
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
let result = null;
|
let result = null;
|
||||||
const field = useLocal({
|
const field = useLocal({
|
||||||
|
|
@ -182,6 +186,7 @@ export const Field: React.FC<{
|
||||||
"single-checkbox",
|
"single-checkbox",
|
||||||
"radio",
|
"radio",
|
||||||
"checkbox",
|
"checkbox",
|
||||||
|
"multi-upload",
|
||||||
].includes(type) &&
|
].includes(type) &&
|
||||||
css`
|
css`
|
||||||
border: 0px !important;
|
border: 0px !important;
|
||||||
|
|
@ -197,7 +202,7 @@ export const Field: React.FC<{
|
||||||
<div
|
<div
|
||||||
// ref={prefixRef}
|
// ref={prefixRef}
|
||||||
className={cx(
|
className={cx(
|
||||||
"px-1 py-1 items-center flex flex-row flex-grow rounded-l-md h-full",
|
"px-1 py-1 items-center flex flex-row flex-grow rounded-l-md h-full prefix",
|
||||||
css`
|
css`
|
||||||
height: 2.13rem;
|
height: 2.13rem;
|
||||||
`
|
`
|
||||||
|
|
@ -231,6 +236,8 @@ export const Field: React.FC<{
|
||||||
mode={"upload"}
|
mode={"upload"}
|
||||||
type="multi"
|
type="multi"
|
||||||
disabled={is_disable}
|
disabled={is_disable}
|
||||||
|
valueKey={valueKey}
|
||||||
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : ["dropdown"].includes(type) ? (
|
) : ["dropdown"].includes(type) ? (
|
||||||
|
|
@ -354,7 +361,7 @@ export const Field: React.FC<{
|
||||||
<div
|
<div
|
||||||
// ref={suffixRef}
|
// ref={suffixRef}
|
||||||
className={cx(
|
className={cx(
|
||||||
"px-1 py-1 items-center flex flex-row flex-grow rounded-r-md h-full",
|
"px-1 py-1 items-center flex flex-row flex-grow rounded-r-md h-full suffix",
|
||||||
css`
|
css`
|
||||||
height: 2.13rem;
|
height: 2.13rem;
|
||||||
`,
|
`,
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,111 @@ export const FilePreview = ({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export const FilePreviewBetter = ({
|
||||||
|
url,
|
||||||
|
disabled,
|
||||||
|
filename,
|
||||||
|
}: {
|
||||||
|
url: any;
|
||||||
|
disabled?: boolean;
|
||||||
|
filename?: string;
|
||||||
|
}) => {
|
||||||
|
const file: any = extractFileInfo(filename || url);
|
||||||
|
const color = colorOfExtension(file.extension);
|
||||||
|
let content = (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex items-center justify-center w-8 h-8 rounded-lg ",
|
||||||
|
css`
|
||||||
|
background: ${color?.background};
|
||||||
|
border: 1px solid ${color?.color};
|
||||||
|
color: ${color?.color};
|
||||||
|
border-radius: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0px 5px;
|
||||||
|
font-size: 9px;
|
||||||
|
margin-right: 5px;
|
||||||
|
`,
|
||||||
|
"flex items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file.extension}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
[".png", ".jpeg", ".jpg", ".webp"].find((e) => file?.fullname.endsWith(e))
|
||||||
|
) {
|
||||||
|
content = (
|
||||||
|
<div className="rounded-lg flex-grow overflow-hidden">
|
||||||
|
<img
|
||||||
|
onClick={() => {
|
||||||
|
let _url = siteurl(url || "");
|
||||||
|
window.open(_url, "_blank");
|
||||||
|
}}
|
||||||
|
className={cx(
|
||||||
|
"rounded-md w-8 h-8 object-cover",
|
||||||
|
css`
|
||||||
|
&:hover {
|
||||||
|
outline: 2px solid #1c4ed8;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
css`
|
||||||
|
background-image: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#ccc 25%,
|
||||||
|
transparent 25%
|
||||||
|
),
|
||||||
|
linear-gradient(135deg, #ccc 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||||
|
linear-gradient(135deg, transparent 75%, #ccc 75%);
|
||||||
|
background-size: 25px 25px; /* Must be a square */
|
||||||
|
background-position: 0 0, 12.5px 0, 12.5px -12.5px, 0px 12.5px; /* Must be half of one side of the square */
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
src={url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{file.extension && (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"flex max-w-full rounded items-center px-1 cursor-pointer flex-grow hover:bg-gray-100 gap-x-1 justify-between",
|
||||||
|
"pr-2",
|
||||||
|
css`
|
||||||
|
&:hover {
|
||||||
|
// border: 1px solid #1c4ed8;
|
||||||
|
// outline: 1px solid #1c4ed8;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
// border-bottom: 1px solid #1c4ed8;
|
||||||
|
// outline: 1px solid #1c4ed8;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
disabled ? "bg-transparent" : "bg-white"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row gap-x-1 items-center">
|
||||||
|
<div className=" flex flex-row items-center">{content}</div>
|
||||||
|
<div className="text-xs filename line-clamp-1 break-all">
|
||||||
|
{file?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-2">
|
||||||
|
<ExternalLink size="12px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
function darkenColor(color: string, factor: number = 0.5): string {
|
function darkenColor(color: string, factor: number = 0.5): string {
|
||||||
const rgb = hexToRgb(color);
|
const rgb = hexToRgb(color);
|
||||||
const r = Math.floor(rgb.r * factor);
|
const r = Math.floor(rgb.r * factor);
|
||||||
|
|
@ -314,6 +419,26 @@ const getFileName = (url: string) => {
|
||||||
return { name, extension, fullname };
|
return { name, extension, fullname };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractFileInfo = (url: string) => {
|
||||||
|
let fileName = url.split("/").pop();
|
||||||
|
if (fileName) {
|
||||||
|
let parts = fileName.split(".");
|
||||||
|
let extension = parts.length > 1 ? parts.pop() : "";
|
||||||
|
let name = parts.join(".");
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
fullname: fileName,
|
||||||
|
extension: extension,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: null,
|
||||||
|
fullname: null,
|
||||||
|
extension: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
export const ImgThumb = ({
|
export const ImgThumb = ({
|
||||||
className,
|
className,
|
||||||
url,
|
url,
|
||||||
|
|
@ -361,3 +486,35 @@ export const ImgThumb = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRandomColorPair = () => {
|
||||||
|
const colors = [
|
||||||
|
{ color: "#dc2626", background: "#fbd5d5" },
|
||||||
|
{ color: "#2563eb", background: "#dbeafe" },
|
||||||
|
{ color: "#16a34a", background: "#dcfce7" },
|
||||||
|
{ color: "#6b7280", background: "#f3f4f6" },
|
||||||
|
{ color: "#7c3aed", background: "#ede9fe" },
|
||||||
|
{ color: "#f97316", background: "#ffedd5" },
|
||||||
|
{ color: "#0d9488", background: "#ccfbf1" },
|
||||||
|
{ color: "#9333ea", background: "#e9d5ff" },
|
||||||
|
{ color: "#eab308", background: "#fef9c3" },
|
||||||
|
];
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorOfExtension = (extension: string) => {
|
||||||
|
const colorMap: any = {
|
||||||
|
pdf: { color: "#dc2626", background: "#fbd5d5" },
|
||||||
|
doc: { color: "#2563eb", background: "#dbeafe" },
|
||||||
|
docx: { color: "#2563eb", background: "#dbeafe" },
|
||||||
|
xls: { color: "#16a34a", background: "#dcfce7" },
|
||||||
|
xlsx: { color: "#16a34a", background: "#dcfce7" },
|
||||||
|
txt: { color: "#6b7280", background: "#f3f4f6" },
|
||||||
|
zip: { color: "#7c3aed", background: "#ede9fe" },
|
||||||
|
rar: { color: "#7c3aed", background: "#ede9fe" },
|
||||||
|
mp4: { color: "#f97316", background: "#ffedd5" },
|
||||||
|
mp3: { color: "#0d9488", background: "#ccfbf1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return colorMap[extension] || getRandomColorPair();
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export const TypeUpload: React.FC<any> = ({
|
||||||
mode,
|
mode,
|
||||||
type,
|
type,
|
||||||
disabled,
|
disabled,
|
||||||
|
valueKey = "url",
|
||||||
|
onDelete,
|
||||||
}) => {
|
}) => {
|
||||||
if (type === "multi") {
|
if (type === "multi") {
|
||||||
return (
|
return (
|
||||||
|
|
@ -20,6 +22,8 @@ export const TypeUpload: React.FC<any> = ({
|
||||||
fm={fm}
|
fm={fm}
|
||||||
on_change={on_change}
|
on_change={on_change}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
|
valueKey={valueKey}
|
||||||
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import get from "lodash.get";
|
import { Upload } from "lucide-react";
|
||||||
import { Loader2, Paperclip, Trash2, Upload } from "lucide-react";
|
|
||||||
import { ChangeEvent, FC } from "react";
|
import { ChangeEvent, FC } from "react";
|
||||||
import * as XLSX from "xlsx";
|
|
||||||
import { useLocal } from "@/lib/utils/use-local";
|
import { useLocal } from "@/lib/utils/use-local";
|
||||||
import { siteurl } from "@/lib/utils/siteurl";
|
|
||||||
import { FilePreview } from "./FilePreview";
|
|
||||||
import { Spinner } from "../../ui/spinner";
|
import { Spinner } from "../../ui/spinner";
|
||||||
|
import { FilePreviewBetter } from "./FilePreview";
|
||||||
|
import { MdDelete } from "react-icons/md";
|
||||||
|
|
||||||
export const FieldUploadMulti: FC<{
|
export const FieldUploadMulti: FC<{
|
||||||
field: any;
|
field: any;
|
||||||
fm: any;
|
fm: any;
|
||||||
on_change: (e: any) => void | Promise<void>;
|
on_change: (e: any) => void | Promise<void>;
|
||||||
mode?: "upload";
|
mode?: "upload";
|
||||||
}> = ({ field, fm, on_change, mode }) => {
|
valueKey?: string;
|
||||||
|
onDelete?: (e: any) => any | Promise<any>;
|
||||||
|
}> = ({ field, fm, on_change, mode, valueKey = "url", onDelete }) => {
|
||||||
const styling = "mini";
|
const styling = "mini";
|
||||||
const disabled = field?.disabled || false;
|
const disabled = field?.disabled || false;
|
||||||
let value: any = fm.data?.[field.name];
|
let value: any = fm.data?.[field.name];
|
||||||
// let type_upload =
|
// let type_upload =
|
||||||
const input = useLocal({
|
const input = useLocal({
|
||||||
value: 0 as any,
|
value: [] as any[],
|
||||||
display: false as any,
|
display: false as any,
|
||||||
ref: null as any,
|
ref: null as any,
|
||||||
drop: false as boolean,
|
drop: false as boolean,
|
||||||
|
|
@ -32,32 +32,6 @@ export const FieldUploadMulti: FC<{
|
||||||
try {
|
try {
|
||||||
file = event.target?.files?.[0];
|
file = event.target?.files?.[0];
|
||||||
} catch (ex) {}
|
} catch (ex) {}
|
||||||
const upload_single = async (file: File) => {
|
|
||||||
return { url: `/dog.jpg` };
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
const url = "/api/upload";
|
|
||||||
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)) {
|
|
||||||
return `_file${get(result, "[0]")}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("Upload Failed");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event.target.files) {
|
if (event.target.files) {
|
||||||
const list = [] as any[];
|
const list = [] as any[];
|
||||||
|
|
@ -70,13 +44,13 @@ export const FieldUploadMulti: FC<{
|
||||||
list.push({
|
list.push({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
data: file,
|
data: file,
|
||||||
|
[valueKey]: `${URL.createObjectURL(file)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fm.data[field.name] = list;
|
fm.data[field.name] = list;
|
||||||
fm.render();
|
fm.render();
|
||||||
on_change(fm.data?.[field.name]);
|
if (typeof on_change === "function") on_change(fm.data?.[field.name]);
|
||||||
input.fase = "start";
|
input.fase = "start";
|
||||||
input.render();
|
input.render();
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +59,111 @@ export const FieldUploadMulti: FC<{
|
||||||
input.ref.value = null;
|
input.ref.value = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex-grow flex-col flex w-full h-full items-stretch relative">
|
||||||
|
{!disabled ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<div className="flex flex-wrap py-1 pb-2">
|
||||||
|
<div className=" relative flex focus-within:border focus-within:border-primary border border-gray-300 rounded-md ">
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"hover:bg-gray-50 text-gray-900 text-md rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 ",
|
||||||
|
css`
|
||||||
|
input[type="file"],
|
||||||
|
input[type="file"]::-webkit-file-upload-button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
disabled && "bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!disabled && (
|
||||||
|
<input
|
||||||
|
ref={(ref) => {
|
||||||
|
if (ref) input.ref = ref;
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
multiple={true}
|
||||||
|
// accept={field.prop.upload?.accept}
|
||||||
|
accept={"file/**"}
|
||||||
|
onChange={on_upload}
|
||||||
|
className={cx(
|
||||||
|
"absolute w-full h-full cursor-pointer top-0 left-0 opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!disabled ? (
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
if (input.ref) {
|
||||||
|
input.ref.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="items-center flex text-base px-1 outline-none rounded cursor-pointer flex-row justify-center"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center px-2">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center text-sm">
|
||||||
|
Add File
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-row items-center px-1.5 text-sm">
|
||||||
|
-
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Array.isArray(value) && value?.length ? (
|
||||||
|
<>
|
||||||
|
{value.map((e: any, idx: number) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col" key={`files-${name}-${idx}`}>
|
||||||
|
<div className="flex flex-row items-center w-64 p-2 border rounded-lg shadow-sm bg-white">
|
||||||
|
<div className="flex flex-grow flex-row items-center">
|
||||||
|
<div className="flex flex-grow">
|
||||||
|
<FilePreviewBetter
|
||||||
|
url={e?.[valueKey]}
|
||||||
|
filename={e?.name}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="hover:bg-gray-100 p-2 rounded-lg cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
fm.data[field.name] = value.filter(
|
||||||
|
(_, i) => i !== idx
|
||||||
|
);
|
||||||
|
fm.render();
|
||||||
|
if (typeof on_change === "function")
|
||||||
|
on_change(fm.data?.[field.name]);
|
||||||
|
|
||||||
|
if (typeof onDelete === "function") onDelete(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdDelete className="w-4 h-4 text-red-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="flex-grow flex-col flex w-full h-full items-stretch p-1">
|
<div className="flex-grow flex-col flex w-full h-full items-stretch p-1">
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,836 @@
|
||||||
|
import {
|
||||||
|
FC,
|
||||||
|
KeyboardEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { useLocal } from "@/lib/utils/use-local";
|
||||||
|
import { TypeaheadOptions } from "./typeahead-opt";
|
||||||
|
import { Badge } from "../../ui/badge";
|
||||||
|
import { GoChevronDown } from "react-icons/go";
|
||||||
|
import { IoCloseOutline } from "react-icons/io5";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import uniqBy from "lodash.uniqby";
|
||||||
|
|
||||||
|
type OptItem = { value: string; label: string; tag?: string };
|
||||||
|
|
||||||
|
export const TypeaheadBetter: FC<{
|
||||||
|
fitur?: "search-add";
|
||||||
|
value?: string[] | null;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
options?: (arg: {
|
||||||
|
search: string;
|
||||||
|
existing: OptItem[];
|
||||||
|
}) => (string | OptItem)[] | Promise<(string | OptItem)[]>;
|
||||||
|
onSelect?: (arg: { search: string; item?: null | OptItem }) => string | false;
|
||||||
|
onChange?: (selected: string[]) => void;
|
||||||
|
unique?: boolean;
|
||||||
|
allowNew?: boolean;
|
||||||
|
className?: string;
|
||||||
|
popupClassName?: string;
|
||||||
|
localSearch?: boolean;
|
||||||
|
autoPopupWidth?: boolean;
|
||||||
|
focusOpen?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
mode?: "multi" | "single";
|
||||||
|
note?: string;
|
||||||
|
disabledSearch?: boolean;
|
||||||
|
onInit?: (e: any) => void;
|
||||||
|
isBetter?: boolean;
|
||||||
|
}> = ({
|
||||||
|
value,
|
||||||
|
fitur,
|
||||||
|
note,
|
||||||
|
options: options_fn,
|
||||||
|
onSelect,
|
||||||
|
unique,
|
||||||
|
allowNew: allow_new,
|
||||||
|
focusOpen: on_focus_open,
|
||||||
|
localSearch: local_search,
|
||||||
|
autoPopupWidth: auto_popup_width,
|
||||||
|
placeholder,
|
||||||
|
mode,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
popupClassName,
|
||||||
|
disabledSearch,
|
||||||
|
onInit,
|
||||||
|
isBetter = false,
|
||||||
|
}) => {
|
||||||
|
const maxLength = 4;
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [debouncedTerm, setDebouncedTerm] = useState("");
|
||||||
|
const local = useLocal({
|
||||||
|
value: [] as string[],
|
||||||
|
open: false,
|
||||||
|
options: [] as OptItem[],
|
||||||
|
loaded: false,
|
||||||
|
loading: false,
|
||||||
|
selectBetter: {
|
||||||
|
all: false,
|
||||||
|
partial: [] as any[],
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
input: "",
|
||||||
|
timeout: null as any,
|
||||||
|
searching: false,
|
||||||
|
promise: null as any,
|
||||||
|
result: null as null | OptItem[],
|
||||||
|
},
|
||||||
|
unique: typeof unique === "undefined" ? true : unique,
|
||||||
|
allow_new: typeof allow_new === "undefined" ? false : allow_new,
|
||||||
|
on_focus_open: typeof on_focus_open === "undefined" ? true : on_focus_open,
|
||||||
|
local_search: typeof local_search === "undefined" ? true : local_search,
|
||||||
|
mode: typeof mode === "undefined" ? "multi" : mode,
|
||||||
|
auto_popup_width:
|
||||||
|
typeof auto_popup_width === "undefined" ? false : auto_popup_width,
|
||||||
|
select: null as null | OptItem,
|
||||||
|
});
|
||||||
|
const input = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
let select_found = false;
|
||||||
|
let options = [...(local.search.result || local.options)];
|
||||||
|
|
||||||
|
const added = new Set<string>();
|
||||||
|
if (local.mode === "multi") {
|
||||||
|
options = options.filter((e) => {
|
||||||
|
if (!added.has(e.value)) added.add(e.value);
|
||||||
|
else return false;
|
||||||
|
if (local.select && local.select.value === e.value) select_found = true;
|
||||||
|
if (local.unique) {
|
||||||
|
if (local.value.includes(e.value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (Array.isArray(value) && value?.length) {
|
||||||
|
if (!select_found) {
|
||||||
|
local.select = options[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) return;
|
||||||
|
if (options.length === 0) {
|
||||||
|
loadOptions().then(() => {
|
||||||
|
if (typeof value === "object" && value) {
|
||||||
|
local.value = value;
|
||||||
|
local.render();
|
||||||
|
} else if (typeof value === "string") {
|
||||||
|
local.value = [value];
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (typeof value === "object" && value) {
|
||||||
|
local.value = value;
|
||||||
|
local.render();
|
||||||
|
} else {
|
||||||
|
local.value = [];
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const select = useCallback(
|
||||||
|
(arg: { search: string; item?: null | OptItem }) => {
|
||||||
|
if (!local.allow_new) {
|
||||||
|
let found = null;
|
||||||
|
if (!arg.item) {
|
||||||
|
found = options.find((e) => e.value === arg.search);
|
||||||
|
} else {
|
||||||
|
found = options.find((e) => e.value === arg.item?.value);
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (local.unique) {
|
||||||
|
let found = local.value.find((e) => {
|
||||||
|
return e === arg.item?.value || arg.search === e;
|
||||||
|
});
|
||||||
|
if (found) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.mode === "single") {
|
||||||
|
local.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof onSelect === "function") {
|
||||||
|
const result = onSelect(arg);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
local.value.push(result);
|
||||||
|
local.render();
|
||||||
|
|
||||||
|
if (typeof onChange === "function") {
|
||||||
|
onChange(local.value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let val = false as any;
|
||||||
|
if (arg.item) {
|
||||||
|
local.value.push(arg.item.value);
|
||||||
|
val = arg.item.value;
|
||||||
|
} else {
|
||||||
|
if (!arg.search) return false;
|
||||||
|
local.value.push(arg.search);
|
||||||
|
val = arg.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof onChange === "function") {
|
||||||
|
onChange(local.value);
|
||||||
|
}
|
||||||
|
local.render();
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[onSelect, local.value, options]
|
||||||
|
);
|
||||||
|
|
||||||
|
const keydown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
if (local.value.length > 0 && e.currentTarget.selectionStart === 0) {
|
||||||
|
local.value.pop();
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const selected = select({
|
||||||
|
search: local.search.input,
|
||||||
|
item: local.select,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (local.mode === "single") {
|
||||||
|
local.open = false;
|
||||||
|
}
|
||||||
|
if (typeof selected === "string") {
|
||||||
|
if (!allow_new) resetSearch();
|
||||||
|
if (local.mode === "single") {
|
||||||
|
const item = options.find((item) => item.value === selected);
|
||||||
|
if (item) {
|
||||||
|
local.search.input = item.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
local.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.length > 0) {
|
||||||
|
local.open = true;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const idx = options.findIndex((item) => {
|
||||||
|
if (item.value === local.select?.value) return true;
|
||||||
|
});
|
||||||
|
if (idx >= 0) {
|
||||||
|
if (idx + 1 <= options.length - 1) {
|
||||||
|
local.select = options[idx + 1];
|
||||||
|
} else {
|
||||||
|
local.select = options[0];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
local.select = options[0];
|
||||||
|
}
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const idx = options.findIndex((item) => {
|
||||||
|
if (item.value === local.select?.value) return true;
|
||||||
|
});
|
||||||
|
if (idx >= 0) {
|
||||||
|
if (idx - 1 >= 0) {
|
||||||
|
local.select = options[idx - 1];
|
||||||
|
} else {
|
||||||
|
local.select = options[options.length - 1];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
local.select = options[0];
|
||||||
|
}
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[local.value, local.select, select, options, local.search.input]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadOptions = useCallback(async () => {
|
||||||
|
if (typeof options_fn === "function" && !local.loading) {
|
||||||
|
local.loading = true;
|
||||||
|
local.loaded = false;
|
||||||
|
local.render();
|
||||||
|
const res = options_fn({
|
||||||
|
search: local.search.input,
|
||||||
|
existing: options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
const applyOptions = (result: (string | OptItem)[]) => {
|
||||||
|
local.options = result.map((item) => {
|
||||||
|
if (typeof item === "string") return { value: item, label: item };
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
local.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (res instanceof Promise) {
|
||||||
|
const result = await res;
|
||||||
|
applyOptions(result);
|
||||||
|
} else {
|
||||||
|
applyOptions(res);
|
||||||
|
}
|
||||||
|
local.loaded = true;
|
||||||
|
local.loading = false;
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [options_fn]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof onInit === "function") {
|
||||||
|
onInit({
|
||||||
|
reload: async () => {
|
||||||
|
if (typeof options_fn === "function" && !local.loading) {
|
||||||
|
local.loading = true;
|
||||||
|
local.loaded = false;
|
||||||
|
local.render();
|
||||||
|
const res = options_fn({
|
||||||
|
search: local.search.input,
|
||||||
|
existing: options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
const applyOptions = (result: (string | OptItem)[]) => {
|
||||||
|
local.options = result.map((item) => {
|
||||||
|
if (typeof item === "string")
|
||||||
|
return { value: item, label: item };
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
local.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (res instanceof Promise) {
|
||||||
|
const result = await res;
|
||||||
|
applyOptions(result);
|
||||||
|
} else {
|
||||||
|
applyOptions(res);
|
||||||
|
}
|
||||||
|
local.loaded = true;
|
||||||
|
local.loading = false;
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
// Debounce effect
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedTerm(searchTerm); // Update debounced term after 1 second
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer); // Clear timeout if user types again
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
// Function to handle search
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedTerm) {
|
||||||
|
performSearch(debouncedTerm);
|
||||||
|
}
|
||||||
|
}, [debouncedTerm]);
|
||||||
|
|
||||||
|
const performSearch = (value: any) => {
|
||||||
|
if (typeof onSelect === "function") {
|
||||||
|
const result = onSelect({
|
||||||
|
search: value,
|
||||||
|
item: {
|
||||||
|
label: value,
|
||||||
|
value: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
local.value.push(result);
|
||||||
|
local.render();
|
||||||
|
|
||||||
|
if (typeof onChange === "function") {
|
||||||
|
onChange(local.value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Lakukan pencarian, panggil API, atau filter data di sini
|
||||||
|
};
|
||||||
|
const resetSearch = () => {
|
||||||
|
local.search.searching = false;
|
||||||
|
local.search.input = "";
|
||||||
|
local.search.promise = null;
|
||||||
|
local.search.result = null;
|
||||||
|
local.select = null;
|
||||||
|
clearTimeout(local.search.timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (local.mode === "single" && local.value.length > 1) {
|
||||||
|
local.value = [local.value.pop() || ""];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.value.length === 0) {
|
||||||
|
if (local.mode === "single") {
|
||||||
|
if (!local.open && !allow_new) {
|
||||||
|
local.select = null;
|
||||||
|
|
||||||
|
local.search.input = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueLabel = uniqBy(
|
||||||
|
local.value?.map((value) => {
|
||||||
|
if (local.mode === "single") {
|
||||||
|
const item = options.find((item) => item.value === value);
|
||||||
|
|
||||||
|
if (!local.open && !allow_new) {
|
||||||
|
local.select = item || null;
|
||||||
|
|
||||||
|
local.search.input = item?.tag || item?.label || "";
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = local.options.find((e) => e.value === value);
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
"value"
|
||||||
|
);
|
||||||
|
let inputval = local.search.input;
|
||||||
|
|
||||||
|
if (!local.open && local.mode === "single" && local.value?.length > 0) {
|
||||||
|
const found = options.find((e) => e.value === local.value[0]);
|
||||||
|
if (found) {
|
||||||
|
inputval = found.tag || found.label;
|
||||||
|
} else {
|
||||||
|
inputval = local.value[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (allow_new && local.open) {
|
||||||
|
local.search.input = local.value[0];
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
}, [local.open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row flex-grow w-full relative">
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
allow_new
|
||||||
|
? "cursor-text"
|
||||||
|
: local.mode === "single"
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "cursor-text",
|
||||||
|
"text-black flex relative flex-wrap py-0 items-center w-full h-full flex-1 ",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) input.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{local.mode === "multi" ? (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
css`
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{valueLabel.map((e, idx) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
variant={"outline"}
|
||||||
|
className={cx(
|
||||||
|
"space-x-1 mr-2 mb-2 bg-white",
|
||||||
|
!disabled &&
|
||||||
|
" cursor-pointer hover:bg-red-100 hover:border-red-100"
|
||||||
|
)}
|
||||||
|
onClick={(ev) => {
|
||||||
|
if (!disabled) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
local.value = local.value.filter(
|
||||||
|
(val) => e?.value !== val
|
||||||
|
);
|
||||||
|
local.render();
|
||||||
|
input.current?.focus();
|
||||||
|
|
||||||
|
if (typeof onChange === "function") {
|
||||||
|
onChange(local.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xs">
|
||||||
|
{e?.tag || e?.label || <> </>}
|
||||||
|
</div>
|
||||||
|
{!disabled && <IoCloseOutline size={12} />}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
<TypeaheadOptions
|
||||||
|
fitur={fitur}
|
||||||
|
popup={true}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
local.select = null;
|
||||||
|
}
|
||||||
|
local.open = open;
|
||||||
|
local.render();
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
resetSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRemove={(data) => {
|
||||||
|
local.value = local.value.filter((val) => data?.value !== val);
|
||||||
|
local.render();
|
||||||
|
input.current?.focus();
|
||||||
|
|
||||||
|
if (typeof onChange === "function") {
|
||||||
|
onChange(local.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSelectAll={(data: boolean) => {
|
||||||
|
local.value = data ? options.map((e) => e?.value) : [];
|
||||||
|
local.render();
|
||||||
|
input.current?.focus();
|
||||||
|
if (typeof onChange === "function") {
|
||||||
|
onChange(local.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
init={local}
|
||||||
|
isBetter={isBetter}
|
||||||
|
loading={local.loading}
|
||||||
|
showEmpty={!allow_new}
|
||||||
|
className={popupClassName}
|
||||||
|
open={local.open}
|
||||||
|
options={options}
|
||||||
|
searching={local.search.searching}
|
||||||
|
searchText={local.search.input}
|
||||||
|
onSearch={async (e) => {
|
||||||
|
const val = e.currentTarget.value;
|
||||||
|
if (!local.open) {
|
||||||
|
local.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
local.search.input = val;
|
||||||
|
local.render();
|
||||||
|
|
||||||
|
if (local.search.promise) {
|
||||||
|
await local.search.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
local.search.searching = true;
|
||||||
|
local.render();
|
||||||
|
if (allow_new) {
|
||||||
|
setSearchTerm(val);
|
||||||
|
}
|
||||||
|
if (local.search.searching) {
|
||||||
|
if (local.local_search) {
|
||||||
|
if (!local.loaded) {
|
||||||
|
await loadOptions();
|
||||||
|
}
|
||||||
|
const search = local.search.input.toLowerCase();
|
||||||
|
if (search) {
|
||||||
|
local.search.result = options.filter((e) =>
|
||||||
|
e.label.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
local.search.result.length > 0 &&
|
||||||
|
!local.search.result.find(
|
||||||
|
(e) => e.value === local.select?.value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
local.search.result = null;
|
||||||
|
}
|
||||||
|
local.search.searching = false;
|
||||||
|
local.render();
|
||||||
|
} else {
|
||||||
|
clearTimeout(local.search.timeout);
|
||||||
|
local.search.timeout = setTimeout(async () => {
|
||||||
|
const result = options_fn?.({
|
||||||
|
search: local.search.input,
|
||||||
|
existing: options,
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
local.search.promise = result;
|
||||||
|
local.search.result = (await result).map((item) => {
|
||||||
|
if (typeof item === "string")
|
||||||
|
return { value: item, label: item };
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
local.search.searching = false;
|
||||||
|
local.search.promise = null;
|
||||||
|
} else {
|
||||||
|
local.search.result = result.map((item) => {
|
||||||
|
if (typeof item === "string")
|
||||||
|
return { value: item, label: item };
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
local.search.searching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
local.search.result.length > 0 &&
|
||||||
|
!local.search.result.find(
|
||||||
|
(e) => e.value === local.select?.value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (!isBetter) local.open = false;
|
||||||
|
resetSearch();
|
||||||
|
const item = options.find((item) => item.value === value);
|
||||||
|
if (item) {
|
||||||
|
let search = local.search.input;
|
||||||
|
if (local.mode === "single") {
|
||||||
|
local.search.input = item.tag || item.label;
|
||||||
|
} else {
|
||||||
|
local.search.input = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
select({
|
||||||
|
search,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
local.render();
|
||||||
|
}}
|
||||||
|
width={
|
||||||
|
local.auto_popup_width ? input.current?.offsetWidth : undefined
|
||||||
|
}
|
||||||
|
isMulti={local.mode === "multi"}
|
||||||
|
selected={({ item, options, idx }) => {
|
||||||
|
// console.log(local.select);
|
||||||
|
if (isBetter) {
|
||||||
|
const val = local.value?.length ? local.value : [];
|
||||||
|
let isSelect = options.find((e) => {
|
||||||
|
return (
|
||||||
|
e?.value === item?.value &&
|
||||||
|
val.find((ex) => ex === item?.value)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return isSelect ? true : false;
|
||||||
|
} else if (item.value === local.select?.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
allow_new ? "cursor-text" : "cursor-pointer",
|
||||||
|
"single flex-1 flex-grow flex flex-row"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!disabled) {
|
||||||
|
if (!local.open) {
|
||||||
|
if (local.on_focus_open) {
|
||||||
|
loadOptions();
|
||||||
|
local.open = true;
|
||||||
|
local.render();
|
||||||
|
// if (allow_new) {
|
||||||
|
// local.search.input = inputval;
|
||||||
|
// local.render();
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (local.mode === "single") {
|
||||||
|
if (input && input.current) input.current.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBetter ? (
|
||||||
|
<div className="h-9 flex-grow flex flex-row items-start">
|
||||||
|
<div className="flex flex-grow"></div>
|
||||||
|
<div className="h-9 flex flex-row items-center px-2">
|
||||||
|
<GoChevronDown size={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
placeholder={
|
||||||
|
local.mode === "multi"
|
||||||
|
? placeholder
|
||||||
|
: valueLabel[0]?.label || placeholder
|
||||||
|
}
|
||||||
|
type="text"
|
||||||
|
ref={input}
|
||||||
|
value={inputval}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const val = e.currentTarget.value;
|
||||||
|
if (!local.open) {
|
||||||
|
local.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
local.search.input = val;
|
||||||
|
local.render();
|
||||||
|
|
||||||
|
if (local.search.promise) {
|
||||||
|
await local.search.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
local.search.searching = true;
|
||||||
|
local.render();
|
||||||
|
if (allow_new) {
|
||||||
|
setSearchTerm(val);
|
||||||
|
}
|
||||||
|
if (local.search.searching) {
|
||||||
|
if (local.local_search) {
|
||||||
|
if (!local.loaded) {
|
||||||
|
await loadOptions();
|
||||||
|
}
|
||||||
|
const search = local.search.input.toLowerCase();
|
||||||
|
if (search) {
|
||||||
|
local.search.result = options.filter((e) =>
|
||||||
|
e.label.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
local.search.result.length > 0 &&
|
||||||
|
!local.search.result.find(
|
||||||
|
(e) => e.value === local.select?.value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
local.select = local.search.result[0];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
local.search.result = null;
|
||||||
|
}
|
||||||
|
local.search.searching = false;
|
||||||
|
local.render();
|
||||||
|
} else {
|
||||||
|
clearTimeout(local.search.timeout);
|
||||||
|
local.search.timeout = setTimeout(async () => {
|
||||||
|
const result = options_fn?.({
|
||||||
|
search: local.search.input,
|
||||||
|
existing: options,
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
local.search.promise = result;
|
||||||
|
local.search.result = (await result).map((item) => {
|
||||||
|
if (typeof item === "string")
|
||||||
|
return { value: item, label: item };
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
local.search.searching = false;
|
||||||
|
local.search.promise = null;
|
||||||
|
} else {
|
||||||
|
local.search.result = result.map((item) => {
|
||||||
|
if (typeof item === "string")
|
||||||
|
return { value: item, label: item };
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
local.search.searching = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
local.search.result.length > 0 &&
|
||||||
|
!local.search.result.find(
|
||||||
|
(e) => e.value === local.select?.value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
local.select = local.search.result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
local.render();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!disabled ? disabledSearch : disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
className={cx(
|
||||||
|
"text-black flex h-9 w-full border-input bg-transparent px-3 py-1 text-base border-none shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground md:text-sm focus:outline-none focus:ring-0",
|
||||||
|
local.mode === "single" ? "cursor-pointer" : ""
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
pointerEvents: disabledSearch ? "none" : "auto", // Mencegah input menangkap klik saat disabled
|
||||||
|
}}
|
||||||
|
onKeyDown={keydown}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TypeaheadOptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{local.mode === "single" && fitur !== "search-add" && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"typeahead-arrow absolute z-10 inset-0 left-auto flex items-center ",
|
||||||
|
" justify-center w-6 mr-1 my-2 bg-transparant",
|
||||||
|
disabled ? "hidden" : "cursor-pointer"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!disabled) {
|
||||||
|
local.value = [];
|
||||||
|
local.render();
|
||||||
|
if (typeof onChange === "function") onChange(local.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inputval ? <X size={14} /> : <GoChevronDown size={14} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -41,6 +41,7 @@ export const ListBetter: React.FC<any> = ({
|
||||||
paging: 1,
|
paging: 1,
|
||||||
maxPage: 1,
|
maxPage: 1,
|
||||||
count: 0 as any,
|
count: 0 as any,
|
||||||
|
ready: false,
|
||||||
addRow: (row: any) => {
|
addRow: (row: any) => {
|
||||||
setData((prev) => [...prev, row]);
|
setData((prev) => [...prev, row]);
|
||||||
local.data.push(row);
|
local.data.push(row);
|
||||||
|
|
@ -83,10 +84,13 @@ export const ListBetter: React.FC<any> = ({
|
||||||
{"Loading..."}
|
{"Loading..."}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
local.ready = false;
|
||||||
|
local.render();
|
||||||
if (typeof onCount === "function") {
|
if (typeof onCount === "function") {
|
||||||
const res = await onCount();
|
const res = await onCount();
|
||||||
local.count = res;
|
local.count = res;
|
||||||
|
local.maxPage = Math.ceil(res / take);
|
||||||
|
local.paging = 1;
|
||||||
local.render();
|
local.render();
|
||||||
}
|
}
|
||||||
if (Array.isArray(onLoad)) {
|
if (Array.isArray(onLoad)) {
|
||||||
|
|
@ -108,6 +112,8 @@ export const ListBetter: React.FC<any> = ({
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
local.ready = true;
|
||||||
|
local.render();
|
||||||
},
|
},
|
||||||
reload: async () => {
|
reload: async () => {
|
||||||
toast.info(
|
toast.info(
|
||||||
|
|
@ -176,9 +182,10 @@ export const ListBetter: React.FC<any> = ({
|
||||||
{"Loading..."}
|
{"Loading..."}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
local.ready = false;
|
||||||
|
local.render();
|
||||||
if (typeof onCount === "function") {
|
if (typeof onCount === "function") {
|
||||||
const res = await onCount();
|
const res = await onCount();
|
||||||
console.log(res, take, Math.ceil(res / take));
|
|
||||||
setMaxPage(Math.ceil(res / take));
|
setMaxPage(Math.ceil(res / take));
|
||||||
local.maxPage = Math.ceil(res / take);
|
local.maxPage = Math.ceil(res / take);
|
||||||
local.count = res;
|
local.count = res;
|
||||||
|
|
@ -213,6 +220,8 @@ export const ListBetter: React.FC<any> = ({
|
||||||
if (typeof onInit === "function") {
|
if (typeof onInit === "function") {
|
||||||
onInit(local);
|
onInit(local);
|
||||||
}
|
}
|
||||||
|
local.ready = true;
|
||||||
|
local.render();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
@ -246,6 +255,13 @@ export const ListBetter: React.FC<any> = ({
|
||||||
className="w-full h-full flex flex-col gap-y-4 p-4"
|
className="w-full h-full flex flex-col gap-y-4 p-4"
|
||||||
reload={reload}
|
reload={reload}
|
||||||
>
|
>
|
||||||
|
{!local.ready ? (
|
||||||
|
<>
|
||||||
|
<div className="flex-grow h-full flex-grow flex flex-row items-center justify-center">
|
||||||
|
<div className="spinner-better"></div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className="flex-grow flex flex-col gap-y-4">
|
<div className="flex-grow flex flex-col gap-y-4">
|
||||||
{Array.isArray(local.data) && local.data?.length ? (
|
{Array.isArray(local.data) && local.data?.length ? (
|
||||||
local.data?.map((e, idx) => {
|
local.data?.map((e, idx) => {
|
||||||
|
|
@ -265,6 +281,7 @@ export const ListBetter: React.FC<any> = ({
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Progress = React.forwardRef<
|
const Progress = React.forwardRef<
|
||||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||||
>(({ className, value, ...props }, ref) => (
|
classNameIndicator?: any; // Trigger to force height update
|
||||||
|
}
|
||||||
|
>(({ className, value, classNameIndicator, ...props }, ref) => (
|
||||||
<ProgressPrimitive.Root
|
<ProgressPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -17,7 +19,10 @@ const Progress = React.forwardRef<
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ProgressPrimitive.Indicator
|
<ProgressPrimitive.Indicator
|
||||||
className="h-full w-full flex-1 bg-primary transition-all indicator"
|
className={cn(
|
||||||
|
"h-full w-full flex-1 bg-linear-progress transition-all indicator rounded-full",
|
||||||
|
classNameIndicator
|
||||||
|
)}
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
/>
|
/>
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import get from "lodash.get";
|
||||||
import api from "./axios";
|
import api from "./axios";
|
||||||
|
|
||||||
type apixType = {
|
type apixType = {
|
||||||
port: "portal" | "recruitment" | "mpp" | "public";
|
port: "portal" | "recruitment" | "mpp" | "public" | "onboarding";
|
||||||
path: string;
|
path: string;
|
||||||
method?: "get" | "delete" | "post" | "put";
|
method?: "get" | "delete" | "post" | "put";
|
||||||
data?: any;
|
data?: any;
|
||||||
|
|
@ -36,6 +36,8 @@ export const apix = async ({
|
||||||
? process.env.NEXT_PUBLIC_API_RECRUITMENT
|
? process.env.NEXT_PUBLIC_API_RECRUITMENT
|
||||||
: port === "mpp"
|
: port === "mpp"
|
||||||
? process.env.NEXT_PUBLIC_API_MPP
|
? process.env.NEXT_PUBLIC_API_MPP
|
||||||
|
: port === "onboarding"
|
||||||
|
? process.env.NEXT_PUBLIC_API_ONBOARDING
|
||||||
: port === "public"
|
: port === "public"
|
||||||
? process.env.NEXT_PUBLIC_BASE_URL
|
? process.env.NEXT_PUBLIC_BASE_URL
|
||||||
: ""
|
: ""
|
||||||
|
|
@ -49,10 +51,20 @@ export const apix = async ({
|
||||||
const requestData =
|
const requestData =
|
||||||
type === "form" && data
|
type === "form" && data
|
||||||
? Object.entries(data as any).reduce((formData, [key, value]) => {
|
? Object.entries(data as any).reduce((formData, [key, value]) => {
|
||||||
|
if (Array.isArray(value) && value?.length) {
|
||||||
|
value.map((item: any) => {
|
||||||
|
formData.append(key, item as any);
|
||||||
|
});
|
||||||
|
} else if (value instanceof FormData) {
|
||||||
|
value.forEach((value, key) => {
|
||||||
|
formData.append(key, value);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
formData.append(
|
formData.append(
|
||||||
key.includes("certificate") ? key : key.replace(/\[\d+\]/, ""),
|
key.includes("certificate") ? key : key.replace(/\[\d+\]/, ""),
|
||||||
value as any
|
value as any
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return formData;
|
return formData;
|
||||||
}, new FormData())
|
}, new FormData())
|
||||||
: data;
|
: data;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
export const convertForm = ({
|
||||||
|
data,
|
||||||
|
task,
|
||||||
|
}: {
|
||||||
|
data: any[];
|
||||||
|
task: (item: any, form: any) => void;
|
||||||
|
}) => {
|
||||||
|
const form = new FormData();
|
||||||
|
if (Array.isArray(data) && data?.length) {
|
||||||
|
data.map((item: any) => {
|
||||||
|
task(item, form);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue