diff --git a/AGENTS.md b/AGENTS.md index 1f403927e..762dbc378 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,6 @@ - `npm run check` (project lint pipeline). - `npx eslint src --ext .ts,.tsx` (scope this to touched files when practical). - `npm run build:tsc` for the TypeScript check that respects `tsconfig.build.json`. - - `npm run build:vite` to ensure the library bundles cleanly. `npm run build` executes both checks in parallel if preferred. - `npm run check` only triggers lint-staged; it does **not** cover the required TypeScript or ESLint passes. - `npm run build:tsc` runs `tsc -p tsconfig.build.json --noEmit`, satisfying the `npx tsc --noEmit` requirement. - Fix every ESLint warning and TypeScript error before handing work back. diff --git a/package-lock.json b/package-lock.json index fde6a0194..9feab2ac8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@gisce/react-formiga-components": "1.18.0", "@gisce/react-formiga-table": "1.16.1", "@monaco-editor/react": "^4.4.5", + "@tanstack/react-virtual": "^3.13.12", "@types/deep-equal": "^1.0.4", "antd": "5.25.1", "buffer": "^6.0.3", @@ -4054,6 +4055,31 @@ "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", "dev": true }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/package.json b/package.json index ff5e9cace..43b02a4ac 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@gisce/react-formiga-components": "1.18.0", "@gisce/react-formiga-table": "1.16.1", "@monaco-editor/react": "^4.4.5", + "@tanstack/react-virtual": "^3.13.12", "@types/deep-equal": "^1.0.4", "antd": "5.25.1", "buffer": "^6.0.3", diff --git a/src/actionbar/ChangeViewButton.tsx b/src/actionbar/ChangeViewButton.tsx index 1c2cc9563..de0eb464f 100644 --- a/src/actionbar/ChangeViewButton.tsx +++ b/src/actionbar/ChangeViewButton.tsx @@ -6,6 +6,7 @@ import { FormOutlined, AreaChartOutlined, CalendarOutlined, + BorderOuterOutlined, } from "@ant-design/icons"; import { useLocale, @@ -30,6 +31,7 @@ const iconsForViewTypes = { form: , graph: , calendar: , + kanban: , }; function getIconForView(view?: View) { diff --git a/src/actionbar/TreeActionBar.tsx b/src/actionbar/TreeActionBar.tsx index f85ca2882..5c7cfdf0f 100644 --- a/src/actionbar/TreeActionBar.tsx +++ b/src/actionbar/TreeActionBar.tsx @@ -73,15 +73,15 @@ function TreeActionBarComponent({ duplicatingItem, setDuplicatingItem, currentModel, - searchTreeRef, + viewRef, setCurrentId, setCurrentItemIndex, searchParams, searchVisible, setSearchVisible, - setSearchTreeNameSearch, - searchTreeNameSearch, - treeIsLoading, + setSearchNameSearch, + searchNameSearch, + viewIsLoading, setPreviousView, previousView, results, @@ -106,8 +106,8 @@ function TreeActionBarComponent({ const { showErrorNotification } = useErrorNotification(); const handleRefresh = useCallback(() => { - searchTreeRef?.current?.refreshResults(); - }, [searchTreeRef]); + viewRef?.current?.refreshResults(); + }, [viewRef]); const handleToggleSearch = useCallback(() => { setSearchVisible?.(!searchVisible); @@ -118,7 +118,7 @@ function TreeActionBarComponent({ model: currentModel, view_id: currentView?.view_id, treeView: currentView, - disabled: treeIsLoading, + disabled: viewIsLoading, parentContext, selectedRowItems, onRefreshParentValues: handleRefresh, @@ -132,10 +132,8 @@ function TreeActionBarComponent({ }); const hasNameSearch = useMemo( - () => - searchTreeNameSearch !== undefined && - searchTreeNameSearch.trim().length > 0, - [searchTreeNameSearch], + () => searchNameSearch !== undefined && searchNameSearch.trim().length > 0, + [searchNameSearch], ); const finalDomain = useMemo(() => { @@ -152,7 +150,7 @@ function TreeActionBarComponent({ context: { ...parentContext }, }); if (newId) { - searchTreeRef?.current?.refreshResults(); + viewRef?.current?.refreshResults(); } } catch (e) { showErrorNotification(e); @@ -162,7 +160,7 @@ function TreeActionBarComponent({ }, [ currentModel, parentContext, - searchTreeRef, + viewRef, selectedRowItems, setDuplicatingItem, showErrorNotification, @@ -178,7 +176,7 @@ function TreeActionBarComponent({ }); setCurrentId?.(undefined); setCurrentItemIndex?.(undefined); - searchTreeRef?.current?.refreshResults(); + viewRef?.current?.refreshResults(); } catch (e) { showErrorNotification(e); } finally { @@ -187,7 +185,7 @@ function TreeActionBarComponent({ }, [ currentModel, parentContext, - searchTreeRef, + viewRef, selectedRowItems, setCurrentId, setCurrentItemIndex, @@ -205,32 +203,28 @@ function TreeActionBarComponent({ const handleSearch = useCallback( (searchString?: string) => { - if (searchString === searchTreeNameSearch) { + if (searchString === searchNameSearch) { return; } - if ( - searchString && - searchString.trim().length > 0 && - !searchTreeNameSearch - ) { + if (searchString && searchString.trim().length > 0 && !searchNameSearch) { setSearchParams?.([]); setSearchValues?.({}); } - setSearchTreeNameSearch?.(searchString); - if (searchTreeNameSearch !== undefined) { + setSearchNameSearch?.(searchString); + if (searchNameSearch !== undefined) { setTimeout(() => { - searchTreeRef?.current?.refreshResults(); + viewRef?.current?.refreshResults(); }, 50); } }, [ - searchTreeNameSearch, - setSearchTreeNameSearch, + searchNameSearch, + setSearchNameSearch, setSearchParams, setSearchValues, - searchTreeRef, + viewRef, ], ); @@ -273,17 +267,17 @@ function TreeActionBarComponent({ ); useEffect(() => { - if (treeType === "infinite" && searchTreeNameSearch === undefined) { + if (treeType === "infinite" && searchNameSearch === undefined) { if (isFirstMount.current) { isFirstMount.current = false; return; } setTimeout(() => { - searchTreeRef?.current?.refreshResults(); + viewRef?.current?.refreshResults(); }, 0); } - }, [treeType, searchTreeNameSearch, searchTreeRef]); + }, [treeType, searchNameSearch, viewRef]); useHotkeys( "ctrl+l,command+l", @@ -326,7 +320,7 @@ function TreeActionBarComponent({ return ( - {treeIsLoading && ( + {viewIsLoading && ( <> @@ -336,8 +330,8 @@ function TreeActionBarComponent({ {!treeExpandable && ( <> {savedSearchesEnabled && treeType !== "legacy" ? ( @@ -346,7 +340,7 @@ function TreeActionBarComponent({ searchVisible={!!searchVisible} onToggleSearch={handleToggleSearch} searchParams={searchParams} - disabled={duplicatingItem || removingItem || treeIsLoading} + disabled={duplicatingItem || removingItem || viewIsLoading} onApplySearch={handleRefresh} onRefetchSavedSearches={onRefetchSavedSearches} onClearSavedSearch={onClearSavedSearch} @@ -361,12 +355,12 @@ function TreeActionBarComponent({ tooltip={t("advanced_search")} type={searchVisible ? "primary" : "default"} onClick={() => setSearchVisible?.(!searchVisible)} - disabled={duplicatingItem || removingItem || treeIsLoading} + disabled={duplicatingItem || removingItem || viewIsLoading} badgeNumber={searchParams?.length} /> )} - + } tooltip={t("duplicate")} @@ -374,7 +368,7 @@ function TreeActionBarComponent({ !selectedRowItems || selectedRowItems?.length !== 1 || duplicatingItem || - treeIsLoading || + viewIsLoading || !permissions?.create } loading={duplicatingItem} @@ -385,7 +379,7 @@ function TreeActionBarComponent({ tooltip={t("delete")} disabled={ !(selectedRowItems && selectedRowItems?.length > 0) || - treeIsLoading || + viewIsLoading || !permissions?.unlink } loading={removingItem} @@ -398,14 +392,14 @@ function TreeActionBarComponent({ icon={} tooltip={t("showLogs")} disabled={ - !(selectedRowItems && selectedRowItems?.length === 1) || treeIsLoading + !(selectedRowItems && selectedRowItems?.length === 1) || viewIsLoading } onClick={() => showLogInfo(currentModel!, selectedRowItems![0].id, t)} /> } tooltip={t("refresh")} - disabled={duplicatingItem || removingItem || treeIsLoading} + disabled={duplicatingItem || removingItem || viewIsLoading} onClick={handleRefresh} /> {!treeExpandable && ( @@ -416,7 +410,7 @@ function TreeActionBarComponent({ availableViews={availableViews} onChangeView={handleChangeView} previousView={previousView} - disabled={treeIsLoading} + disabled={viewIsLoading} /> )} @@ -446,7 +440,7 @@ function TreeActionBarComponent({ ]} onItemClick={handleExportAction} disabled={ - duplicatingItem || removingItem || treeIsLoading || hasNameSearch + duplicatingItem || removingItem || viewIsLoading || hasNameSearch } /> void; availableViews: View[]; formRef: any; - searchTreeRef: any; + viewRef: any; onNewClicked: () => void; currentId?: number; setCurrentId: (id?: number) => void; @@ -35,8 +42,8 @@ type ActionViewProviderProps = { ) => void; selectedRowItems?: any[]; setSelectedRowItems: (value: any[] | ((prevValue: any[]) => any[])) => void; - setSearchTreeNameSearch: (searchString?: string) => void; - searchTreeNameSearch?: string; + setSearchNameSearch: (searchString?: string) => void; + searchNameSearch?: string; goToResourceId: (ids: number[], openInSameTab?: boolean) => Promise; limit?: number; isActive: boolean; @@ -62,8 +69,8 @@ export type ActionViewContextType = Omit< setRemovingItem?: (value: boolean) => void; formIsLoading?: boolean; setFormIsLoading?: (value: boolean) => void; - treeIsLoading?: boolean; - setTreeIsLoading?: (value: boolean) => void; + viewIsLoading?: boolean; + setViewIsLoading?: (value: boolean) => void; graphIsLoading?: boolean; setGraphIsLoading?: (value: boolean) => void; attachments?: any; @@ -118,7 +125,7 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { setCurrentView, availableViews, formRef, - searchTreeRef, + viewRef: searchTreeRef, onNewClicked, currentId, setCurrentId, @@ -133,8 +140,8 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { setTotalItems, setSelectedRowItems, selectedRowItems, - searchTreeNameSearch, - setSearchTreeNameSearch, + searchNameSearch: searchTreeNameSearch, + setSearchNameSearch: setSearchTreeNameSearch, goToResourceId, limit: limitProps, isActive, @@ -150,7 +157,7 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { const [formHasChanges, setFormHasChanges] = useState(false); const [removingItem, setRemovingItem] = useState(false); const [formIsLoading, setFormIsLoading] = useState(true); - const [treeIsLoading, setTreeIsLoading] = useState(true); + const [viewIsLoading, setViewIsLoading] = useState(true); const [attachments, setAttachments] = useState([]); const [duplicatingItem, setDuplicatingItem] = useState(false); const [searchParams, setSearchParams] = useState( @@ -160,6 +167,11 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { const [graphIsLoading, setGraphIsLoading] = useState(true); const [previousView, setPreviousView] = useState(); + const wrappedOnNewClicked = useCallback(() => { + setPreviousView(currentView); + onNewClicked(); + }, [currentView, onNewClicked, setPreviousView]); + // Memoized merged fields from all available views const allViewFields = useMemo(() => { if (!availableViews || availableViews.length === 0) { @@ -249,9 +261,9 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { formHasChanges, setFormHasChanges, formRef, - searchTreeRef, + viewRef: searchTreeRef, onFormSave: callOnFormSave, - onNewClicked, + onNewClicked: wrappedOnNewClicked, currentId, setCurrentId, currentItemIndex, @@ -263,8 +275,8 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { setRemovingItem, formIsLoading, setFormIsLoading, - treeIsLoading, - setTreeIsLoading, + viewIsLoading, + setViewIsLoading, attachments, setAttachments, selectedRowItems, @@ -279,8 +291,8 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { setSorter, totalItems, setTotalItems, - searchTreeNameSearch, - setSearchTreeNameSearch, + searchNameSearch: searchTreeNameSearch, + setSearchNameSearch: setSearchTreeNameSearch, setGraphIsLoading, graphIsLoading, previousView, @@ -334,7 +346,7 @@ export const useActionViewContext = () => { setCurrentView: () => {}, availableViews: [], formRef: { current: null }, - searchTreeRef: { current: null }, + viewRef: { current: null }, onNewClicked: () => {}, currentId: undefined, setCurrentId: () => {}, @@ -349,8 +361,8 @@ export const useActionViewContext = () => { setTotalItems: () => {}, selectedRowItems: [], setSelectedRowItems: () => {}, - setSearchTreeNameSearch: () => {}, - searchTreeNameSearch: undefined, + setSearchNameSearch: () => {}, + searchNameSearch: undefined, goToResourceId: async () => {}, limit: DEFAULT_SEARCH_LIMIT, isActive: undefined, @@ -363,8 +375,8 @@ export const useActionViewContext = () => { setRemovingItem: () => {}, formIsLoading: false, setFormIsLoading: () => {}, - treeIsLoading: false, - setTreeIsLoading: () => {}, + viewIsLoading: false, + setViewIsLoading: () => {}, graphIsLoading: false, setGraphIsLoading: () => {}, attachments: [], diff --git a/src/helpers/kanbanHelper.ts b/src/helpers/kanbanHelper.ts new file mode 100644 index 000000000..9e0630952 --- /dev/null +++ b/src/helpers/kanbanHelper.ts @@ -0,0 +1,87 @@ +export const normalizeColumnValue = ( + value: any, + fieldDefinition: any, + t: (key: string) => string, +): { id: string; label: string; originalValue: any } | null => { + if (value === null || value === undefined) { + if (fieldDefinition?.type !== "boolean") { + return null; + } + } + + const fieldType = fieldDefinition?.type; + + switch (fieldType) { + case "many2one": + if (Array.isArray(value) && value.length === 2) { + return { + id: value[0], + label: value[1], + originalValue: value, + }; + } + return null; + + case "selection": { + let selectionKey: any; + let selectionLabel: string; + + if (Array.isArray(value) && value.length === 2) { + selectionKey = value[0]; + selectionLabel = value[1]; + } else { + selectionKey = value; + const selectionValues = + fieldDefinition?.selection || fieldDefinition?.selectionValues; + if (selectionValues) { + const found = selectionValues.find( + ([id]: [any, string]) => id === selectionKey, + ); + selectionLabel = found ? found[1] : String(selectionKey); + } else { + selectionLabel = String(selectionKey); + } + } + + return { + id: selectionKey, + label: selectionLabel, + originalValue: selectionKey, + }; + } + + case "boolean": { + const boolValue = + value === true || value === 1 || value === "true" || value === "1"; + return { + id: String(boolValue), + label: boolValue ? t("yes") : t("no"), + originalValue: boolValue, + }; + } + + case "reference": { + if (typeof value === "string" && value.includes(",")) { + const [, idPart] = value.split(","); + return { + id: idPart, + label: value, + originalValue: value, + }; + } + return null; + } + + default: { + if (value === null || value === undefined) { + return null; + } + const stringValue = String(value); + return { + id: stringValue, + label: stringValue, + originalValue: value, + }; + } + } +}; diff --git a/src/hooks/useActionViewSavedSearches.tsx b/src/hooks/useActionViewSavedSearches.tsx new file mode 100644 index 000000000..bfa4fa324 --- /dev/null +++ b/src/hooks/useActionViewSavedSearches.tsx @@ -0,0 +1,251 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useLocale } from "@gisce/react-formiga-components"; +import { Tooltip, theme } from "antd"; +import { FilterOutlined, CloseOutlined } from "@ant-design/icons"; +import deepEqual from "deep-equal"; +import { useNetworkRequest } from "@/hooks/useNetworkRequest"; +import { useFeatureIsEnabled, useConfigContext } from "@/context/ConfigContext"; +import { ErpFeatureKeys } from "@/models/erpFeature"; +import ConnectionProvider from "@/ConnectionProvider"; +import { useActionViewContext } from "@/context/ActionViewContext"; + +const { useToken } = theme; + +export type UseActionViewSavedSearchesParams = { + model: string; + context: any; + viewRef: any; + setSearchParams?: (params: any) => void; + setSearchValues?: (values: any) => void; + setSearchVisible?: (visible: boolean) => void; +}; + +export type UseActionViewSavedSearchesReturn = { + fetchSavedSearches: () => Promise; + handleClearSavedSearch: () => void; + handleOpenSidebar: () => void; + subtitle: React.ReactNode; + savedSearchesEnabled: boolean; +}; + +export const useActionViewSavedSearches = ({ + model, + context, + viewRef, + setSearchParams, + setSearchValues, + setSearchVisible, +}: UseActionViewSavedSearchesParams): UseActionViewSavedSearchesReturn => { + const { t } = useLocale(); + const { token } = useToken(); + const { globalValues } = useConfigContext(); + + const savedSearchesEnabled = useFeatureIsEnabled( + ErpFeatureKeys.FEATURE_SAVED_SEARCHES, + ); + + const { + currentSavedSearch, + setCurrentSavedSearch, + setSavedSearches, + savedSearches, + searchParams, + isActive, + } = useActionViewContext(); + + const [searchAllIdsRequest] = useNetworkRequest( + ConnectionProvider.getHandler().searchAllIds, + ); + const [readObjectsRequest] = useNetworkRequest( + ConnectionProvider.getHandler().readEvalUiObjects, + ); + + const fetchSavedSearches = useCallback(async () => { + if (!savedSearchesEnabled || !model) { + setSavedSearches?.([]); + setCurrentSavedSearch?.(null); + return; + } + + try { + const searchIds = await searchAllIdsRequest({ + params: [ + ["model", "=", model], + ["create_uid", "=", globalValues?.uid], + ], + model: "ir.search", + order: "last_run desc", + context, + }); + + if (searchIds.length === 0) { + setSavedSearches?.([]); + setCurrentSavedSearch?.(null); + return; + } + + const [searches] = await readObjectsRequest({ + model: "ir.search", + ids: searchIds, + fieldsToRetrieve: ["id", "model", "domain", "name", "last_run"], + context, + }); + + setSavedSearches?.(searches); + } catch (error) { + console.error("Error fetching saved searches:", error); + setSavedSearches?.([]); + setCurrentSavedSearch?.(null); + } + }, [ + savedSearchesEnabled, + model, + context, + globalValues, + searchAllIdsRequest, + readObjectsRequest, + setSavedSearches, + setCurrentSavedSearch, + ]); + + const handleClearSavedSearch = useCallback(() => { + setCurrentSavedSearch?.(null); + setSearchParams?.([]); + setSearchValues?.({}); + + setTimeout(() => { + viewRef?.current?.refreshResults(); + }, 100); + }, [setCurrentSavedSearch, setSearchParams, setSearchValues, viewRef]); + + const handleOpenSidebar = useCallback(() => { + setSearchVisible?.(true); + }, [setSearchVisible]); + + // Fetch saved searches on mount + useEffect(() => { + fetchSavedSearches(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Fetch saved searches when view becomes active + const wasActiveRef = useRef(isActive); + useEffect(() => { + if (isActive && !wasActiveRef.current && savedSearchesEnabled) { + fetchSavedSearches(); + } + wasActiveRef.current = isActive; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isActive]); + + // Auto-match current search params to saved searches + useEffect(() => { + if ( + savedSearchesEnabled && + savedSearches && + savedSearches.length > 0 && + searchParams && + !currentSavedSearch + ) { + // Find a saved search that matches current search params + const matchingSavedSearch = savedSearches.find((savedSearch: any) => + deepEqual(savedSearch.domain, searchParams), + ); + + if (matchingSavedSearch) { + setCurrentSavedSearch?.(matchingSavedSearch); + } + } + }, [ + savedSearchesEnabled, + savedSearches, + searchParams, + currentSavedSearch, + setCurrentSavedSearch, + ]); + + // Create subtitle with saved search badge + const subtitle = useMemo(() => { + return currentSavedSearch?.name ? ( +
+ +
{t("openSavedSearchInSidebar")}
+
+ {currentSavedSearch.name} +
+
+ } + > +
+ + + {currentSavedSearch.name} + +
+ + + + + + ) : null; + }, [ + currentSavedSearch?.name, + token.colorPrimary, + token.colorText, + handleOpenSidebar, + handleClearSavedSearch, + t, + ]); + + return { + fetchSavedSearches, + handleClearSavedSearch, + handleOpenSidebar, + subtitle, + savedSearchesEnabled, + }; +}; diff --git a/src/hooks/useAvailableHeight.ts b/src/hooks/useAvailableHeight.ts index f9c77d43d..b85b7e587 100644 --- a/src/hooks/useAvailableHeight.ts +++ b/src/hooks/useAvailableHeight.ts @@ -4,10 +4,12 @@ export const useAvailableHeight = ({ elementRef, offset = 0, dependencies = [], + observedRefs = [], }: { elementRef: RefObject; offset?: number; dependencies?: React.DependencyList; + observedRefs?: Array>; }): number => { const [availableHeight, setAvailableHeight] = useState(0); @@ -24,7 +26,23 @@ export const useAvailableHeight = ({ updateHeight(); window.addEventListener("resize", updateHeight); - return () => window.removeEventListener("resize", updateHeight); + // Set up ResizeObserver for elements that can affect the available height + const resizeObservers: ResizeObserver[] = []; + + observedRefs.forEach((ref) => { + if (ref.current) { + const observer = new ResizeObserver(() => { + updateHeight(); + }); + observer.observe(ref.current); + resizeObservers.push(observer); + } + }); + + return () => { + window.removeEventListener("resize", updateHeight); + resizeObservers.forEach((observer) => observer.disconnect()); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [elementRef, ...dependencies]); diff --git a/src/hooks/useProcessAction.ts b/src/hooks/useProcessAction.ts new file mode 100644 index 000000000..24b2110a4 --- /dev/null +++ b/src/hooks/useProcessAction.ts @@ -0,0 +1,69 @@ +import { useContext, useCallback } from "react"; +import { ContentRootContext } from "@/context/ContentRootContext"; + +export interface UseProcessActionParams { + model?: string; + fields?: any; + values?: any; + getValues?: () => any; + context?: any; + onRefreshParentValues?: () => Promise; +} + +export interface ProcessActionResult { + runAction: (params: { + actionData: any; + additionalContext?: any; + overrideValues?: any; + overrideFields?: any; + }) => Promise<{ closeParent?: boolean }>; +} + +export function useProcessAction({ + fields, + values, + getValues, + context, + onRefreshParentValues, +}: UseProcessActionParams): ProcessActionResult { + const contentRootContext = useContext(ContentRootContext); + const { processAction } = contentRootContext || {}; + + const runAction = useCallback( + async ({ + actionData, + additionalContext = {}, + overrideValues, + overrideFields, + }: { + actionData: any; + additionalContext?: any; + overrideValues?: any; + overrideFields?: any; + }): Promise<{ closeParent?: boolean }> => { + const finalValues = + overrideValues !== undefined + ? overrideValues + : getValues + ? getValues() + : values; + + const result = + (await processAction?.({ + actionData, + fields: overrideFields !== undefined ? overrideFields : fields, + values: finalValues, + context: { + ...context, + ...additionalContext, + }, + onRefreshParentValues, + })) || {}; + + return result; + }, + [processAction, fields, values, getValues, context, onRefreshParentValues], + ); + + return { runAction }; +} diff --git a/src/hooks/useSearchTreeState.ts b/src/hooks/useSearchTreeState.ts index 326276998..98accf473 100644 --- a/src/hooks/useSearchTreeState.ts +++ b/src/hooks/useSearchTreeState.ts @@ -82,8 +82,8 @@ export function useSearchTreeState({ // Return either context values or local state values based on isUnderActionViewContext return isUnderActionViewContext ? { - treeIsLoading: actionViewContext.treeIsLoading ?? false, - setTreeIsLoading: actionViewContext.setTreeIsLoading ?? (() => {}), + treeIsLoading: actionViewContext.viewIsLoading ?? false, + setTreeIsLoading: actionViewContext.setViewIsLoading ?? (() => {}), searchVisible: actionViewContext.searchVisible ?? false, setSearchVisible: actionViewContext.setSearchVisible ?? (() => {}), selectedRowItems: actionViewContext.selectedRowItems || [], @@ -99,9 +99,9 @@ export function useSearchTreeState({ setSearchParams: actionViewContext.setSearchParams ?? (() => {}), searchValues: actionViewContext.searchValues || {}, setSearchValues: actionViewContext.setSearchValues ?? (() => {}), - searchTreeNameSearch: actionViewContext.searchTreeNameSearch, + searchTreeNameSearch: actionViewContext.searchNameSearch, setSearchTreeNameSearch: - actionViewContext.setSearchTreeNameSearch ?? (() => {}), + actionViewContext.setSearchNameSearch ?? (() => {}), results: actionViewContext.results || [], setResults: actionViewContext.setResults ?? (() => {}), searchQuery: actionViewContext.searchQuery, diff --git a/src/locales/ca_ES.ts b/src/locales/ca_ES.ts index 7f0e5c650..9558d2a98 100644 --- a/src/locales/ca_ES.ts +++ b/src/locales/ca_ES.ts @@ -135,4 +135,15 @@ export default { favouriteName: "Nom del preferit", saveFavourite: "Desar preferit", enterFavouriteName: "Introdueix el nom del preferit", + wip_limit: "Màxim de targetes per columna", + unlimited: "Il·limitat", + over_limit: "Sobre el límit", + no_records: "Sense registres", + wip_limit_reached: "Màxim de targetes assolit per aquesta columna", + card_moved_successfully: "Targeta moguda correctament", + error_moving_card: "Error en moure la targeta", + no_data: "Sense dades", + error_parsing_kanban_view: "Error en parsejar la vista kanban", + error_loading_kanban_data: "Error en carregar les dades kanban", + add_card: "Afegir targeta", }; diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 4f83c9dc4..dd0b02a74 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -132,4 +132,15 @@ export default { favouriteName: "Favourite name", saveFavourite: "Save favourite", enterFavouriteName: "Enter favourite name", + wip_limit: "Max cards per column", + unlimited: "Unlimited", + over_limit: "Over limit", + no_records: "No records", + wip_limit_reached: "Maximum cards reached for this column", + card_moved_successfully: "Card moved successfully", + error_moving_card: "Error moving card", + no_data: "No data", + error_parsing_kanban_view: "Error parsing kanban view", + error_loading_kanban_data: "Error loading kanban data", + add_card: "Add card", }; diff --git a/src/locales/es_ES.ts b/src/locales/es_ES.ts index 36e31db0d..eaf72da93 100644 --- a/src/locales/es_ES.ts +++ b/src/locales/es_ES.ts @@ -137,4 +137,15 @@ export default { favouriteName: "Nombre del favorito", saveFavourite: "Guardar favorito", enterFavouriteName: "Introduce el nombre del favorito", + wip_limit: "Máximo de tarjetas por columna", + unlimited: "Ilimitado", + over_limit: "Sobre el límite", + no_records: "Sin registros", + wip_limit_reached: "Máximo de tarjetas alcanzado para esta columna", + card_moved_successfully: "Tarjeta movida correctamente", + error_moving_card: "Error al mover la tarjeta", + no_data: "Sin datos", + error_parsing_kanban_view: "Error al parsear la vista kanban", + error_loading_kanban_data: "Error al cargar los datos kanban", + add_card: "Añadir tarjeta", }; diff --git a/src/types/index.ts b/src/types/index.ts index 5e07d0d2f..9b9817b1c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -84,7 +84,19 @@ export type GraphView = BaseViewExtra & { search_fields?: SearchFields; }; -export type View = TreeView | FormView | DashboardView | GraphView; +export type KanbanView = BaseView & { + arch: string; + fields: any; + column_field: string; + drag?: boolean; + sort?: boolean; + set_max_cards?: boolean; + colors?: string; + toolbar?: any; + search_fields?: SearchFields; +}; + +export type View = TreeView | FormView | DashboardView | GraphView | KanbanView; type SearchResponse = { totalItems: () => Promise; @@ -307,10 +319,7 @@ type ConnectionProviderType = { }, requestConfig?: any, ) => Promise; - getView: ( - options: GetViewRequest, - requestConfig?: any, - ) => Promise; + getView: (options: GetViewRequest, requestConfig?: any) => Promise; getFields: (options: GetFieldsRequest, requestConfig?: any) => Promise; searchAllIds: ( options: SearchAllIdsRequest, @@ -331,6 +340,7 @@ type ConnectionProviderType = { options: DeleteObjectsRequest, requestConfig?: any, ) => Promise; + rawExecute: (options: ExecuteRequest, requestConfig?: any) => Promise; execute: (options: ExecuteRequest, requestConfig?: any) => Promise; readObjects: ( options: ReadObjectsRequest, @@ -452,7 +462,7 @@ type ConnectionProviderType = { ) => Promise>; }; -type ViewType = "tree" | "form" | "dashboard" | "graph" | "calendar"; +type ViewType = "tree" | "form" | "dashboard" | "graph" | "calendar" | "kanban"; type ViewTuple = [number | undefined, ViewType]; type ActionInfo = { diff --git a/src/ui/TitleHeader.tsx b/src/ui/TitleHeader.tsx index 004fc0c24..5059cc024 100644 --- a/src/ui/TitleHeader.tsx +++ b/src/ui/TitleHeader.tsx @@ -66,7 +66,10 @@ const TitleHeader: React.FC = ({ ); } - if (currentView?.type === "tree" && selectedRowItems?.length) { + if ( + (currentView?.type === "tree" || currentView?.type === "kanban") && + selectedRowItems?.length + ) { if (selectedRowItems.length === 1) { return ( <> @@ -87,6 +90,14 @@ const TitleHeader: React.FC = ({ ); } + if (currentView?.type === "kanban" && totalItems !== undefined) { + return ( + <> + {totalItems} {totalItems === 1 ? t("register") : t("registers")} + + ); + } + return null; }, [ showSummary, diff --git a/src/views/ActionView.tsx b/src/views/ActionView.tsx index 631c3f2b3..3923f0679 100644 --- a/src/views/ActionView.tsx +++ b/src/views/ActionView.tsx @@ -15,6 +15,7 @@ import { FormView, GraphView, InitialViewData, + KanbanView, TreeView, View, ViewType, @@ -35,6 +36,7 @@ import { GraphActionView } from "@/views/actionViews/GraphActionView"; import { FormActionView } from "./actionViews/FormActionView"; import { TreeActionView } from "./actionViews/TreeActionView"; import { DashboardActionView } from "./actionViews/DashboardActionView"; +import { KanbanActionView } from "./actionViews/KanbanActionView"; import { resolveViewInfoPromises } from "@/helpers/viewHelper"; import { useDeepCompareEffect } from "use-deep-compare"; import { useAutoUpdateUrlAndTitle } from "@/hooks/useAutoUpdateUrlAndTitle"; @@ -119,7 +121,7 @@ function ActionView(props: Props, ref: any) { }); const formRef = useRef(); - const searchTreeRef = useRef(); + const viewRef = useRef(); const tabManagerContext = useContext( TabManagerContext, @@ -252,6 +254,14 @@ function ActionView(props: Props, ref: any) { }); break; } + case "kanban": { + viewDataRetrieved.push({ + ...(viewInfo as KanbanView), + type: viewType, + extra: { action_id, action_type }, + }); + break; + } default: break; } @@ -480,7 +490,7 @@ function ActionView(props: Props, ref: any) { setCurrentView={setCurrentView} availableViews={availableViews} formRef={formRef} - searchTreeRef={searchTreeRef} + viewRef={viewRef} onNewClicked={onNewClicked} currentId={currentId} setCurrentId={setCurrentId} @@ -495,8 +505,8 @@ function ActionView(props: Props, ref: any) { setTotalItems={setTotalItems} selectedRowItems={selectedRowItems} setSelectedRowItems={setSelectedRowItems} - setSearchTreeNameSearch={setSearchTreeNameSearch} - searchTreeNameSearch={searchTreeNameSearch} + setSearchNameSearch={setSearchTreeNameSearch} + searchNameSearch={searchTreeNameSearch} goToResourceId={goToResourceId} limit={limit} isActive={tabKey === activeKey} @@ -521,8 +531,8 @@ function ActionView(props: Props, ref: any) { setCurrentItemIndex={setCurrentItemIndex} formForcedValues={formForcedValues} limit={limit} - searchTreeRef={searchTreeRef} - searchTreeNameSearch={searchTreeNameSearch} + viewRef={viewRef} + searchNameSearch={searchTreeNameSearch} setCurrentView={setCurrentView} setCurrentId={setCurrentId} /> @@ -552,8 +562,8 @@ const ActionViewContent = ({ setCurrentItemIndex, formForcedValues, limit, - searchTreeRef, - searchTreeNameSearch, + viewRef, + searchNameSearch, setCurrentView, setCurrentId, }: { @@ -571,8 +581,8 @@ const ActionViewContent = ({ setCurrentId: any; setCurrentView: any; limit?: number; - searchTreeRef: React.RefObject; - searchTreeNameSearch?: string; + viewRef: React.RefObject; + searchNameSearch?: string; formForcedValues: any; }) => { useAutoUpdateUrlAndTitle(); @@ -616,8 +626,8 @@ const ActionViewContent = ({ domain={domain} formView={availableViews.find((v) => v.type === "form") as FormView} treeView={view as TreeView} - searchTreeRef={searchTreeRef} - searchTreeNameSearch={searchTreeNameSearch} + viewRef={viewRef} + searchNameSearch={searchNameSearch} availableViews={availableViews} results={results} setCurrentItemIndex={setCurrentItemIndex} @@ -660,6 +670,23 @@ const ActionViewContent = ({ /> ); } + case "kanban": { + return ( + + ); + } } }); }; diff --git a/src/views/actionViews/GraphActionView.tsx b/src/views/actionViews/GraphActionView.tsx index 4f5211438..0771be232 100644 --- a/src/views/actionViews/GraphActionView.tsx +++ b/src/views/actionViews/GraphActionView.tsx @@ -65,8 +65,8 @@ export const GraphActionView = (props: GraphActionViewProps) => { sorter = undefined, setSorter = undefined, setTotalItems: setActionViewTotalItems = undefined, - setSearchTreeNameSearch = undefined, - setTreeIsLoading = undefined, + setSearchNameSearch = undefined, + setViewIsLoading = undefined, limit, setLimit, searchParams, @@ -74,7 +74,7 @@ export const GraphActionView = (props: GraphActionViewProps) => { setSearchValues, currentView, totalItems, - searchTreeNameSearch, + searchNameSearch, } = actionViewContext || {}; const [applyLimit, setApplyLimit] = useState(true); @@ -97,7 +97,7 @@ export const GraphActionView = (props: GraphActionViewProps) => { return; } const allRowsResults = await ConnectionProvider.getHandler().searchAllIds({ - params: searchTreeNameSearch ? domain : mergedParams, + params: searchNameSearch ? domain : mergedParams, model, context, totalItems, @@ -105,7 +105,7 @@ export const GraphActionView = (props: GraphActionViewProps) => { setManualIds(allRowsResults); }, [ visible, - searchTreeNameSearch, + searchNameSearch, domain, mergedParams, model, @@ -138,7 +138,7 @@ export const GraphActionView = (props: GraphActionViewProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ visible, - searchTreeNameSearch, + searchNameSearch, domain, mergedParams, totalItems, @@ -162,13 +162,13 @@ export const GraphActionView = (props: GraphActionViewProps) => { const { clear, searchFilterLoading, searchError, offset, tableRefreshing } = useSearch({ model, - setSearchTreeNameSearch, + setSearchTreeNameSearch: setSearchNameSearch, setSelectedRowItems, searchParams, setSearchValues, setSearchParams, setSearchVisible, - setTreeIsLoading, + setTreeIsLoading: setViewIsLoading, context, formView: formView!, treeView: treeView!, diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx new file mode 100644 index 000000000..bdb0d3334 --- /dev/null +++ b/src/views/actionViews/KanbanActionView.tsx @@ -0,0 +1,361 @@ +import { Fragment, useCallback, useState, memo, useMemo, useRef } from "react"; +import { FormView, KanbanView, TreeView, View } from "@/types"; +import TitleHeader from "@/ui/TitleHeader"; +import TreeActionBar from "@/actionbar/TreeActionBar"; +import { KanbanComponent, KanbanRef } from "@/widgets/views/Kanban/Kanban"; +import { useActionViewContext } from "@/context/ActionViewContext"; +import { KanbanRecord, ColumnDefinition } from "@/widgets/views/Kanban/types"; +import { FormModal } from "@/widgets/modals/FormModal"; +import { Kanban } from "@gisce/ooui"; +import { SearchTreeHeader } from "@/widgets/views/SearchTreeHeader"; +import { SideSearchFilter } from "@/widgets/views/searchFilter/SideSearchFilter"; +import { NameSearchWarning } from "@/widgets/views/Tree/NameSearchWarning"; +import { useSearchTreeState } from "@/hooks/useSearchTreeState"; +import { mergeSearchFields } from "@/helpers/formHelper"; +import { useAvailableHeight } from "@/hooks/useAvailableHeight"; +import { useActionViewSavedSearches } from "@/hooks/useActionViewSavedSearches"; +import { normalizeColumnValue } from "@/helpers/kanbanHelper"; +import { useLocale } from "@gisce/react-formiga-components"; + +const HEIGHT_OFFSET = 10; + +export type KanbanActionViewProps = { + kanbanView: KanbanView; + visible: boolean; + model: string; + domain: any; + context: any; + availableViews: View[]; + viewRef: any; +}; + +const KanbanActionViewComponent = (props: KanbanActionViewProps) => { + const { + visible, + kanbanView, + model, + context, + domain, + availableViews, + viewRef, + } = props; + + const { setViewIsLoading } = useActionViewContext(); + const { t } = useLocale(); + + const { + searchVisible, + setSearchVisible, + selectedRowItems, + setSelectedRowItems, + searchParams, + setSearchParams, + searchValues, + setSearchValues, + searchTreeNameSearch, + setSearchTreeNameSearch, + } = useSearchTreeState({ useLocalState: false }); + + const [showFormModal, setShowFormModal] = useState(false); + const [selectedRecord, setSelectedRecord] = useState< + KanbanRecord | undefined + >(); + const [creatingInColumn, setCreatingInColumn] = + useState(null); + const [totalRows, setTotalRows] = useState(null); + + const kanbanRef = viewRef as React.RefObject; + const containerRef = useRef(null); + const titleHeaderRef = useRef(null); + const searchHeaderRef = useRef(null); + const availableHeight = useAvailableHeight({ + elementRef: containerRef, + offset: HEIGHT_OFFSET, + dependencies: [searchHeaderRef.current], + observedRefs: [titleHeaderRef, searchHeaderRef], + }); + + const containerStyle = useMemo( + () => ({ + overflow: "hidden", + height: `${availableHeight}px`, + minHeight: `${availableHeight}px`, + maxHeight: `${availableHeight}px`, + }), + [availableHeight], + ); + + const kanbanColumnField = useMemo(() => { + if (!kanbanView.arch || !kanbanView.fields) { + return null; + } + try { + const kanban = new Kanban(kanbanView.fields); + kanban.parse(kanbanView.arch); + return kanban.column_field; + } catch { + return null; + } + }, [kanbanView.arch, kanbanView.fields]); + + const getColumnIdFromValue = useCallback( + (value: any, fieldName: string | null): string | null => { + if (value === null || value === undefined || !fieldName) { + return null; + } + + const fieldDef = kanbanView.fields?.[fieldName]; + if (!fieldDef) { + return null; + } + + const normalized = normalizeColumnValue(value, fieldDef, t); + return normalized?.id ?? null; + }, + [kanbanView.fields, t], + ); + + const handleCardClick = useCallback((record: KanbanRecord) => { + setSelectedRecord(record); + setShowFormModal(true); + }, []); + + const handleAddCard = useCallback((column: ColumnDefinition) => { + setCreatingInColumn(column); + setSelectedRecord(undefined); + setShowFormModal(true); + }, []); + + const handleCardValuesChanged = useCallback( + (id?: number, values?: any, oldRecord?: KanbanRecord) => { + if (!id || !values || !oldRecord) { + return; + } + + if (kanbanColumnField) { + const oldColumnValue = oldRecord[kanbanColumnField]; + const newColumnValue = values[kanbanColumnField]; + + if (newColumnValue !== undefined && oldColumnValue !== newColumnValue) { + const oldColumnId = getColumnIdFromValue( + oldColumnValue, + kanbanColumnField, + ); + const newColumnId = getColumnIdFromValue( + newColumnValue, + kanbanColumnField, + ); + + if (oldColumnId && newColumnId) { + const columnsToRefresh = + oldColumnId === newColumnId + ? [oldColumnId] + : [oldColumnId, newColumnId]; + kanbanRef.current?.refreshColumns(columnsToRefresh); + } else { + kanbanRef.current?.refreshResults(); + } + return; + } + } + + kanbanRef.current?.refreshResults(); + }, + [kanbanColumnField, getColumnIdFromValue, kanbanRef], + ); + + const onCancelFormModal = useCallback( + (params?: { id?: number; values?: any }) => { + setShowFormModal(false); + const oldRecord = selectedRecord; + setSelectedRecord(undefined); + setCreatingInColumn(null); + handleCardValuesChanged(params?.id, params?.values, oldRecord); + }, + [selectedRecord, handleCardValuesChanged], + ); + + const onFormModalSubmitSucceed = useCallback( + (id?: number, values?: any) => { + setShowFormModal(false); + + if (creatingInColumn && kanbanColumnField) { + kanbanRef.current?.refreshColumns([creatingInColumn.id]); + setCreatingInColumn(null); + } else { + const oldRecord = selectedRecord; + setSelectedRecord(undefined); + handleCardValuesChanged(id, values, oldRecord); + } + }, + [ + creatingInColumn, + kanbanColumnField, + kanbanRef, + selectedRecord, + handleCardValuesChanged, + ], + ); + + const handleTotalRowsChange = useCallback((total: number) => { + setTotalRows(total); + }, []); + + const { fetchSavedSearches, handleClearSavedSearch, subtitle } = + useActionViewSavedSearches({ + model, + context, + viewRef: kanbanRef, + setSearchParams, + setSearchValues, + setSearchVisible, + }); + + const onSideSearchFilterClose = useCallback( + () => setSearchVisible?.(false), + [setSearchVisible], + ); + + const onSideSearchFilterSubmit = useCallback( + ({ params, values, closeSidebar = true }: any) => { + setSelectedRowItems?.([]); + setSearchTreeNameSearch?.(undefined); + setSearchParams?.(params); + setSearchValues?.(values); + if (closeSidebar) { + setSearchVisible?.(false); + } + }, + [ + setSelectedRowItems, + setSearchTreeNameSearch, + setSearchParams, + setSearchValues, + setSearchVisible, + ], + ); + + const onSideSearchFilterClear = useCallback(() => { + setSearchParams?.([]); + setSearchValues?.({}); + setSearchVisible?.(false); + }, [setSearchParams, setSearchValues, setSearchVisible]); + + const formView = useMemo( + () => availableViews.find((v) => v.type === "form") as FormView, + [availableViews], + ); + + const treeView = useMemo( + () => availableViews.find((v) => v.type === "tree") as TreeView | undefined, + [availableViews], + ); + + const sideSearchFilterProps = useMemo( + () => ({ + isOpen: searchVisible || false, + fields: { + ...formView?.fields, + ...treeView?.fields, + ...kanbanView?.fields, + }, + searchFields: mergeSearchFields([ + formView?.search_fields, + treeView?.search_fields, + kanbanView?.search_fields, + ]), + searchValues, + currentModel: model, + context, + }), + [ + searchVisible, + formView, + treeView, + kanbanView, + searchValues, + model, + context, + ], + ); + + const selectedRowKeys = useMemo(() => { + return selectedRowItems?.map((item: any) => item.id) || []; + }, [selectedRowItems]); + + const shouldShowNameSearchWarning = + searchTreeNameSearch && totalRows !== undefined && totalRows !== null; + + if (!visible) { + return null; + } + + return ( + + +
+ + + +
+
+ setSearchVisible?.(true)} + /> + ) : undefined + } + /> +
+
+ +
+ {formView && ( + + )} +
+ ); +}; + +export const KanbanActionView = memo(KanbanActionViewComponent); diff --git a/src/views/actionViews/TreeActionView.tsx b/src/views/actionViews/TreeActionView.tsx index 70207c967..3b69470a1 100644 --- a/src/views/actionViews/TreeActionView.tsx +++ b/src/views/actionViews/TreeActionView.tsx @@ -6,7 +6,6 @@ import { useCallback, useContext, useEffect, - useMemo, useRef, useState, } from "react"; @@ -15,28 +14,20 @@ import { ActionViewContextType, useActionViewContext, } from "@/context/ActionViewContext"; -import { Tooltip, theme } from "antd"; -import { FilterOutlined, CloseOutlined } from "@ant-design/icons"; import { SearchTreeInfinite } from "@/widgets/views/SearchTreeInfinite"; import SearchTree from "@/widgets/views/SearchTree"; import { SearchTreePaginated } from "@/widgets/views/Tree/Paginated/SearchTreePaginated"; import { useDeepCompareEffect } from "use-deep-compare"; -import { useConfigContext, useFeatureIsEnabled } from "@/context/ConfigContext"; +import { useConfigContext } from "@/context/ConfigContext"; import { DEFAULT_SEARCH_LIMIT } from "@/models/constants"; -import { useLocale } from "@gisce/react-formiga-components"; -import ConnectionProvider from "@/ConnectionProvider"; -import { useNetworkRequest } from "@/hooks/useNetworkRequest"; -import deepEqual from "deep-equal"; -import { ErpFeatureKeys } from "@/models/erpFeature"; import { determineTreeType, isTreeExpandable } from "@/helpers/treeHelper"; - -const { useToken } = theme; +import { useActionViewSavedSearches } from "@/hooks/useActionViewSavedSearches"; export type TreeActionViewProps = { formView: FormView; treeView: TreeView; visible: boolean; - searchTreeRef: any; + viewRef: any; model: string; domain: any; context: any; @@ -45,7 +36,7 @@ export type TreeActionViewProps = { setCurrentId: (id?: number) => void; setCurrentView: (view: View) => void; availableViews: View[]; - searchTreeNameSearch?: string; + searchNameSearch?: string; limit?: number; }; @@ -55,7 +46,7 @@ export const DEFAULT_TREE_TYPE: TreeType = "legacy"; export const TreeActionView = (props: TreeActionViewProps) => { const { visible, - searchTreeRef, + viewRef, model, context, formView, @@ -66,13 +57,13 @@ export const TreeActionView = (props: TreeActionViewProps) => { setCurrentId, setCurrentView, availableViews, - searchTreeNameSearch, + searchNameSearch, limit, } = props; const previousVisibleRef = useRef(visible); const [treeType, setTreeType] = useState(DEFAULT_TREE_TYPE); - const { treeMaxLimit, globalValues } = useConfigContext(); + const { treeMaxLimit } = useConfigContext(); const { setLimit } = useActionViewContext(); @@ -91,125 +82,26 @@ export const TreeActionView = (props: TreeActionViewProps) => { setPreviousView, setTreeType: setContextTreeType, setSelectedRowItems, - currentSavedSearch, - setCurrentSavedSearch, - setSavedSearches, - savedSearches, setSearchVisible, setSearchParams, setSearchValues, - searchParams, - isActive, } = useContext(ActionViewContext) as ActionViewContextType; - const { token } = useToken(); - const { t } = useLocale(); - - const savedSearchesEnabled = useFeatureIsEnabled( - ErpFeatureKeys.FEATURE_SAVED_SEARCHES, - ); - - const [searchAllIdsRequest] = useNetworkRequest( - ConnectionProvider.getHandler().searchAllIds, - ); - const [readObjectsRequest] = useNetworkRequest( - ConnectionProvider.getHandler().readEvalUiObjects, - ); - - const fetchSavedSearches = useCallback(async () => { - if (!savedSearchesEnabled || !model) { - setSavedSearches?.([]); - setCurrentSavedSearch?.(null); - return []; - } - - try { - const searchIds = await searchAllIdsRequest({ - params: [ - ["model", "=", model], - ["create_uid", "=", globalValues?.uid], - ], - model: "ir.search", - order: "last_run desc", - context, - }); - - if (searchIds.length === 0) { - setSavedSearches?.([]); - setCurrentSavedSearch?.(null); - return []; - } - - const [searches] = await readObjectsRequest({ - model: "ir.search", - ids: searchIds, - fieldsToRetrieve: ["id", "model", "domain", "name", "last_run"], - context, - }); - setSavedSearches?.(searches); - return searches || []; - } catch (error) { - console.error("Error fetching saved searches:", error); - setSavedSearches?.([]); - setCurrentSavedSearch?.(null); - return []; - } - }, [ - savedSearchesEnabled, - model, - context, - globalValues, - searchAllIdsRequest, - readObjectsRequest, - setSavedSearches, - setCurrentSavedSearch, - ]); + const { fetchSavedSearches, handleClearSavedSearch, subtitle } = + useActionViewSavedSearches({ + model, + context, + viewRef, + setSearchParams, + setSearchValues, + setSearchVisible, + }); useEffect(() => { setContextTreeType?.(treeType); // eslint-disable-next-line react-hooks/exhaustive-deps }, [treeType]); - useEffect(() => { - fetchSavedSearches(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const wasActiveRef = useRef(isActive); - - useEffect(() => { - if (isActive && !wasActiveRef.current && savedSearchesEnabled) { - fetchSavedSearches(); - } - wasActiveRef.current = isActive; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isActive]); - - useEffect(() => { - if ( - savedSearchesEnabled && - savedSearches && - savedSearches.length > 0 && - searchParams && - !currentSavedSearch - ) { - // Find a saved search that matches current search params - const matchingSavedSearch = savedSearches.find((savedSearch: any) => - deepEqual(savedSearch.domain, searchParams), - ); - - if (matchingSavedSearch) { - setCurrentSavedSearch?.(matchingSavedSearch); - } - } - }, [ - savedSearchesEnabled, - savedSearches, - searchParams, - currentSavedSearch, - setCurrentSavedSearch, - ]); - const onRowClicked = useCallback( (event: any) => { const { id } = event; @@ -256,96 +148,6 @@ export const TreeActionView = (props: TreeActionViewProps) => { [limit, setLimit], ); - const handleClearSavedSearch = useCallback(() => { - setCurrentSavedSearch?.(null); - setSearchParams?.([]); - setSearchValues?.({}); - - setTimeout(() => { - searchTreeRef?.current?.refreshResults(); - }, 100); - }, [setCurrentSavedSearch, setSearchParams, setSearchValues, searchTreeRef]); - - const handleOpenSidebar = useCallback(() => { - setSearchVisible?.(true); - }, [setSearchVisible]); - - const subtitle = useMemo(() => { - return currentSavedSearch?.name ? ( -
- -
{t("openSavedSearchInSidebar")}
-
- {currentSavedSearch.name} -
-
- } - > -
- - - {currentSavedSearch.name} - -
- - - - - - ) : null; - }, [ - currentSavedSearch?.name, - token.colorPrimary, - token.colorText, - handleOpenSidebar, - handleClearSavedSearch, - t, - ]); - if (!visible) { return null; } @@ -364,7 +166,7 @@ export const TreeActionView = (props: TreeActionViewProps) => { {treeType === "infinite" && ( { )} {treeType === "paginated" && ( { )} {treeType === "legacy" && ( (); const originalFormValues = useRef({}); + const initialFormValues = useRef(null); const lastAssignedValues = useRef({}); const warningIsShown = useRef(false); const formSubmitting = useRef(false); @@ -173,7 +175,7 @@ function Form(props: FormProps, ref: any) { const contentRootContext = useContext( ContentRootContext, ) as ContentRootContextType; - const { processAction, globalValues } = contentRootContext || {}; + const { globalValues } = contentRootContext || {}; const { onActionTriggered } = useConfigContext(); @@ -423,6 +425,65 @@ function Form(props: FormProps, ref: any) { return values; }, [getCurrentValues, getAdditionalValues, fields]); + const onRefreshParentValues = useCallback(async () => { + mustFetchParentValues.current = true; + await fetchValues({ forceRefresh: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { runAction: runActionFromHook } = useProcessAction({ + fields, + getValues, + context: parentContext, + onRefreshParentValues, + }); + + const runAction = useCallback( + async ({ + actionData, + context = {}, + }: { + actionData: any; + context?: any; + }) => { + const { closeParent } = await runActionFromHook({ + actionData, + additionalContext: { + ...formOoui?.context, + ...context, + }, + }); + + if (!rootForm && closeParent) { + onSubmitSucceed?.(getCurrentId(), getValues(), getFormValues()); + } + }, + [ + runActionFromHook, + formOoui?.context, + rootForm, + onSubmitSucceed, + getCurrentId, + getValues, + getFormValues, + ], + ); + + const runActionButton = useCallback( + async ({ action, context }: { action: string; context: any }) => { + const actionData = ( + await ConnectionProvider.getHandler().readObjects({ + model: MODEL_ACTIONS, + ids: [parseInt(action)], + context: parentContext, + }) + )[0]; + + await runAction({ actionData, context }); + }, + [runAction, parentContext], + ); + const onCancel = useCallback(() => { if (mustFetchParentValues.current) { onMustRefreshParent?.(); @@ -532,6 +593,10 @@ function Form(props: FormProps, ref: any) { originalFormValues.current = processValues(values, _fields); + if (initialFormValues.current === null) { + initialFormValues.current = processValues(values, _fields); + } + assignNewValuesToForm({ values, fields: _fields, @@ -760,6 +825,20 @@ function Form(props: FormProps, ref: any) { } if (!formHasChanges() && getCurrentId()! && callOnSubmitSucceed) { + const currentVals = getCurrentValues(fields); + const touchedFromInitial = getTouchedValues({ + source: initialFormValues.current || originalFormValues.current, + target: currentVals, + fields, + }); + + if (Object.keys(touchedFromInitial).length > 0) { + formSubmitting.current = false; + setFormHasChanges?.(false); + onSubmitSucceed?.(getCurrentId(), getValues(), getFormValues()); + return { succeed: true, id: getCurrentId()! }; + } + formSubmitting.current = false; setFormHasChanges?.(false); onCancel?.(); @@ -1110,52 +1189,6 @@ function Form(props: FormProps, ref: any) { } } - async function runActionButton({ - action, - context, - }: { - action: string; - context: any; - }) { - const actionData = ( - await ConnectionProvider.getHandler().readObjects({ - model: MODEL_ACTIONS, - ids: [parseInt(action)], - context: parentContext, - }) - )[0]; - - await runAction({ actionData, context }); - } - - async function runAction({ - actionData, - context = {}, - }: { - actionData: any; - context?: any; - }) { - const { closeParent } = - (await processAction?.({ - actionData, - fields, - values: getValues(), - context: { - ...parentContext, - ...formOoui?.context, - ...context, - }, - onRefreshParentValues: async () => { - mustFetchParentValues.current = true; - await fetchValues({ forceRefresh: true }); - }, - })) || {}; - - if (!rootForm && closeParent) { - onSubmitSucceed?.(getCurrentId(), getValues(), getFormValues()); - } - } - function elementHasLostFocus() { checkFieldsChanges({ elementHasLostFocus: true }); } diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx new file mode 100644 index 000000000..cfc55017b --- /dev/null +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -0,0 +1,270 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + forwardRef, + useImperativeHandle, + memo, + useRef, +} from "react"; +import { useDeepCompareMemo } from "use-deep-compare"; +import { KanbanView } from "@/types"; +import { Kanban } from "@gisce/ooui"; +import type { KanbanButton } from "@gisce/ooui/dist/Kanban"; +import { KanbanBoard, KanbanBoardRef } from "./KanbanBoard"; +import { KanbanRecord, ColumnDefinition } from "./types"; +import { useKanbanColumns } from "./useKanbanColumns"; +import { Alert, Spin } from "antd"; +import { useLocale } from "@gisce/react-formiga-components"; +import { KanbanColumnRef } from "./KanbanColumn"; + +type KanbanProps = { + kanbanView: KanbanView; + model: string; + domain: any[]; + context: any; + searchParams?: any[]; + nameSearch?: string; + onCardClick?: (record: KanbanRecord) => void; + onLoadingChange?: (isLoading: boolean) => void; + onTotalRowsChange?: (totalRows: number) => void; + onAddCardClick?: (column: ColumnDefinition) => void; +}; + +export type KanbanRef = { + refreshResults: () => void; + refreshColumns: (columnIds: string[]) => void; +}; + +const KanbanComponentInner = ( + props: KanbanProps, + ref: React.Ref, +) => { + const { + kanbanView, + model, + domain, + context, + searchParams = [], + nameSearch, + onCardClick, + onLoadingChange, + onTotalRowsChange, + onAddCardClick, + } = props; + + const prevNameSearch = useRef(nameSearch); + + const { t } = useLocale(); + const [kanbanDef, setKanbanDef] = useState(null); + const [parsingError, setParsingError] = useState(null); + + useEffect(() => { + if (!kanbanView.arch || !kanbanView.fields) { + return; + } + + try { + const kanban = new Kanban(kanbanView.fields); + kanban.parse(kanbanView.arch); + setKanbanDef(kanban); + setParsingError(null); + } catch (err: any) { + console.error("Error parsing kanban definition:", err); + setParsingError(err); + } + }, [kanbanView.arch, kanbanView.fields]); + + const columnFieldDef = useMemo(() => { + if (!kanbanDef || !kanbanView.fields || !kanbanDef.column_field) { + return null; + } + + return kanbanView.fields[kanbanDef.column_field]; + }, [kanbanDef, kanbanView.fields]); + + const fieldsToRetrieve = useMemo(() => { + if (!kanbanDef) { + return []; + } + + const fields: string[] = kanbanDef.card_fields.map((f: any) => f.id); + + if ( + kanbanDef.buttons.some( + (b: KanbanButton) => b.states !== undefined && b.states !== null, + ) && + kanbanView.fields["state"] + ) { + fields.push("state"); + } + + return [...new Set(fields)]; + }, [kanbanDef, kanbanView.fields]); + + const { + columns, + isLoading: isLoadingColumns, + error: columnsError, + } = useKanbanColumns({ + model, + domain, + context, + columnField: kanbanDef?.column_field || "", + columnFieldDefinition: columnFieldDef, + searchParams, + enabled: !!kanbanDef && !!columnFieldDef, + columnDomain: kanbanDef?.column_domain, + }); + + const columnRefs = useRef>(new Map()); + const boardRef = useRef(null); + const columnCountsRef = useRef>({}); + + useEffect(() => { + const isNameSearchActive = nameSearch && nameSearch.trim().length > 0; + const wasNameSearchActive = + prevNameSearch.current && + typeof prevNameSearch.current === "string" && + prevNameSearch.current.trim().length > 0; + + if (!isNameSearchActive && wasNameSearchActive) { + columnRefs.current.forEach((ref) => { + ref.refresh(); + }); + } + + prevNameSearch.current = nameSearch; + }, [nameSearch]); + + const refreshColumns = useCallback((columnIds: string[]) => { + columnIds.forEach((columnId) => { + const ref = columnRefs.current.get(columnId); + if (ref) { + ref.refresh(); + } + }); + }, []); + + useImperativeHandle( + ref, + () => ({ + refreshResults: () => { + columnRefs.current.forEach((ref) => { + ref.refresh(); + }); + }, + refreshColumns, + }), + [refreshColumns], + ); + + const setColumnRef = useCallback( + (columnId: string, ref: KanbanColumnRef | null) => { + if (ref) { + columnRefs.current.set(columnId, ref); + } else { + columnRefs.current.delete(columnId); + } + }, + [], + ); + + const handleColumnCountChange = useCallback( + (columnId: string, count: number) => { + columnCountsRef.current = { + ...columnCountsRef.current, + [columnId]: count, + }; + const totalRows = Object.values(columnCountsRef.current).reduce( + (sum, c) => sum + c, + 0, + ); + onTotalRowsChange?.(totalRows); + }, + [onTotalRowsChange], + ); + + const handleDragSuccess = useCallback( + (sourceColumnId: string, targetColumnId: string) => { + refreshColumns([sourceColumnId, targetColumnId]); + }, + [refreshColumns], + ); + + useEffect(() => { + onLoadingChange?.(isLoadingColumns); + }, [isLoadingColumns, onLoadingChange]); + + const content = useDeepCompareMemo(() => { + if (parsingError) { + return ( + + ); + } + + if (columnsError) { + return ( + + ); + } + + if (!kanbanDef || (isLoadingColumns && columns.length === 0)) { + return ; + } + + return ( + + ); + }, [ + parsingError, + columnsError, + kanbanDef, + columns, + isLoadingColumns, + model, + domain, + context, + searchParams, + nameSearch, + fieldsToRetrieve, + onCardClick, + setColumnRef, + handleColumnCountChange, + onAddCardClick, + handleDragSuccess, + t, + ]); + + return <>{content}; +}; + +export const KanbanComponent = memo( + forwardRef(KanbanComponentInner), +); diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx new file mode 100644 index 000000000..7ccbe2692 --- /dev/null +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -0,0 +1,480 @@ +import { + memo, + useState, + useCallback, + useRef, + forwardRef, + useImperativeHandle, + useEffect, +} from "react"; +import { useDeepCompareMemo } from "use-deep-compare"; +import { + DndContext, + DragOverEvent, + DragOverlay, + DragStartEvent, + DragEndEvent, + PointerSensor, + useSensor, + useSensors, + closestCenter, +} from "@dnd-kit/core"; +import { KanbanColumn, KanbanColumnRef } from "./KanbanColumn"; +import { KanbanCard } from "./KanbanCard"; +import { KanbanRecord, ColumnDefinition } from "./types"; +import { Kanban } from "@gisce/ooui"; +import { useLocale } from "@gisce/react-formiga-components"; +import ConnectionProvider from "@/ConnectionProvider"; +import { useErrorNotification } from "@/hooks/useErrorNotification"; +import { useNetworkRequest } from "@/hooks/useNetworkRequest"; +import { normalizeColumnValue } from "@/helpers/kanbanHelper"; +import { useProcessAction } from "@/hooks/useProcessAction"; + +export type KanbanBoardRef = { + refreshAllColumns: () => void; +}; + +type KanbanBoardProps = { + columns: ColumnDefinition[]; + model: string; + domain: any[]; + context: any; + searchParams?: any[]; + nameSearch?: string; + fieldsToRetrieve?: string[]; + kanbanDef: Kanban; + onCardClick?: (record: KanbanRecord) => void; + setColumnRef: (columnId: string, ref: KanbanColumnRef | null) => void; + onColumnCountChange: (columnId: string, count: number) => void; + onAddCardClick?: (column: ColumnDefinition) => void; + onDragSuccess?: (sourceColumnId: string, targetColumnId: string) => void; +}; + +const KanbanBoardComponent = ( + props: KanbanBoardProps, + ref: React.Ref, +) => { + const { + columns, + model, + domain, + context = {}, + searchParams, + nameSearch, + fieldsToRetrieve, + kanbanDef, + onCardClick, + setColumnRef, + onColumnCountChange, + onAddCardClick, + onDragSuccess, + } = props; + + const { t } = useLocale(); + const { showErrorNotification } = useErrorNotification(); + const [activeRecord, setActiveRecord] = useState(null); + const [overColumnId, setOverColumnId] = useState(null); + const [overId, setOverId] = useState(null); + const colorsForRecordsRef = useRef<{ [key: number]: string }>({}); + const statusForRecordsRef = useRef<{ [key: number]: string }>({}); + const allRecordsRef = useRef<{ [key: number]: KanbanRecord }>({}); + const columnRefsRef = useRef<{ [columnId: string]: KanbanColumnRef }>({}); + const [executeColumnChange, cancelExecuteColumnChange] = useNetworkRequest( + ConnectionProvider.getHandler().rawExecute, + ); + + useEffect(() => { + return () => { + cancelExecuteColumnChange(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + const { active } = event; + const recordId = active.id as number; + + const fullRecord = allRecordsRef.current[recordId]; + if (fullRecord) { + setActiveRecord(fullRecord); + } else { + setActiveRecord({ id: recordId } as KanbanRecord); + } + }, []); + + const findColumnByValue = useCallback( + (value: any): ColumnDefinition | undefined => { + const columnFieldDef = kanbanDef.fields?.[kanbanDef.column_field]; + if (!columnFieldDef) return undefined; + + const normalizedValue = normalizeColumnValue(value, columnFieldDef, t); + if (!normalizedValue) return undefined; + + return columns.find((col) => col.id === normalizedValue.id); + }, + [columns, kanbanDef, t], + ); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + const { over, active } = event; + if (!over) { + setOverColumnId(null); + setOverId(null); + return; + } + + // First, check if hovering over a column directly + const overColumn = columns.find((col) => col.id === over.id); + if (overColumn) { + setOverColumnId(overColumn.id); + setOverId(null); + return; + } + + // If over a card, try to get column from dnd-kit data first (most reliable) + const cardColumnId = over.data.current?.columnId as string | undefined; + if (cardColumnId) { + setOverColumnId(cardColumnId); + return; + } + + // Fallback: try to find column from record cache + const overRecordId = over.id as number; + const overRecord = allRecordsRef.current[overRecordId]; + if (overRecord) { + const recordColumnValue = overRecord[kanbanDef.column_field]; + const recordColumn = findColumnByValue(recordColumnValue); + if (recordColumn) { + setOverColumnId(recordColumn.id); + if (overRecordId !== active.id) { + setOverId(overRecordId); + } else { + setOverId(null); + } + return; + } + } + + setOverColumnId(null); + setOverId(null); + }, + [columns, kanbanDef.column_field, findColumnByValue], + ); + + const handleDragCancel = useCallback(() => { + setActiveRecord(null); + setOverColumnId(null); + setOverId(null); + }, []); + + const handleRecordsUpdate = useCallback( + ( + records: KanbanRecord[], + colors: { [key: number]: string }, + status: { [key: number]: string }, + ) => { + records.forEach((record) => { + allRecordsRef.current[record.id] = record; + if (colors?.[record.id]) { + colorsForRecordsRef.current[record.id] = colors[record.id]; + } + if (status?.[record.id]) { + statusForRecordsRef.current[record.id] = status[record.id]; + } + }); + }, + [], + ); + + const refreshAllColumns = useCallback(() => { + Object.values(columnRefsRef.current).forEach((columnRef) => { + if (columnRef) { + columnRef.refresh(); + } + }); + }, []); + + const refreshSourceAndTarget = useCallback( + (sourceId: string, targetId: string) => { + const targetRef = columnRefsRef.current[targetId]; + if (targetRef) { + targetRef.refresh(); + } + const sourceRef = columnRefsRef.current[sourceId]; + if (sourceRef) { + sourceRef.refresh(); + } + }, + [], + ); + + const onActionCompleted = useCallback(async () => { + refreshAllColumns(); + }, [refreshAllColumns]); + + const { runAction } = useProcessAction({ + context, + onRefreshParentValues: onActionCompleted, + }); + + const handleDragEnd = useCallback( + async (event: DragEndEvent) => { + const { active, over } = event; + + const cleanup = () => { + setActiveRecord(null); + setOverColumnId(null); + setOverId(null); + }; + + if (!over) { + cleanup(); + return; + } + + const recordId = active.id as number; + const record = allRecordsRef.current[recordId]; + + if (!record) { + cleanup(); + return; + } + + const sourceColumnValue = record[kanbanDef.column_field]; + const columnFieldDef = kanbanDef.fields?.[kanbanDef.column_field]; + const sourceColumnNormalized = columnFieldDef + ? normalizeColumnValue(sourceColumnValue, columnFieldDef, t) + : null; + + if (!sourceColumnNormalized) { + cleanup(); + return; + } + + let targetColumn = columns.find((col) => col.id === over.id); + + if (!targetColumn) { + const overRecordId = over.id as number; + const overRecord = allRecordsRef.current[overRecordId]; + if (overRecord) { + const overRecordColumnValue = overRecord[kanbanDef.column_field]; + targetColumn = findColumnByValue(overRecordColumnValue); + } + } + + if (!targetColumn) { + cleanup(); + return; + } + + if (sourceColumnNormalized.id === targetColumn.id) { + cleanup(); + return; + } + + const fromValue = normalizeColumnValue( + sourceColumnValue, + columnFieldDef, + t, + ); + + const toValue = normalizeColumnValue( + targetColumn.originalValue, + columnFieldDef, + t, + ); + + cleanup(); + + try { + const methodName = + kanbanDef.on_change_column?.method || "on_change_column"; + + const result = await executeColumnChange({ + model, + action: methodName, + payload: [ + [recordId], + kanbanDef.column_field, + fromValue?.id, + toValue?.id, + { + ...context, + active_id: recordId, + active_ids: [recordId], + }, + ], + }); + + if (result && typeof result === "object" && result.type) { + await runAction({ + actionData: result, + // additionalContext: { + // active_id: recordId, + // active_ids: [recordId], + // }, + // overrideValues: record, + // overrideFields: kanbanDef?.fields || {}, + }); + return; + } + + refreshSourceAndTarget(sourceColumnNormalized.id, targetColumn.id); + } catch (err) { + if (onDragSuccess) { + onDragSuccess(targetColumn.id, sourceColumnNormalized.id); + } + + showErrorNotification(err); + } + }, + [ + columns, + model, + context, + kanbanDef, + showErrorNotification, + onDragSuccess, + executeColumnChange, + findColumnByValue, + t, + runAction, + refreshSourceAndTarget, + ], + ); + + useImperativeHandle( + ref, + () => ({ + refreshAllColumns, + }), + [refreshAllColumns], + ); + + const handleColumnRef = useCallback( + (columnId: string, columnRef: KanbanColumnRef | null) => { + if (columnRef) { + columnRefsRef.current[columnId] = columnRef; + } else { + delete columnRefsRef.current[columnId]; + } + setColumnRef(columnId, columnRef); + }, + [setColumnRef], + ); + + const columnRefCallbacks = useDeepCompareMemo(() => { + const callbacks: Record void> = {}; + columns.forEach((column) => { + callbacks[column.id] = (ref: KanbanColumnRef | null) => { + handleColumnRef(column.id, ref); + }; + }); + return callbacks; + }, [columns, handleColumnRef]); + + const columnAddCardCallbacks = useDeepCompareMemo(() => { + if (!onAddCardClick) return {}; + const callbacks: Record void> = {}; + columns.forEach((column) => { + callbacks[column.id] = () => onAddCardClick(column); + }); + return callbacks; + }, [columns, onAddCardClick]); + + if (columns.length === 0) { + return ( +
+ {t("no_data")} +
+ ); + } + + return ( + +
+ {columns.map((column) => ( + + ))} +
+ + + {activeRecord && activeRecord.id ? ( +
+ +
+ ) : null} +
+
+ ); +}; + +export const KanbanBoard = memo( + forwardRef(KanbanBoardComponent), +); diff --git a/src/widgets/views/Kanban/KanbanCard.styles.ts b/src/widgets/views/Kanban/KanbanCard.styles.ts new file mode 100644 index 000000000..ebb1ac9df --- /dev/null +++ b/src/widgets/views/Kanban/KanbanCard.styles.ts @@ -0,0 +1,56 @@ +import { Card as AntCard } from "antd"; +import styled from "styled-components"; + +export const StyledCard = styled(AntCard)<{ + $bgColor: string; + $borderColor: string; + $primaryColor: string; + $color?: string; + $isDraggingActive?: boolean; +}>` + position: relative; + background-color: ${(props) => props.$bgColor}; + border: 1px solid ${(props) => props.$borderColor}; + outline: none; + outline-offset: -1px; + overflow: visible; + + .ant-card-body { + overflow: visible; + } + + &:hover { + outline: ${(props) => + props.$isDraggingActive + ? "none" + : `3px solid ${props.$color || props.$primaryColor}`}; + } +`; + +export const ColorBar = styled.div<{ $color: string }>` + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 5px; + background-color: ${(props) => props.$color}; + border-radius: 7px 0 0 7px; +`; + +export const StatusDot = styled.div<{ $color: string }>` + position: absolute; + right: 8px; + top: 8px; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: ${(props) => props.$color}; +`; + +export const DropIndicator = styled.div<{ $color: string }>` + height: 3px; + background-color: ${(props) => props.$color}; + border-radius: 2px; + margin-bottom: 8px; + transition: opacity 0.15s ease; +`; diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx new file mode 100644 index 000000000..523688be3 --- /dev/null +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -0,0 +1,303 @@ +import { memo, useState, useCallback, MouseEvent, useEffect } from "react"; +import { Button, Space, Typography, theme } from "antd"; +import { useSortable } from "@dnd-kit/sortable"; +import { useDeepCompareMemo } from "use-deep-compare"; +import { KanbanRecord } from "./types"; +import { + Kanban, + Button as ButtonOoui, + KanbanCard as OouiKanbanCard, +} from "@gisce/ooui"; +import ConnectionProvider from "@/ConnectionProvider"; +import { useErrorNotification } from "@/hooks/useErrorNotification"; +import { useNetworkRequest } from "@/hooks/useNetworkRequest"; +import { useProcessAction } from "@/hooks/useProcessAction"; +import { KANBAN_COMPONENTS } from "./kanbanComponents"; +import { Icon } from "@gisce/react-formiga-components"; +import { + StyledCard, + ColorBar, + StatusDot, + DropIndicator, +} from "./KanbanCard.styles"; + +const { Text } = Typography; +const { useToken } = theme; + +type KanbanCardProps = { + record: KanbanRecord; + kanbanDef: Kanban; + draggable: boolean; + model: string; + color?: string; + status?: string; + context?: any; + onClick?: () => void; + onRefreshAll?: () => void; + isMoving?: boolean; + isDropTarget?: boolean; + activeId?: number | null; + columnId?: string; +}; + +const KanbanCardComponent = (props: KanbanCardProps) => { + const { + record, + kanbanDef, + draggable, + model, + color, + status, + context = {}, + onClick, + onRefreshAll, + isMoving = false, + columnId, + isDropTarget = false, + activeId = null, + } = props; + const { token } = useToken(); + const [loadingButton, setLoadingButton] = useState(null); + const { showErrorNotification } = useErrorNotification(); + + const [executeButton, cancelExecuteButton] = useNetworkRequest( + ConnectionProvider.getHandler().execute, + ); + + useEffect(() => { + return () => { + cancelExecuteButton(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onActionCompleted = useCallback(async () => { + onRefreshAll?.(); + }, [onRefreshAll]); + + const { runAction } = useProcessAction({ + context, + onRefreshParentValues: onActionCompleted, + }); + + const { attributes, listeners, setNodeRef, isDragging } = useSortable({ + id: record.id, + disabled: !draggable, + data: { columnId }, + }); + + const style = { + opacity: isDragging || isMoving ? 0 : 1, + cursor: "pointer", + }; + + const { visibleButtons, widgetMap } = useDeepCompareMemo(() => { + const kanbanCard = new OouiKanbanCard(kanbanDef); + const container = kanbanCard.parse(record); + const allWidgets = container.rows.flat(); + const buttons = allWidgets.filter( + (w: any) => w instanceof ButtonOoui && !w.invisible, + ) as ButtonOoui[]; + const map = new Map(); + allWidgets.forEach((w: any) => { + if (w.id) { + map.set(w.id, w); + } + }); + return { + visibleButtons: buttons, + widgetMap: map, + }; + }, [kanbanDef, record]); + + const visibleFields = useDeepCompareMemo(() => { + return kanbanDef.card_fields.filter((field: any) => { + const widget = widgetMap.get(field.id); + return !widget || !widget.invisible; + }); + }, [kanbanDef.card_fields, widgetMap]); + + const renderField = useCallback( + (field: any) => { + const fieldName = field.id; + if (!fieldName || !kanbanDef.fields[fieldName]) { + return null; + } + + let fieldValue = record[fieldName]; + const fieldType = field.type as string; + const fieldDef = kanbanDef.fields[fieldName]; + + if ( + fieldType === "many2one" && + Array.isArray(fieldValue) && + fieldValue.length === 2 + ) { + fieldValue = { + id: fieldValue[0], + value: fieldValue[1], + model: fieldDef?.relation, + }; + } + + const component = (KANBAN_COMPONENTS as any)?.[fieldType]; + + if (component && fieldValue) { + const renderedContent = component({ + value: fieldValue, + key: fieldName, + ooui: field, + context, + }); + + const handleFieldClick = (e: MouseEvent) => { + if (fieldType === "many2one") { + e.stopPropagation(); + } + }; + + return ( +
+ {!field.nolabel && ( + + {field.label || fieldName}:{" "} + + )} + + {renderedContent} + +
+ ); + } + + return ( +
+ {!field.nolabel && ( + + {field.label || fieldName}:{" "} + + )} + + {fieldValue ? fieldValue.toString() : "-"} + +
+ ); + }, + [record, context, kanbanDef.fields], + ); + + const handleButtonClick = useCallback( + async (e: MouseEvent, button: ButtonOoui) => { + e.stopPropagation(); + + if (loadingButton) { + return; + } + + setLoadingButton(button.id); + + try { + if (button.buttonType === "object") { + const result = await executeButton({ + model, + action: button.id, + payload: [record.id], + context: { + ...context, + active_id: record.id, + active_ids: [record.id], + }, + }); + + if (result && typeof result === "object" && result.type) { + await runAction({ + actionData: result, + }); + return; + } + + onRefreshAll?.(); + } + } catch (err) { + showErrorNotification(err); + } finally { + setLoadingButton(null); + } + }, + [ + loadingButton, + model, + record, + context, + executeButton, + runAction, + onRefreshAll, + showErrorNotification, + ], + ); + + const buttonClickHandlers = useDeepCompareMemo(() => { + return visibleButtons.reduce void>>( + (acc, button) => { + acc[button.id] = (e: MouseEvent) => handleButtonClick(e, button); + return acc; + }, + {}, + ); + }, [visibleButtons, handleButtonClick]); + + const showDropIndicator = isDropTarget && activeId !== null; + + return ( +
+ {showDropIndicator && } + + {color && } + {status && } +
+ {visibleFields.map((field: any) => renderField(field))} +
+ + {visibleButtons.length > 0 && ( + + {visibleButtons.map((button: ButtonOoui) => ( + + ))} + + )} +
+
+ ); +}; + +export const KanbanCard = memo(KanbanCardComponent); diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx new file mode 100644 index 000000000..04dbb8117 --- /dev/null +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -0,0 +1,409 @@ +import { + memo, + useMemo, + forwardRef, + useImperativeHandle, + useEffect, + useRef, + useCallback, +} from "react"; +import { useDeepCompareEffect } from "use-deep-compare"; +import { Badge, Button, Space, theme, Typography } from "antd"; +import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; +import { useDroppable } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { KanbanCard } from "./KanbanCard"; +import { KanbanRecord, ColumnDefinition } from "./types"; +import { Kanban } from "@gisce/ooui"; +import { useLocale } from "@gisce/react-formiga-components"; +import { useKanbanColumnData } from "./useKanbanColumnData"; + +const { Text } = Typography; +const { useToken } = theme; + +export type KanbanColumnRef = { + refresh: () => void; +}; + +type KanbanColumnProps = { + column: ColumnDefinition; + model: string; + domain: any[]; + context: any; + searchParams?: any[]; + nameSearch?: string; + fieldsToRetrieve?: string[]; + kanbanDef: Kanban; + allowSetMaxCards: boolean; + maxCards?: number; + isOver?: boolean; + onCardClick?: (record: KanbanRecord) => void; + onMaxCardsChange?: (colId: string, maxCards: number | undefined) => void; + onCountChange: (columnId: string, count: number) => void; + onRecordsUpdate?: ( + records: KanbanRecord[], + colors: { [key: number]: string }, + status: { [key: number]: string }, + ) => void; + onAddCardClick?: () => void; + onRefreshAll?: () => void; + activeId?: number | null; + overId?: number | null; +}; + +const KanbanColumnComponent = ( + props: KanbanColumnProps, + ref: React.Ref, +) => { + const { + column, + model, + domain, + context = {}, + searchParams, + nameSearch, + fieldsToRetrieve, + kanbanDef, + maxCards, + isOver = false, + onCardClick, + onCountChange, + onRecordsUpdate, + onAddCardClick, + onRefreshAll, + activeId = null, + overId = null, + } = props; + + const { + id: columnId, + label: columnLabel, + originalValue: columnOriginalValue, + } = column; + + const { t } = useLocale(); + const { token } = useToken(); + + const { + records, + count, + aggregates, + colorsForRecords, + statusForRecords, + isLoading, + isLoadingMore, + isRefreshing, + hasMore, + refresh, + fetchNextPage, + } = useKanbanColumnData({ + model, + domain, + context, + columnValue: columnOriginalValue, + searchParams, + nameSearch, + fieldsToRetrieve, + enabled: true, + kanbanDef, + }); + + useImperativeHandle( + ref, + () => ({ + refresh, + }), + [refresh], + ); + + // Report count changes to parent + useEffect(() => { + onCountChange(columnId, count); + }, [columnId, count, onCountChange]); + + // Report records updates to parent (for drag overlay) + useDeepCompareEffect(() => { + if (onRecordsUpdate && records.length > 0) { + onRecordsUpdate(records, colorsForRecords, statusForRecords); + } + }, [records, colorsForRecords, statusForRecords, onRecordsUpdate]); + + const { setNodeRef } = useDroppable({ + id: columnId, + }); + + const recordIds = useMemo(() => records.map((r) => r.id), [records]); + + const isOverLimit = maxCards !== undefined && count > maxCards; + + const aggregatesSummary = useMemo(() => { + return aggregates && Object.keys(aggregates).length > 0 + ? Object.values(aggregates) + .map((agg) => `${agg.label}: ${agg.amount}`) + .join(", ") + : null; + }, [aggregates]); + + const cardClickHandlers = useMemo(() => { + if (!onCardClick) return {}; + return records.reduce void>>((acc, record) => { + acc[record.id] = () => onCardClick(record); + return acc; + }, {}); + }, [records, onCardClick]); + + const hasStatusRibbon = useMemo(() => { + return records.some((record) => statusForRecords?.[record.id]); + }, [records, statusForRecords]); + + const scrollContainerRef = useRef(null); + + const estimatedCardHeight = useMemo(() => { + const cardPadding = 24; + const fieldsContainerMargin = 8; + const cardWrapperMargin = 8; + const fieldHeight = 24; + const buttonAreaHeight = kanbanDef.buttons.length > 0 ? 40 : 0; + + const numFields = kanbanDef.card_fields.length; + const totalFieldsHeight = numFields * fieldHeight; + + return ( + cardPadding + + fieldsContainerMargin + + totalFieldsHeight + + buttonAreaHeight + + cardWrapperMargin + ); + }, [kanbanDef.card_fields.length, kanbanDef.buttons.length]); + + const virtualizer = useVirtualizer({ + count: records.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: useCallback(() => estimatedCardHeight, [estimatedCardHeight]), + overscan: 5, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + useEffect(() => { + const [lastItem] = [...virtualItems].reverse(); + + if (!lastItem) { + return; + } + + if (lastItem.index >= records.length - 5 && hasMore && !isLoadingMore) { + fetchNextPage(); + } + }, [virtualItems, records.length, hasMore, isLoadingMore, fetchNextPage]); + + if (count === 0 && !kanbanDef.drag) { + return null; + } + + return ( +
+
+
+
+ + {columnLabel} + + +
+
+ {isLoading || isRefreshing ? ( + + + + {t("loading")} + + + ) : aggregatesSummary ? ( + + {aggregatesSummary} + + ) : null} +
+
+
+ +
+ +
+ {virtualItems.map((virtualRow) => { + const record = records[virtualRow.index]; + return ( +
+ +
+ ); + })} +
+
+ + {isLoadingMore && ( +
+ + + + {t("loading")} + + +
+ )} + + {records.length === 0 && !isLoading && ( +
+ {t("no_records")} +
+ )} +
+ +
+ +
+
+ ); +}; + +export const KanbanColumn = memo( + forwardRef(KanbanColumnComponent), +); diff --git a/src/widgets/views/Kanban/kanbanComponents.tsx b/src/widgets/views/Kanban/kanbanComponents.tsx new file mode 100644 index 000000000..390f35991 --- /dev/null +++ b/src/widgets/views/Kanban/kanbanComponents.tsx @@ -0,0 +1,15 @@ +import { ReactElement, useMemo } from "react"; +import { COLUMN_COMPONENTS } from "@/widgets/views/Tree/treeComponents"; + +export const NumberComponent = ({ value }: { value: number }): ReactElement => { + return useMemo( + () =>
{value}
, + [value], + ); +}; + +export const KANBAN_COMPONENTS = { + ...COLUMN_COMPONENTS, + integer: NumberComponent, + float: NumberComponent, +}; diff --git a/src/widgets/views/Kanban/types.ts b/src/widgets/views/Kanban/types.ts new file mode 100644 index 000000000..3f76539eb --- /dev/null +++ b/src/widgets/views/Kanban/types.ts @@ -0,0 +1,10 @@ +export type KanbanRecord = { + id: number; + [key: string]: any; +}; + +export type ColumnDefinition = { + id: string; + label: string; + originalValue: any; +}; diff --git a/src/widgets/views/Kanban/useKanbanColumnData.ts b/src/widgets/views/Kanban/useKanbanColumnData.ts new file mode 100644 index 000000000..b75d22193 --- /dev/null +++ b/src/widgets/views/Kanban/useKanbanColumnData.ts @@ -0,0 +1,359 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + useDeepCompareCallback, + useDeepCompareEffect, + useDeepCompareMemo, +} from "use-deep-compare"; +import ConnectionProvider from "@/ConnectionProvider"; +import { useNetworkRequest } from "@/hooks/useNetworkRequest"; +import { mergeParams } from "@/helpers/searchHelper"; +import { Kanban } from "@gisce/ooui"; +import { KanbanRecord } from "./types"; + +export type KanbanColumnAggregates = { + [fieldName: string]: { + label: string; + amount: number | string; + }; +}; + +type UseKanbanColumnDataParams = { + model: string; + domain: any[]; + context: any; + columnValue: string; + searchParams?: any[]; + nameSearch?: string; + fieldsToRetrieve?: string[]; + enabled?: boolean; + kanbanDef?: Kanban; +}; + +export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { + const { + model, + domain, + context, + columnValue, + searchParams = [], + nameSearch, + fieldsToRetrieve = [], + enabled = true, + kanbanDef, + } = params; + + const [records, setRecords] = useState([]); + const [aggregates, setAggregates] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [currentOffset, setCurrentOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + const [totalCount, setTotalCount] = useState(0); + const [colorsForRecords, setColorsForRecords] = useState<{ + [key: number]: string; + }>({}); + const [statusForRecords, setStatusForRecords] = useState<{ + [key: number]: string; + }>({}); + + const PAGE_SIZE = 30; + + const [searchForTree, cancelSearchForTree] = useNetworkRequest( + ConnectionProvider.getHandler().searchForTree, + ); + + const [readAggregates, cancelReadAggregates] = useNetworkRequest( + ConnectionProvider.getHandler().readAggregates, + ); + + const [searchCount, cancelSearchCount] = useNetworkRequest( + ConnectionProvider.getHandler().searchCount, + ); + + useEffect(() => { + return () => { + cancelSearchForTree(); + cancelReadAggregates(); + cancelSearchCount(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const fieldsToAggregate = useDeepCompareMemo(() => { + if (!kanbanDef?.aggregations) return undefined; + + const aggregations = kanbanDef.aggregations; + if (Object.keys(aggregations).length === 0) return undefined; + + const result: Record = {}; + Object.keys(aggregations).forEach((fieldName) => { + result[fieldName] = ["sum"]; + }); + + return result; + }, [kanbanDef?.aggregations]); + + const fetchData = useDeepCompareCallback( + async (isLoadingNextPage = false) => { + if (!enabled || !model || !kanbanDef?.column_field) { + return; + } + + const isInitialLoad = records.length === 0 && !isLoadingNextPage; + + if (isLoadingNextPage) { + setIsLoadingMore(true); + } else { + setIsLoading(true); + // Only reset offset/hasMore on initial load, not on refresh + if (isInitialLoad) { + setCurrentOffset(0); + setHasMore(true); + } + } + setError(null); + + try { + // When nameSearch is active: use ONLY domain (ignore searchParams) + // When nameSearch is NOT active: merge domain + searchParams + const baseDomain = nameSearch + ? domain + : mergeParams(domain, searchParams); + + // Extract the proper value for the search query + // For many2one fields, columnValue is [id, name], we need just the id + let searchValue: any = columnValue; + if (Array.isArray(columnValue) && columnValue.length === 2) { + // many2one field: use the ID (first element) + searchValue = columnValue[0]; + } else if (columnValue === "" || columnValue === "false") { + // Handle empty/false string values + searchValue = false; + } + + const columnDomain = [ + ...baseDomain, + [kanbanDef.column_field, "=", searchValue], + ]; + + if (!isLoadingNextPage) { + try { + const countResult = await searchCount({ + model, + params: columnDomain, + context, + name_search: nameSearch, + }); + setTotalCount(countResult); + } catch (err: any) { + if (err.name !== "AbortError") { + console.error("Error fetching column count:", err); + } + setTotalCount(0); + } + } + + // Build fields object for searchForTree + // searchForTree expects an object of field definitions, not an array of field names + const fieldsToFetch = [ + ...new Set([...fieldsToRetrieve, kanbanDef.column_field]), + ]; + const fieldsObject = kanbanDef?.fields + ? Object.keys(kanbanDef.fields).reduce( + (acc: any, fieldName: string) => { + if (fieldsToFetch.includes(fieldName)) { + acc[fieldName] = kanbanDef.fields[fieldName]; + } + return acc; + }, + {}, + ) + : {}; + + let order: string | undefined; + if (kanbanDef?.sort && !nameSearch) { + order = `${kanbanDef.sort} asc`; + } + + const attrs: any = {}; + if (kanbanDef?.colors) { + attrs.colors = kanbanDef.colors; + } + if (kanbanDef?.status) { + attrs.status = kanbanDef.status; + } + + const offsetToUse = isLoadingNextPage ? currentOffset : 0; + + const { results: fetchedRecords, attrsEvaluated } = await searchForTree( + { + model, + params: columnDomain, + context, + fields: fieldsObject, + limit: nameSearch ? 0 : PAGE_SIZE, + offset: nameSearch ? 0 : offsetToUse, + order, + attrs: Object.keys(attrs).length > 0 ? attrs : undefined, + name_search: nameSearch, + }, + ); + + setRecords(fetchedRecords); + + if (nameSearch) { + setCurrentOffset(0); + setHasMore(false); + } else if (isLoadingNextPage) { + setCurrentOffset((prev) => prev + PAGE_SIZE); + setHasMore(fetchedRecords.length === PAGE_SIZE); + } else { + setCurrentOffset(PAGE_SIZE); + setHasMore(fetchedRecords.length === PAGE_SIZE); + } + + // Fetch aggregates if defined (only on initial load, not pagination) + if (fieldsToAggregate && !isLoadingNextPage) { + if (fetchedRecords.length > 0) { + try { + // For aggregates, we need to use the full domain (not just the fetched IDs) + // to get aggregates for ALL records in the column, not just the current page + const aggregateDomain = columnDomain; + + const retrievedData = await readAggregates({ + model, + domain: aggregateDomain, + aggregateFields: fieldsToAggregate, + context, + }); + + const columnAggregates: KanbanColumnAggregates = {}; + Object.entries(retrievedData).forEach(([fieldName, values]) => { + const label = kanbanDef?.aggregations[fieldName] || fieldName; + columnAggregates[fieldName] = { + label, + amount: (values as Record).sum || 0, + }; + }); + + setAggregates(columnAggregates); + } catch (err: any) { + if (err.name !== "AbortError") { + console.warn("Error fetching column aggregates:", err); + } + setAggregates({}); + } + } else { + // No records, clear aggregates + setAggregates({}); + } + } + + // Parse colors and status from attrsEvaluated returned by searchForTree + if (attrsEvaluated && Array.isArray(attrsEvaluated)) { + const newColors: { [key: number]: string } = {}; + const newStatus: { [key: number]: string } = {}; + + attrsEvaluated.forEach((attr: any) => { + if (attr.id !== undefined) { + if (attr.colors) { + newColors[attr.id] = attr.colors; + } + if (attr.status) { + newStatus[attr.id] = attr.status; + } + } + }); + + if (isLoadingNextPage) { + setColorsForRecords((prev) => ({ + ...prev, + ...newColors, + })); + setStatusForRecords((prev) => ({ + ...prev, + ...newStatus, + })); + } else { + setColorsForRecords(newColors); + setStatusForRecords(newStatus); + } + } + } catch (err: any) { + if (err.name !== "AbortError") { + console.error("Error fetching column data:", err); + setError(err); + } + } finally { + if (isLoadingNextPage) { + setIsLoadingMore(false); + } else { + setIsLoading(false); + setIsRefreshing(false); + } + } + }, + [ + enabled, + model, + kanbanDef?.column_field, + columnValue, + domain, + searchParams, + nameSearch, + context, + fieldsToRetrieve, + fieldsToAggregate, + kanbanDef, + searchForTree, + searchCount, + readAggregates, + currentOffset, + PAGE_SIZE, + records.length, + ], + ); + + useDeepCompareEffect(() => { + fetchData(); + }, [ + enabled, + model, + kanbanDef?.column_field, + columnValue, + domain, + searchParams, + nameSearch, + context, + fieldsToRetrieve, + ]); + + const refresh = useCallback(() => { + // Don't clear data - keep previous data visible during refresh + setIsRefreshing(true); + fetchData(); + }, [fetchData]); + + const fetchNextPage = useCallback(() => { + if (!isLoadingMore && hasMore) { + fetchData(true); + } + }, [fetchData, isLoadingMore, hasMore]); + + return { + records, + count: totalCount, + aggregates, + colorsForRecords, + statusForRecords, + isLoading, + isLoadingMore, + isRefreshing, + hasMore, + error, + refresh, + fetchNextPage, + }; +}; diff --git a/src/widgets/views/Kanban/useKanbanColumns.ts b/src/widgets/views/Kanban/useKanbanColumns.ts new file mode 100644 index 000000000..0e0a55e9b --- /dev/null +++ b/src/widgets/views/Kanban/useKanbanColumns.ts @@ -0,0 +1,242 @@ +import { useState, useCallback } from "react"; +import { useDeepCompareCallback, useDeepCompareEffect } from "use-deep-compare"; +import ConnectionProvider from "@/ConnectionProvider"; +import { useNetworkRequest } from "@/hooks/useNetworkRequest"; +import { mergeParams } from "@/helpers/searchHelper"; +import { useLocale } from "@gisce/react-formiga-components"; +import { normalizeColumnValue } from "@/helpers/kanbanHelper"; +import { ColumnDefinition } from "./types"; + +type UseKanbanColumnsParams = { + model: string; + domain: any[]; + context: any; + columnField: string; + columnFieldDefinition: any; + searchParams?: any[]; + enabled?: boolean; + columnDomain?: string | null; +}; + +export const useKanbanColumns = (params: UseKanbanColumnsParams) => { + const { + model, + domain, + context, + columnField, + columnFieldDefinition, + searchParams = [], + enabled = true, + columnDomain = null, + } = params; + + const { t } = useLocale(); + const [columns, setColumns] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const [searchRequest, cancelSearchRequest] = useNetworkRequest( + ConnectionProvider.getHandler().search, + ); + + const [getFieldsRequest, cancelGetFieldsRequest] = useNetworkRequest( + ConnectionProvider.getHandler().getFields, + ); + + const [evalDomainRequest, cancelEvalDomainRequest] = useNetworkRequest( + ConnectionProvider.getHandler().evalDomain, + ); + + const getStaticColumnDefinitions = useCallback((): + | ColumnDefinition[] + | null => { + if (!columnFieldDefinition) { + return null; + } + + const fieldType = columnFieldDefinition.type; + + if (fieldType === "boolean") { + return [ + { id: "false", label: t("no"), originalValue: false }, + { id: "true", label: t("yes"), originalValue: true }, + ]; + } + + if (fieldType === "selection") { + const selectionValues = + columnFieldDefinition.selection || + columnFieldDefinition.selectionValues; + if (selectionValues) { + return selectionValues.map(([id, label]: [any, string]) => ({ + id: String(id), + label: String(label), + originalValue: id, + })); + } + } + + return null; + }, [columnFieldDefinition, t]); + + const fetchDynamicColumns = useDeepCompareCallback(async () => { + if (!enabled || !model || !columnField) { + return; + } + + // First check if we have static column definitions + // These are fields where we know all possible values beforehand + const staticColumns = getStaticColumnDefinitions(); + if (staticColumns) { + setColumns(staticColumns); + return; + } + + // For many2one fields, query the related model directly + if ( + columnFieldDefinition?.type === "many2one" && + columnFieldDefinition?.relation + ) { + setIsLoading(true); + setError(null); + + try { + let parsedColumnDomain: any[] = []; + + if (columnDomain) { + const relatedModelFields = await getFieldsRequest({ + model: columnFieldDefinition.relation, + context, + }); + + parsedColumnDomain = await evalDomainRequest({ + domain: columnDomain, + values: {}, + fields: relatedModelFields, + context, + }); + } + + const fetchedRecords = await searchRequest({ + model: columnFieldDefinition.relation, + params: parsedColumnDomain, + context, + fieldsToRetrieve: ["id", "name"], + limit: 0, + }); + + const dynamicColumns = fetchedRecords.map((record: any) => ({ + id: String(record.id), + label: record.name || String(record.id), + originalValue: [record.id, record.name || String(record.id)], + })); + + setColumns(dynamicColumns); + } catch (err: any) { + if (err.name !== "AbortError") { + console.error( + "Error fetching kanban columns from related model:", + err, + ); + setError(err); + } + } finally { + setIsLoading(false); + } + return; + } + + // For other dynamic columns (reference, etc.), extract unique values from main model records + setIsLoading(true); + setError(null); + + try { + const finalDomain = mergeParams(domain, searchParams); + + // Fetch only the column field to minimize data transfer + // Limit to 1000 records for column discovery to avoid killing the server + // This means columns with values only in records beyond 1000 won't appear + const fetchedRecords = await searchRequest({ + model, + params: finalDomain, + context, + fieldsToRetrieve: [columnField], + limit: 1000, + }); + + // Extract unique column values + const dynamicColumnMap = new Map< + string, + { label: string; originalValue: any } + >(); + + fetchedRecords.forEach((record: any) => { + const columnValue = record[columnField]; + const columnInfo = normalizeColumnValue( + columnValue, + columnFieldDefinition, + t, + ); + + if (columnInfo && !dynamicColumnMap.has(columnInfo.id)) { + dynamicColumnMap.set(columnInfo.id, { + label: columnInfo.label, + originalValue: columnInfo.originalValue, + }); + } + }); + + const dynamicColumns = Array.from(dynamicColumnMap.entries()).map( + ([id, { label, originalValue }]) => ({ + id, + label, + originalValue, + }), + ); + + setColumns(dynamicColumns); + } catch (err: any) { + if (err.name !== "AbortError") { + console.error("Error fetching kanban columns:", err); + setError(err); + } + } finally { + setIsLoading(false); + } + }, [ + enabled, + model, + columnField, + domain, + searchParams, + context, + getStaticColumnDefinitions, + normalizeColumnValue, + columnFieldDefinition, + searchRequest, + getFieldsRequest, + evalDomainRequest, + columnDomain, + ]); + + useDeepCompareEffect(() => { + fetchDynamicColumns(); + + return () => { + cancelSearchRequest(); + cancelGetFieldsRequest(); + cancelEvalDomainRequest(); + }; + }, [enabled, model, columnField, domain, searchParams, context]); + + const refresh = useCallback(() => { + fetchDynamicColumns(); + }, [fetchDynamicColumns]); + + return { + columns, + isLoading, + error, + refresh, + }; +}; diff --git a/src/widgets/views/SearchTree.tsx b/src/widgets/views/SearchTree.tsx index e70db8f28..4b8c4087b 100644 --- a/src/widgets/views/SearchTree.tsx +++ b/src/widgets/views/SearchTree.tsx @@ -106,8 +106,8 @@ function SearchTree(props: Props, ref: any) { sorter = undefined, setSorter = undefined, setTotalItems: setActionViewTotalItems = undefined, - setSearchTreeNameSearch = undefined, - setTreeIsLoading = undefined, + setSearchNameSearch = undefined, + setViewIsLoading = undefined, searchValues = {}, setSearchValues = undefined, limit = DEFAULT_SEARCH_LIMIT, @@ -144,13 +144,13 @@ function SearchTree(props: Props, ref: any) { getAllIds, } = useSearch({ model: currentModel!, - setSearchTreeNameSearch, + setSearchTreeNameSearch: setSearchNameSearch, setSelectedRowItems: changeSelectedRowKeys, setSearchParams, setSearchValues, searchParams, setSearchVisible, - setTreeIsLoading, + setTreeIsLoading: setViewIsLoading, nameSearch, searchNameGetDoneRef, context: parentContext, @@ -193,14 +193,14 @@ function SearchTree(props: Props, ref: any) { setInitialFetchDone(false); setIsLoading(true); setInitialError(undefined); - setTreeIsLoading?.(true); + setViewIsLoading?.(true); try { await fetchModelData(); setInitialFetchDone(true); } catch (error) { showErrorNotification(error); - setTreeIsLoading?.(false); + setViewIsLoading?.(false); } finally { setIsLoading(false); } diff --git a/src/widgets/views/Tree/treeComponents.tsx b/src/widgets/views/Tree/treeComponents.tsx index 616246589..d5c70b8a6 100644 --- a/src/widgets/views/Tree/treeComponents.tsx +++ b/src/widgets/views/Tree/treeComponents.tsx @@ -1,5 +1,5 @@ -import { ReactElement, useCallback, useEffect, useMemo, useState } from "react"; -import { Checkbox, Spin, ColorPicker, Tooltip } from "antd"; +import { ReactElement, useMemo } from "react"; +import { Checkbox, ColorPicker, Tooltip } from "antd"; import { parseFloatToString } from "@/helpers/timeHelper"; import { ProgressBarInput } from "../../base/ProgressBar"; import { One2manyValue } from "../../base/one2many/One2manyInputLegacy"; @@ -8,7 +8,6 @@ import { Many2oneTree } from "../../base/many2one/Many2oneTree"; import { ReferenceTree } from "../../base/ReferenceTree"; import Avatar from "../../custom/Avatar"; import { CustomTag, TagInput } from "../../custom/Tag"; -import ConnectionProvider from "@/ConnectionProvider"; import { colorFromString } from "@/helpers/formHelper"; import { EmailTagsRender } from "@/widgets/custom/EmailTags"; import { ImageRender } from "@/widgets/base/Image"; @@ -20,7 +19,6 @@ import { import { useActionViewContext } from "@/context/ActionViewContext"; import { useOne2manyContext } from "@/context/One2manyContext"; import { DateValue, DateTimeValue } from "@gisce/react-formiga-components"; -import { useNetworkRequest } from "@/hooks/useNetworkRequest"; import { useDeepCompareMemo } from "use-deep-compare"; export const BooleanComponent = ({