diff --git a/comps/custom/Breadcrumb.tsx b/comps/custom/Breadcrumb.tsx index 2faa6f5..bdef0f0 100755 --- a/comps/custom/Breadcrumb.tsx +++ b/comps/custom/Breadcrumb.tsx @@ -1,7 +1,5 @@ import { useLocal } from "@/utils/use-local"; import { FC, ReactNode, useEffect } from "react"; -import { Skeleton } from "../ui/skeleton"; -import get from "lodash.get"; import { FieldLoading } from "../ui/field-loading"; export type BreadItem = { diff --git a/comps/form/base/BaseField.tsx b/comps/form/base/BaseField.tsx index 467c5e7..0a894df 100755 --- a/comps/form/base/BaseField.tsx +++ b/comps/form/base/BaseField.tsx @@ -19,14 +19,14 @@ export const BaseField = (prop: { typeof field.prefix === "function" ? field.prefix() : typeof field.prefix === "string" - ? field.prefix - : null; + ? field.prefix + : null; const suffix = typeof field.suffix === "function" ? field.suffix() : typeof field.suffix === "string" - ? field.prefix - : null; + ? field.prefix + : null; const name = field.name; const errors = fm.error.get(name); @@ -47,9 +47,7 @@ export const BaseField = (prop: { w === "½" && "c-w-1/2", w === "⅓" && "c-w-1/3", w === "¼" && "c-w-1/4", - field.type === "link" - ? "c-flex-row c-items-stretch c-min-h-[78px]" - : "c-flex-col c-space-y-1", + "c-flex-col c-space-y-1", field.focused && "focused", field.disabled && "disabled", typeof fm.data[name] !== "undefined" && @@ -65,9 +63,7 @@ export const BaseField = (prop: { !["toogle", "button", "radio", "checkbox"].includes(arg.sub_type) ? cx( "field-outer c-overflow-hidden c-flex-1 c-flex c-flex-row c-text-sm c-bg-white", - field.type === "link" - ? " c-items-center" - : "c-border c-rounded " + "c-border c-rounded " ) : "", fm.status === "loading" @@ -75,13 +71,13 @@ export const BaseField = (prop: { border-color: transparent; ` : field.disabled - ? "c-border-gray-100" - : errors.length > 0 - ? field.focused - ? "c-border-red-600 c-bg-red-50 c-outline c-outline-red-700" - : "c-border-red-600 c-bg-red-50" - : field.focused && - "c-border-blue-700 c-outline c-outline-blue-700", + ? "c-border-gray-100" + : errors.length > 0 + ? field.focused + ? "c-border-red-600 c-bg-red-50 c-outline c-outline-red-700" + : "c-border-red-600 c-bg-red-50" + : field.focused && + "c-border-blue-700 c-outline c-outline-blue-700", css` & > .field-inner { min-height: 35px; @@ -105,7 +101,6 @@ export const BaseField = (prop: {
= (arg) => { w === "½" && "c-w-1/2", w === "⅓" && "c-w-1/3", w === "¼" && "c-w-1/4", - field.type === "link" - ? "c-flex-row c-items-stretch c-min-h-[78px]" - : "c-flex-col c-space-y-1" + "c-flex-col c-space-y-1" )} {...props} ref={typeof arg.field_ref === "function" ? arg.field_ref : undefined} diff --git a/comps/form/field/FieldInput.tsx b/comps/form/field/FieldInput.tsx index dc9d94c..88aa01e 100755 --- a/comps/form/field/FieldInput.tsx +++ b/comps/form/field/FieldInput.tsx @@ -6,6 +6,7 @@ import { TableEdit } from "./table-edit/TableEdit"; import { FieldTypeInput, PropTypeInput } from "./type/TypeInput"; import { MultiOption } from "./type/TypeMultiOption"; import { SingleOption } from "./type/TypeSingleOption"; +import { FieldLink } from "./type/TypeLink"; const modify = { timeout: null as any, @@ -108,7 +109,7 @@ export const FieldInput: FC<{ !["toogle", "button", "radio", "checkbox"].includes(arg.sub_type) ? cx( "field-outer c-overflow-hidden c-flex-1 c-flex c-flex-row c-text-sm c-bg-white", - field.type === "link" ? " c-items-center" : "c-border c-rounded " + "c-border c-rounded " ) : "", fm.status === "loading" @@ -145,7 +146,6 @@ export const FieldInput: FC<{
{custom} ) : ( <> - {type_field === "link" && <>ini link} + {type_field === "link" && ( + + )} {["date", "input"].includes(type_field) ? ( = ({ const errors = fm.error.get(field.name); return ( -
+
0 && `c-text-red-600`)}> {field.label} diff --git a/comps/form/field/type/TypeLink.tsx b/comps/form/field/type/TypeLink.tsx new file mode 100755 index 0000000..86997e5 --- /dev/null +++ b/comps/form/field/type/TypeLink.tsx @@ -0,0 +1,196 @@ +import { FieldLoading, Spinner } from "lib/comps/ui/field-loading"; +import { hashSum } from "lib/utils/hash-sum"; +import { getPathname } from "lib/utils/pathname"; +import { useLocal } from "lib/utils/use-local"; +import { ArrowUpRight } from "lucide-react"; +import { FC, ReactNode, useEffect } from "react"; +import { FMLocal, FieldLocal, FieldProp } from "../../typings"; + +export const FieldLink: FC<{ + field: FieldLocal; + fm: FMLocal; + arg: FieldProp; +}> = ({ field, fm, arg }) => { + const local = useLocal({ + text: "", + init: false, + navigating: false, + custom: false, + }); + + const Link = ({ + children, + }: { + children: (arg: { icon: any }) => ReactNode; + }) => { + return ( +
{ + if (!isEditor) { + local.navigating = true; + local.render(); + if (!(await navigateLink(arg.link, field))) { + local.navigating = false; + local.render(); + } + } + }} + > + {children({ + icon: local.navigating ? ( + + ) : ( + + ), + })} +
+ ); + }; + + useEffect(() => { + if (arg.link && !local.init) { + if (typeof arg.link.text === "string") { + local.text = arg.link.text; + local.init = true; + } else if (typeof arg.link.text === "function") { + const res = arg.link.text({ + field, + Link, + }); + if (res instanceof Promise) { + res.then((text) => { + local.text = text; + local.init = true; + local.render(); + }); + } else { + local.text = res; + local.init = true; + } + } + } + local.render(); + }, []); + + return ( +
+ {!local.init ? ( + + ) : ( + + {({ icon }) => { + return ( + <> +
{local.text}
+ {icon} + + ); + }} + + )} +
+ ); +}; + +export type LinkParam = { + url: string; + where: any; + hash: any; + prefix: { + label: any; + url: string; + md?: { name: string; value: any }; + }[]; +}; + +const navigateLink = async (link: FieldProp["link"], field: FieldLocal) => { + let params = link.params(field); + + if (typeof params === "object") { + if (params instanceof Promise) { + params = await params; + } + } + + const md = params.md; + const where = params.where; + + const prefix: LinkParam["prefix"] = []; + + if (md) { + md.header.render(); + if (md.header.breadcrumb.length > 0) { + const path = getPathname({ hash: false }); + let i = 0; + for (const b of md.header.breadcrumb) { + prefix.push({ + label: b.label, + url: `${path}`, + md: + i > 0 + ? { name: md.name, value: md.selected[md.pk?.name || ""] } + : undefined, + }); + i++; + } + } + } + + const values: LinkParam = { + url: getPathname({ hash: false }), + where, + prefix, + hash: "", + }; + const vhash = hashSum(values); + values.hash = vhash; + + if (!link.url) { + alert("No URL defined!"); + return false; + } + await api._kv("set", vhash, values); + const lnk = location.hash.split("#").find((e) => e.startsWith("lnk=")); + let prev_link = ""; + if (lnk) { + prev_link = lnk.split("=").pop() || ""; + if (prev_link) prev_link = prev_link + "+"; + } + + navigate(`${link.url}#lnk=${prev_link + vhash}`); + return true; +}; + +export const parseLink = () => { + const lnk = location.hash.split("#").find((e) => e.startsWith("lnk=")); + + if (lnk) { + const res = lnk.split("=").pop() || ""; + if (res) { + return res.split("+"); + } + } + return []; +}; +export const fetchLinkParams = async ( + parsed_link?: ReturnType +) => { + const parsed = parsed_link || parseLink(); + + return await Promise.all(parsed.map((e) => api._kv("get", e))); +}; diff --git a/comps/form/gen/gen-form.ts b/comps/form/gen/gen-form.ts index a549fbc..1c63727 100755 --- a/comps/form/gen/gen-form.ts +++ b/comps/form/gen/gen-form.ts @@ -60,11 +60,7 @@ export const generateForm = async ( after_load: ` if (typeof md === "object") { opt.fm.status = "ready"; - if (item) { - for (const [k,v] of Object.entries(item)) { - md.selected[k] = v; - } - } + md.selected = opt.fm.data; md.header.render(); md.render(); } diff --git a/comps/form/typings.ts b/comps/form/typings.ts index 94d92e3..ae4b09c 100755 --- a/comps/form/typings.ts +++ b/comps/form/typings.ts @@ -1,6 +1,7 @@ import { GFCol } from "@/gen/utils"; -import { MutableRefObject, ReactElement, ReactNode } from "react"; +import { FC, MutableRefObject, ReactElement, ReactNode } from "react"; import { editorFormData } from "./utils/ed-data"; +import { MDLocal } from "../md/utils/typings"; export type FMProps = { on_init: (arg: { fm: FMLocal; submit: any; reload: any }) => any; @@ -46,6 +47,18 @@ export type FieldProp = { label: string; desc?: string; props?: any; + link: { + text: + | string + | ((arg: { + field: FieldLocal; + Link: FC<{ children: any; }>; + }) => Promise | string); + url: string; + params: ( + field: FieldLocal + ) => { md: MDLocal; where: any } | Promise<{ md: MDLocal; where: any }>; + }; fm: FMLocal; type: FieldType | (() => FieldType); required: ("y" | "n") | (() => "y" | "n"); @@ -142,7 +155,7 @@ export type FMInternal = { soft_delete: { field: any; }; - has_fields_container: boolean, + has_fields_container: boolean; }; export type FMLocal = FMInternal & { render: () => void }; diff --git a/comps/md/MasterDetail.tsx b/comps/md/MasterDetail.tsx index e2a4107..0b4b14b 100755 --- a/comps/md/MasterDetail.tsx +++ b/comps/md/MasterDetail.tsx @@ -12,7 +12,6 @@ import { } from "./utils/md-hash"; import { mdRenderLoop } from "./utils/md-render-loop"; import { MDLocalInternal, MDProps } from "./utils/typings"; -import { any } from "zod"; export const MasterDetail: FC = (arg) => { const { @@ -36,8 +35,8 @@ export const MasterDetail: FC = (arg) => { status: isEditor ? "init" : "ready", actions: [], header: { + loading: false, breadcrumb: [], - internalRender() {}, render: () => {}, master: { prefix: null, suffix: null }, child: { prefix: null, suffix: null }, @@ -60,6 +59,7 @@ export const MasterDetail: FC = (arg) => { item: _item, }, params: { + links: [], hash: {}, tabs: {}, parse: () => { @@ -93,7 +93,9 @@ export const MasterDetail: FC = (arg) => { if (pk) { const value = md.params.hash[md.name]; if (value) { - md.selected = { [pk.name]: value }; + if (!md.selected) { + md.selected = { [pk.name]: value }; + } const tab = md.params.tabs[md.name]; if (tab && md.tab.list.includes(tab)) { md.tab.active = tab; diff --git a/comps/md/gen/md-form.ts b/comps/md/gen/md-form.ts index 79e21f8..ab982c2 100755 --- a/comps/md/gen/md-form.ts +++ b/comps/md/gen/md-form.ts @@ -78,7 +78,7 @@ export const generateMDForm = async ( if (Object.keys(md.selected).length === 0){ breads.push({ label: "Add New" }); } else { - breads.push({ label: "Edit" }); + breads.push({ label: guessLabel(md.selected) || "Detail" }); } } } diff --git a/comps/md/gen/md-gen.ts b/comps/md/gen/md-gen.ts index f12d2f2..caf073e 100755 --- a/comps/md/gen/md-gen.ts +++ b/comps/md/gen/md-gen.ts @@ -1,7 +1,7 @@ import { GenFn } from "lib/gen/utils"; import { generateMDForm } from "./md-form"; import { generateMDList } from "./md-list"; - +import capitalize from "lodash.capitalize"; const w = window as any; export const generateMasterDetail: GenFn<{ item: PrasiItem; @@ -16,7 +16,9 @@ export const generateMasterDetail: GenFn<{ ); const title = fn_title(); if (!title && item.edit.props?.gen_table) { - item.edit.setProp('title', item.edit.props?.gen_table) + const table = { ...item.edit.props?.gen_table }; + table.value = `${capitalize(table.value as string)}`; + item.edit.setProp("title", table); } } catch (e) {} diff --git a/comps/md/parts/MDDetail.tsx b/comps/md/parts/MDDetail.tsx index 57bd285..210cefc 100755 --- a/comps/md/parts/MDDetail.tsx +++ b/comps/md/parts/MDDetail.tsx @@ -1,7 +1,8 @@ +import { useLocal } from "lib/utils/use-local"; import { FC, useEffect } from "react"; +import { breadcrumbPrefix } from "../utils/md-hash"; import { MDLocal, MDRef } from "../utils/typings"; import { MDHeader } from "./MDHeader"; -import { useLocal } from "lib/utils/use-local"; export const should_show_tab = (md: MDLocal) => { if (isEditor) { @@ -114,19 +115,8 @@ export const MDRenderTab: FC<{ on_init: () => MDLocal; breadcrumb: () => Array; }> = ({ child, on_init, breadcrumb }) => { - const local = useLocal({ md: null as null | MDLocal }); - if (!local.md) { - local.md = on_init(); - } - const md = local.md; - - md.header.render = () => { - md.header.breadcrumb = breadcrumb(); - md.header.internalRender(); - }; - useEffect(() => { - md.header.render(); - }, Object.values(md.deps || {}) || []); + const md = on_init(); + md.header.child.breadcrumb = breadcrumb; return <>{child}; }; diff --git a/comps/md/parts/MDHeader.tsx b/comps/md/parts/MDHeader.tsx index 4fcfa71..9d482ec 100755 --- a/comps/md/parts/MDHeader.tsx +++ b/comps/md/parts/MDHeader.tsx @@ -1,11 +1,19 @@ import { FC, useState } from "react"; +import { breadcrumbPrefix } from "../utils/md-hash"; import { MDLocal, MDRef } from "../utils/typings"; export const MDHeader: FC<{ md: MDLocal; mdr: MDRef }> = ({ md, mdr }) => { const [_, set] = useState({}); const head = mdr.item.edit.props?.header.value; const PassProp = mdr.PassProp; - md.header.internalRender = () => set({}); md.header.render = () => set({}); + + const prefix = breadcrumbPrefix(md); + if (md.selected && md.header.child.breadcrumb) { + md.header.breadcrumb = [...prefix, ...md.header.child.breadcrumb()]; + } else if (!md.selected && md.header.master.breadcrumb) { + md.header.breadcrumb = [...prefix, ...md.header.master.breadcrumb()]; + } + return {head}; }; diff --git a/comps/md/parts/MDMaster.tsx b/comps/md/parts/MDMaster.tsx index 4fdacf2..8cc1737 100755 --- a/comps/md/parts/MDMaster.tsx +++ b/comps/md/parts/MDMaster.tsx @@ -1,7 +1,7 @@ +import { useLocal } from "lib/utils/use-local"; import { FC, useEffect } from "react"; import { MDLocal, MDRef } from "../utils/typings"; import { MDHeader } from "./MDHeader"; -import { useLocal } from "lib/utils/use-local"; const w = window as unknown as { md_panel_master: any; @@ -18,14 +18,9 @@ export const MDRenderMaster: FC<{ local.md = on_init(); } const md = local.md; - - md.header.render = () => { - md.header.breadcrumb = breadcrumb(); - md.header.internalRender(); - }; + md.header.master.breadcrumb = breadcrumb; useEffect(() => { - md.header.render(); if (md) { let width = 0; let min_width = 0; diff --git a/comps/md/utils/editor-init.tsx b/comps/md/utils/editor-init.tsx index 8ec7523..d355954 100755 --- a/comps/md/utils/editor-init.tsx +++ b/comps/md/utils/editor-init.tsx @@ -40,7 +40,6 @@ export const editorMDInit = (md: MDLocal, mdr: MDRef, arg: MDProps) => { ]; md.status = "unready"; } else { - // md.header.breadcrumb = []; md.status = "ready"; } }; diff --git a/comps/md/utils/md-hash.ts b/comps/md/utils/md-hash.ts index 345c86b..d9ca93b 100755 --- a/comps/md/utils/md-hash.ts +++ b/comps/md/utils/md-hash.ts @@ -1,4 +1,6 @@ +import { fetchLinkParams, parseLink } from "lib/comps/form/field/type/TypeLink"; import { MDLocal } from "./typings"; +import { BreadItem } from "lib/comps/custom/Breadcrumb"; export const masterDetailParseHash = (md: MDLocal) => { let raw_hash = decodeURIComponent(location.hash); @@ -21,6 +23,27 @@ export const masterDetailParseHash = (md: MDLocal) => { } } } + + const parsed_link = parseLink(); + let changed = parsed_link.length !== md.params.links.length; + + if (!changed) { + for (let i = 0; i < parsed_link.length; i++) { + if (parsed_link[i] !== md.params.links[i].hash) { + changed = true; + } + } + } + + if (changed) { + md.params.links = []; + md.header.loading = true; + fetchLinkParams(parsed_link).then((links) => { + md.params.links = links; + md.header.loading = false; + md.header.render(); + }); + } }; export const masterDetailApplyParams = (md: MDLocal) => { @@ -56,3 +79,40 @@ export const masterDetailApplyParams = (md: MDLocal) => { location.hash = hash; } }; + +export const breadcrumbPrefix = (md: MDLocal) => { + const prefix: BreadItem[] = []; + if (md.params.links && md.params.links.length > 0) { + const hashes: string[] = []; + for (const link of md.params.links) { + if (!hashes.includes(link.hash)) { + hashes.push(link.hash); + } + } + for (const link of md.params.links) { + for (const p of link.prefix) { + prefix.push({ + label: p.label, + onClick(ev) { + let url = ""; + + const hashIndex = hashes.indexOf(link.hash); + const link_hashes = hashes.slice(0, hashIndex).join("+"); + const lnk = link_hashes ? `#lnk=${link_hashes}` : ``; + + if (p.md) { + url = `${link.url}#${p.md.name}=${p.md.value}${lnk}`; + } else { + url = `${link.url}${lnk}`; + } + + if (url) { + navigate(url); + } + }, + }); + } + } + } + return prefix; +}; diff --git a/comps/md/utils/typings.ts b/comps/md/utils/typings.ts index 2126c6d..d11cbef 100755 --- a/comps/md/utils/typings.ts +++ b/comps/md/utils/typings.ts @@ -1,6 +1,7 @@ import { BreadItem } from "@/comps/custom/Breadcrumb"; import { FMLocal } from "@/comps/form/typings"; import { GFCol } from "@/gen/utils"; +import { LinkParam } from "lib/comps/form/field/type/TypeLink"; import { ReactNode } from "react"; type ID_MASTER_DETAIL = string; @@ -38,11 +39,11 @@ export type MDLocalInternal = { title: string; status: "init" | "unready" | "ready"; header: { + loading: boolean; breadcrumb: BreadItem[]; - internalRender: () => void; render: () => void; - master: { prefix: any; suffix: any }; - child: { prefix: any; suffix: any }; + master: { prefix: any; suffix: any; breadcrumb?: () => BreadItem[] }; + child: { prefix: any; suffix: any; breadcrumb?: () => BreadItem[] }; }; actions: MDActions; selected: any; @@ -53,7 +54,8 @@ export type MDLocalInternal = { internal: { action_should_refresh: boolean }; master: { render: () => void }; params: { - hash: any; + links: LinkParam[]; + hash: Record; tabs: any; parse: () => void; apply: () => void; diff --git a/comps/ui/field-loading.tsx b/comps/ui/field-loading.tsx index e842e98..2617c27 100755 --- a/comps/ui/field-loading.tsx +++ b/comps/ui/field-loading.tsx @@ -1,28 +1,41 @@ import { Skeleton } from "@/comps/ui/skeleton"; +import { cn } from "lib/utils"; +import { Loader2 } from "lucide-react"; +import { FC } from "react"; -export const FieldLoading = () => { +export const FieldLoading: FC<{ height?: "normal" | "short" }> = (prop) => { + let height = "10px"; + if (prop.height === "short") height = "6px"; return (
); }; + +export const Spinner = ({ className }: { className?: string }) => { + return ( +
+ +
+ ); +}; diff --git a/exports.tsx b/exports.tsx index cdda649..a42afb9 100755 --- a/exports.tsx +++ b/exports.tsx @@ -1,5 +1,7 @@ export { FieldLoading } from "@/comps/ui/field-loading"; import { lazify, lazifyMany } from "@/utils/lazify"; +export { guessLabel } from "./utils/guess-label"; +export { fetchLinkParams } from "./comps/form/field/type/TypeLink"; export { prasi_gen } from "./gen/prasi_gen"; export const Popover = lazify( diff --git a/utils/guess-label.ts b/utils/guess-label.ts new file mode 100755 index 0000000..d1c766d --- /dev/null +++ b/utils/guess-label.ts @@ -0,0 +1,27 @@ +import { validate } from "uuid"; + +export const guessLabel = (_obj: Record, key?: string) => { + let label = ""; + let obj = _obj; + if (key) obj = _obj[key]; + + const label_key = + Object.keys(obj) + .map((e) => e.toLowerCase()) + .find((e) => e.includes("name") || e.includes("nama")) || ""; + + label = obj[label_key]; + if (obj.length > 1) { + for (const v of Object.values(obj)) { + if (typeof v === "string" && v.length >= 2 && !validate(v)) { + label = v; + } + } + } + + if (typeof label === "string" && label.length > 10) { + label = label.substring(0, 10) + "…"; + } + + return label; +}; diff --git a/utils/pathname.ts b/utils/pathname.ts index ed4b360..ba2f060 100755 --- a/utils/pathname.ts +++ b/utils/pathname.ts @@ -1,4 +1,4 @@ -export const getPathname = (url?: string) => { +export const getPathname = (opt?: { hash?: boolean }) => { if ( ["prasi.avolut.com"].includes(location.hostname) || location.host === "localhost:4550" @@ -9,11 +9,8 @@ export const getPathname = (url?: string) => { location.pathname.startsWith("/deploy") ) { const hash = location.hash; - if (url?.startsWith("/prod")) { - return "/" + url.split("/").slice(3).join("/"); - } - if (hash !== "") { + if (hash !== "" && opt?.hash !== false) { return "/" + location.pathname.split("/").slice(3).join("/") + hash; } else { return "/" + location.pathname.split("/").slice(3).join("/");