From 3aec975fce3ad4667a365beaa5c2e1a6fdbdbca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Wed, 29 Oct 2025 13:22:35 +0100 Subject: [PATCH 01/41] feat: initial types for kanban view https://github.com/gisce/webclient/issues/963 --- src/actionbar/ChangeViewButton.tsx | 2 ++ src/types/index.ts | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) 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/types/index.ts b/src/types/index.ts index 5e07d0d2f..7304f3936 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -84,7 +84,17 @@ 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; +}; + +export type View = TreeView | FormView | DashboardView | GraphView | KanbanView; type SearchResponse = { totalItems: () => Promise; @@ -307,10 +317,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, @@ -452,7 +459,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 = { From 74e4a6ed4177bdd387758316e4df7ddbdff23433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Wed, 29 Oct 2025 15:15:04 +0100 Subject: [PATCH 02/41] feat: action bar + strings --- src/actionbar/KanbanActionBar.tsx | 61 +++++++++++++++++++++++++++++++ src/locales/ca_ES.ts | 8 ++++ src/locales/en_US.ts | 8 ++++ src/locales/es_ES.ts | 8 ++++ 4 files changed, 85 insertions(+) create mode 100644 src/actionbar/KanbanActionBar.tsx diff --git a/src/actionbar/KanbanActionBar.tsx b/src/actionbar/KanbanActionBar.tsx new file mode 100644 index 000000000..a8e937251 --- /dev/null +++ b/src/actionbar/KanbanActionBar.tsx @@ -0,0 +1,61 @@ +import { memo, useContext } from "react"; +import { + ActionViewContext, + ActionViewContextType, +} from "@/context/ActionViewContext"; +import { Space } from "antd"; +import ChangeViewButton from "./ChangeViewButton"; +import ActionButton from "./ActionButton"; +import { ShareUrlButton } from "./ShareUrlButton"; +import { ActionBarSeparator } from "./ActionBarSeparator"; +import { ReloadOutlined } from "@ant-design/icons"; +import { useLocale } from "@gisce/react-formiga-components"; +import { View } from "@/types"; + +type KanbanActionBarProps = { + onRefresh?: () => void; + isLoading?: boolean; +}; + +const KanbanActionBarComponent = (props: KanbanActionBarProps) => { + const { onRefresh, isLoading = false } = props; + const { t } = useLocale(); + + const { + availableViews, + currentView, + setCurrentView, + searchParams, + previousView, + setPreviousView, + } = useContext(ActionViewContext) as ActionViewContextType; + + return ( + + } + tooltip={t("refresh")} + disabled={isLoading} + onClick={onRefresh} + /> + + { + setPreviousView?.(currentView); + setCurrentView?.(newView); + }} + previousView={previousView} + disabled={isLoading} + /> + + + + + + ); +}; + +const KanbanActionBar = memo(KanbanActionBarComponent); +export default KanbanActionBar; diff --git a/src/locales/ca_ES.ts b/src/locales/ca_ES.ts index 7f0e5c650..b6edcf514 100644 --- a/src/locales/ca_ES.ts +++ b/src/locales/ca_ES.ts @@ -135,4 +135,12 @@ 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", }; diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 4f83c9dc4..698d45a9a 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -132,4 +132,12 @@ 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", }; diff --git a/src/locales/es_ES.ts b/src/locales/es_ES.ts index 36e31db0d..e3cf30737 100644 --- a/src/locales/es_ES.ts +++ b/src/locales/es_ES.ts @@ -137,4 +137,12 @@ 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", }; From 314e7298b05bc793ada5cc211f6c7deafee30950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 30 Oct 2025 16:40:03 +0100 Subject: [PATCH 03/41] feat: kanban wip --- src/actionbar/KanbanActionBar.tsx | 9 +- src/context/ActionViewContext.tsx | 2 +- src/locales/ca_ES.ts | 2 + src/locales/en_US.ts | 2 + src/locales/es_ES.ts | 2 + src/views/ActionView.tsx | 26 ++ src/views/actionViews/KanbanActionView.tsx | 103 ++++++ src/widgets/views/Kanban/Kanban.tsx | 212 +++++++++++++ src/widgets/views/Kanban/KanbanBoard.tsx | 183 +++++++++++ src/widgets/views/Kanban/KanbanCard.tsx | 209 ++++++++++++ src/widgets/views/Kanban/KanbanColumn.tsx | 172 ++++++++++ .../views/Kanban/useKanbanAggregates.ts | 146 +++++++++ src/widgets/views/Kanban/useKanbanData.ts | 299 ++++++++++++++++++ 13 files changed, 1365 insertions(+), 2 deletions(-) create mode 100644 src/views/actionViews/KanbanActionView.tsx create mode 100644 src/widgets/views/Kanban/Kanban.tsx create mode 100644 src/widgets/views/Kanban/KanbanBoard.tsx create mode 100644 src/widgets/views/Kanban/KanbanCard.tsx create mode 100644 src/widgets/views/Kanban/KanbanColumn.tsx create mode 100644 src/widgets/views/Kanban/useKanbanAggregates.ts create mode 100644 src/widgets/views/Kanban/useKanbanData.ts diff --git a/src/actionbar/KanbanActionBar.tsx b/src/actionbar/KanbanActionBar.tsx index a8e937251..c57fc701f 100644 --- a/src/actionbar/KanbanActionBar.tsx +++ b/src/actionbar/KanbanActionBar.tsx @@ -3,7 +3,7 @@ import { ActionViewContext, ActionViewContextType, } from "@/context/ActionViewContext"; -import { Space } from "antd"; +import { Space, Spin } from "antd"; import ChangeViewButton from "./ChangeViewButton"; import ActionButton from "./ActionButton"; import { ShareUrlButton } from "./ShareUrlButton"; @@ -32,6 +32,13 @@ const KanbanActionBarComponent = (props: KanbanActionBarProps) => { return ( + {isLoading && ( + <> + + + + + )} } tooltip={t("refresh")} diff --git a/src/context/ActionViewContext.tsx b/src/context/ActionViewContext.tsx index 770ca6e4c..24631151e 100644 --- a/src/context/ActionViewContext.tsx +++ b/src/context/ActionViewContext.tsx @@ -1,6 +1,6 @@ import { convertParamsToValues } from "@/helpers/searchHelper"; import { DEFAULT_SEARCH_LIMIT } from "@/models/constants"; -import { TreeView, View } from "@/types"; +import { View } from "@/types"; import { DEFAULT_TREE_TYPE, TreeType, diff --git a/src/locales/ca_ES.ts b/src/locales/ca_ES.ts index b6edcf514..98d48e460 100644 --- a/src/locales/ca_ES.ts +++ b/src/locales/ca_ES.ts @@ -143,4 +143,6 @@ export default { 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", }; diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 698d45a9a..7348c67f4 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -140,4 +140,6 @@ export default { 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", }; diff --git a/src/locales/es_ES.ts b/src/locales/es_ES.ts index e3cf30737..6d2d652d2 100644 --- a/src/locales/es_ES.ts +++ b/src/locales/es_ES.ts @@ -145,4 +145,6 @@ export default { 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", }; diff --git a/src/views/ActionView.tsx b/src/views/ActionView.tsx index 631c3f2b3..cbeec4415 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"; @@ -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; } @@ -660,6 +670,22 @@ const ActionViewContent = ({ /> ); } + case "kanban": { + return ( + + ); + } } }); }; diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx new file mode 100644 index 000000000..bf217b8b5 --- /dev/null +++ b/src/views/actionViews/KanbanActionView.tsx @@ -0,0 +1,103 @@ +import { Fragment, useCallback, useState, useRef, memo } from "react"; +import { FormView, KanbanView, View } from "@/types"; +import TitleHeader from "@/ui/TitleHeader"; +import KanbanActionBar from "@/actionbar/KanbanActionBar"; +import { KanbanComponent, KanbanRef } from "@/widgets/views/Kanban/Kanban"; +import { useActionViewContext } from "@/context/ActionViewContext"; +import { KanbanRecord } from "@/widgets/views/Kanban/useKanbanData"; +import { FormModal } from "@/widgets/modals/FormModal"; +import { useAvailableHeight } from "@/hooks/useAvailableHeight"; + +export type KanbanActionViewProps = { + kanbanView: KanbanView; + visible: boolean; + model: string; + domain: any; + context: any; + availableViews: View[]; +}; + +const KanbanActionViewComponent = (props: KanbanActionViewProps) => { + const { visible, kanbanView, model, context, domain, availableViews } = props; + + const { searchParams = [] } = useActionViewContext(); + + const [isLoading, setIsLoading] = useState(false); + const [showFormModal, setShowFormModal] = useState(false); + const [selectedRecord, setSelectedRecord] = useState< + KanbanRecord | undefined + >(); + const kanbanRef = useRef(null); + const containerRef = useRef(null); + const availableHeight = useAvailableHeight({ + elementRef: containerRef, + offset: 10, + }); + + const handleRefresh = useCallback(() => { + kanbanRef.current?.refresh(); + }, []); + + const handleCardClick = useCallback((record: KanbanRecord) => { + setSelectedRecord(record); + setShowFormModal(true); + }, []); + + const onCancelFormModal = useCallback(() => { + setShowFormModal(false); + setSelectedRecord(undefined); + }, []); + + const onFormModalSubmitSucceed = useCallback(() => { + setShowFormModal(false); + setSelectedRecord(undefined); + kanbanRef.current?.refresh(); + }, []); + + if (!visible) { + return null; + } + + const formView = availableViews.find((v) => v.type === "form") as FormView; + + return ( + + + + +
0 ? `${availableHeight}px` : undefined, + overflow: "hidden", + display: "flex", + flexDirection: "column", + }} + > + +
+ {formView && ( + + )} +
+ ); +}; + +export const KanbanActionView = memo(KanbanActionViewComponent); diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx new file mode 100644 index 000000000..c211c2f64 --- /dev/null +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -0,0 +1,212 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + forwardRef, + useImperativeHandle, + memo, +} from "react"; +import { KanbanView } from "@/types"; +import { Kanban } from "@gisce/ooui"; +import { KanbanBoard } from "./KanbanBoard"; +import { useKanbanData, KanbanRecord } from "./useKanbanData"; +import { useKanbanAggregates } from "./useKanbanAggregates"; +import { Alert, Spin } from "antd"; +import { mergeParams } from "@/helpers/searchHelper"; +import { useLocale } from "@gisce/react-formiga-components"; + +type KanbanProps = { + kanbanView: KanbanView; + model: string; + domain: any[]; + context: any; + searchParams?: any[]; + onCardClick?: (record: KanbanRecord) => void; + onLoadingChange?: (isLoading: boolean) => void; +}; + +export type KanbanRef = { + refresh: () => void; +}; + +const KanbanComponentInner = ( + props: KanbanProps, + ref: React.Ref, +) => { + const { + kanbanView, + model, + domain, + context, + searchParams = [], + onCardClick, + onLoadingChange, + } = props; + + 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: any) => b.states !== undefined && b.states !== null, + ) + ) { + fields.push("state", "status"); + } + + fields.push("__model"); + + return [...new Set(fields)]; + }, [kanbanDef]); + + const { + columns, + isLoading: isLoadingData, + isRefreshing: isRefreshingData, + error: dataError, + fetchRecords, + colorsForRecords, + } = useKanbanData({ + model, + domain, + context, + columnField: kanbanDef?.column_field || "", + columnFieldDefinition: columnFieldDef, + searchParams, + fieldsToRetrieve, + enabled: !!kanbanDef && !!columnFieldDef, + kanbanDef: kanbanDef || undefined, + viewId: kanbanView.view_id, + }); + + useImperativeHandle(ref, () => ({ + refresh: () => { + fetchRecords(); + }, + })); + + const columnIds = useMemo(() => columns.map((col) => col.id), [columns]); + + const aggregatedDomain = useMemo( + () => mergeParams(domain, searchParams), + [domain, searchParams], + ); + + const { + aggregatesByColumn, + isLoading: isLoadingAggregates, + hasAggregates, + } = useKanbanAggregates({ + kanbanDef: kanbanDef || undefined, + model, + domain: aggregatedDomain, + context, + columnField: kanbanDef?.column_field || "", + columnIds, + enabled: !!kanbanDef && columns.length > 0, + }); + + useEffect(() => { + const isLoading = isLoadingData || isRefreshingData || isLoadingAggregates; + onLoadingChange?.(isLoading); + }, [isLoadingData, isRefreshingData, isLoadingAggregates, onLoadingChange]); + + const handleButtonClick = useCallback( + async (buttonName: string, recordId: number) => { + await fetchRecords(); + }, + [fetchRecords], + ); + + if (parsingError) { + return ( + + ); + } + + if (dataError) { + return ( + + ); + } + + if (!kanbanDef) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +}; + +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..846afe32b --- /dev/null +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -0,0 +1,183 @@ +import { memo, useState, RefObject, useCallback } from "react"; +import { + DndContext, + DragOverEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { Spin } from "antd"; +import { KanbanColumn } from "./KanbanColumn"; +import { KanbanCard } from "./KanbanCard"; +import { + KanbanColumn as KanbanColumnType, + KanbanRecord, +} from "./useKanbanData"; +import { Kanban } from "@gisce/ooui"; +import { useLocale } from "@gisce/react-formiga-components"; +import { KanbanAggregatesByColumn } from "./useKanbanAggregates"; + +type KanbanBoardProps = { + colorsForRecords?: RefObject<{ [key: number]: string }>; + columns: KanbanColumnType[]; + kanbanDef: Kanban; + context?: any; + isLoading?: boolean; + isRefreshing?: boolean; + aggregatesByColumn?: KanbanAggregatesByColumn; + isLoadingAggregates?: boolean; + hasAggregates?: boolean; + onCardClick?: (record: KanbanRecord) => void; + onButtonClick?: (buttonName: string, recordId: number) => void; +}; + +const KanbanBoardComponent = (props: KanbanBoardProps) => { + const { + colorsForRecords, + columns, + kanbanDef, + context = {}, + isLoading = false, + isRefreshing = false, + aggregatesByColumn, + isLoadingAggregates = false, + hasAggregates = false, + onCardClick, + onButtonClick, + } = props; + + const { t } = useLocale(); + const [activeRecord, setActiveRecord] = useState(null); + const [isDragging, setIsDragging] = useState(false); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + ); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const { active } = event; + const recordId = active.id as number; + + for (const column of columns) { + const record = column.records.find((r) => r.id === recordId); + if (record) { + setActiveRecord(record); + setIsDragging(true); + break; + } + } + }, + [columns], + ); + + const handleDragOver = useCallback((_event: DragOverEvent) => {}, []); + + const handleDragEnd = useCallback(async () => { + setIsDragging(false); + setActiveRecord(null); + }, []); + + const handleDragCancel = useCallback(() => { + setIsDragging(false); + setActiveRecord(null); + }, []); + + if (isLoading && !isRefreshing) { + return ( +
+ +
+ ); + } + + if (columns.length === 0) { + return ( +
+ {t("no_data")} +
+ ); + } + + return ( + +
+ {columns.map((column) => ( + + ))} +
+ + + {activeRecord ? ( +
+ +
+ ) : null} +
+
+ ); +}; + +export const KanbanBoard = memo(KanbanBoardComponent); diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx new file mode 100644 index 000000000..9aa4bda1d --- /dev/null +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -0,0 +1,209 @@ +import { memo, useMemo, useState, MouseEvent, useCallback } from "react"; +import { Card as AntCard, Button, Space, Typography, theme } from "antd"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { KanbanRecord } from "./useKanbanData"; +import { Kanban, Button as KanbanButton } from "@gisce/ooui"; +import ConnectionProvider from "@/ConnectionProvider"; +import { COLUMN_COMPONENTS } from "../Tree/treeComponents"; + +const { Text } = Typography; +const { useToken } = theme; + +type KanbanCardProps = { + record: KanbanRecord; + kanbanDef: Kanban; + draggable: boolean; + color?: string; + context?: any; + onClick?: () => void; + onButtonClick?: (buttonName: string, recordId: number) => void; +}; + +const KanbanCardComponent = (props: KanbanCardProps) => { + const { + record, + kanbanDef, + draggable, + color, + context = {}, + onClick, + onButtonClick, + } = props; + const { token } = useToken(); + const [loadingButton, setLoadingButton] = useState(null); + const [isHovered, setIsHovered] = useState(false); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: record.id, + disabled: !draggable, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + cursor: "pointer", + }; + + const renderField = useCallback( + (field: any) => { + const fieldName = field.id; + const fieldValue = record[fieldName]; + const fieldType = field.type as string; + + const component = (COLUMN_COMPONENTS as any)?.[fieldType]; + + if (component) { + const renderedContent = component({ + value: fieldValue, + key: fieldName, + ooui: field, + context, + }); + + return ( +
+ + {field.label || fieldName}:{" "} + + {renderedContent} +
+ ); + } + + return ( +
+ + {field.label || fieldName}:{" "} + + + {fieldValue?.toString() || "-"} + +
+ ); + }, + [record, context], + ); + + const visibleButtons = useMemo(() => { + return kanbanDef.buttons.filter((button: any) => { + if (!button.states) { + return true; + } + + const currentState = record.state || record.status; + if (!currentState) { + return true; + } + + const allowedStates = button.states + .split(",") + .map((s: string) => s.trim()); + return allowedStates.includes(currentState); + }); + }, [kanbanDef.buttons, record]); + + const handleButtonClick = useCallback( + async (e: MouseEvent, button: KanbanButton) => { + e.stopPropagation(); + + if (loadingButton) { + return; + } + + setLoadingButton(button.id); + + try { + if (button.buttonType === "object") { + await ConnectionProvider.getHandler().execute({ + model: record.__model || "", + method: button.id, + args: [[record.id]], + } as any); + + if (onButtonClick) { + onButtonClick(button.id, record.id); + } + } + } catch (err) { + console.error("Error executing button action:", err); + } finally { + setLoadingButton(null); + } + }, + [loadingButton, record, onButtonClick], + ); + + const handleMouseEnter = useCallback(() => { + setIsHovered(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setIsHovered(false); + }, []); + + return ( +
+ + {color && ( +
+ )} +
+ {kanbanDef.card_fields.map((field: any) => renderField(field))} +
+ + {visibleButtons.length > 0 && ( + + {visibleButtons.map((button: any) => ( + + ))} + + )} + +
+ ); +}; + +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..2e88a7e05 --- /dev/null +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -0,0 +1,172 @@ +import { memo, RefObject } from "react"; +import { Badge, Card, Space, theme, Typography } from "antd"; +import { LoadingOutlined } from "@ant-design/icons"; +import { useDroppable } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { KanbanCard } from "./KanbanCard"; +import { + KanbanColumn as KanbanColumnType, + KanbanRecord, +} from "./useKanbanData"; +import { Kanban } from "@gisce/ooui"; +import { useLocale } from "@gisce/react-formiga-components"; +import { KanbanColumnAggregates as KanbanColumnAggregatesType } from "./useKanbanAggregates"; + +const { Text } = Typography; +const { useToken } = theme; + +type KanbanColumnProps = { + column: KanbanColumnType; + kanbanDef: Kanban; + draggable: boolean; + colorsForRecords?: RefObject<{ [key: number]: string }>; + sortable: boolean; + allowSetMaxCards: boolean; + maxCards?: number; + context?: any; + aggregates?: KanbanColumnAggregatesType; + isLoadingAggregates?: boolean; + onCardClick?: (record: KanbanRecord) => void; + onButtonClick?: (buttonName: string, recordId: number) => void; + onMaxCardsChange?: (colId: string, maxCards: number | undefined) => void; +}; + +const KanbanColumnComponent = (props: KanbanColumnProps) => { + const { + column, + kanbanDef, + draggable, + colorsForRecords, + sortable, + maxCards, + context = {}, + aggregates, + isLoadingAggregates = false, + onCardClick, + onButtonClick, + } = props; + + const { t } = useLocale(); + const { token } = useToken(); + + const { setNodeRef, isOver } = useDroppable({ + id: column.id, + }); + + const recordIds = column.records.map((r) => r.id); + + const isOverLimit = maxCards !== undefined && column.count > maxCards; + + const aggregatesSummary = + aggregates && Object.keys(aggregates).length > 0 + ? Object.values(aggregates) + .map((agg) => `${agg.label}: ${agg.amount}`) + .join(", ") + : null; + + return ( + + + + {column.label} + + + + + {isLoadingAggregates ? ( + + + + {t("loading")} + + + ) : aggregatesSummary ? ( + + {aggregatesSummary} + + ) : null} + +
+ } + > +
+ + {column.records.map((record) => ( + onCardClick?.(record)} + onButtonClick={onButtonClick} + /> + ))} + +
+ + {column.records.length === 0 && ( +
+ {t("no_records")} +
+ )} + + ); +}; + +export const KanbanColumn = memo(KanbanColumnComponent); diff --git a/src/widgets/views/Kanban/useKanbanAggregates.ts b/src/widgets/views/Kanban/useKanbanAggregates.ts new file mode 100644 index 000000000..b9a7229e7 --- /dev/null +++ b/src/widgets/views/Kanban/useKanbanAggregates.ts @@ -0,0 +1,146 @@ +import { useState } from "react"; +import ConnectionProvider from "@/ConnectionProvider"; +import { useNetworkRequest } from "@/hooks/useNetworkRequest"; +import { Kanban } from "@gisce/ooui"; +import { + useDeepCompareEffect, + useDeepCompareCallback, + useDeepCompareMemo, +} from "use-deep-compare"; + +export type KanbanColumnAggregates = { + [fieldName: string]: { + label: string; + amount: number | string; + }; +}; + +export type KanbanAggregatesByColumn = { + [columnId: string]: KanbanColumnAggregates; +}; + +export const useKanbanAggregates = ({ + kanbanDef, + model, + domain, + context, + columnField, + columnIds, + enabled = true, +}: { + kanbanDef?: Kanban; + model: string; + domain: any[]; + context: any; + columnField: string; + columnIds: string[]; + enabled?: boolean; +}): { + aggregatesByColumn: KanbanAggregatesByColumn; + isLoading: boolean; + hasAggregates: boolean; +} => { + const [aggregatesByColumn, setAggregatesByColumn] = + useState({}); + const [isLoading, setIsLoading] = useState(false); + + const [readAggregates, cancelReadAggregates] = useNetworkRequest( + ConnectionProvider.getHandler().readAggregates, + ); + + 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 fetchAggregates = useDeepCompareCallback(async () => { + if (!enabled || !fieldsToAggregate || !columnField) { + return; + } + + setIsLoading(true); + + try { + const aggregatesPromises = columnIds.map(async (columnId) => { + const columnDomain = [ + ...domain, + [columnField, "=", columnId === "" ? false : columnId], + ]; + + const retrievedData = await readAggregates({ + model, + domain: columnDomain, + 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, + }; + }); + + return { columnId, aggregates: columnAggregates }; + }); + + const results = await Promise.all(aggregatesPromises); + + const newAggregatesByColumn: KanbanAggregatesByColumn = {}; + results.forEach(({ columnId, aggregates }) => { + newAggregatesByColumn[columnId] = aggregates; + }); + + setAggregatesByColumn(newAggregatesByColumn); + } catch (err) { + console.error("Error fetching kanban aggregates:", err); + setAggregatesByColumn({}); + } finally { + setIsLoading(false); + } + }, [ + enabled, + fieldsToAggregate, + columnField, + columnIds, + domain, + model, + context, + kanbanDef?.aggregations, + readAggregates, + ]); + + useDeepCompareEffect(() => { + if (!fieldsToAggregate || columnIds.length === 0) { + setAggregatesByColumn({}); + return; + } + + fetchAggregates(); + + return () => { + cancelReadAggregates(); + }; + }, [fieldsToAggregate, columnIds, domain, context]); + + const hasAggregates = + fieldsToAggregate !== undefined && + Object.keys(fieldsToAggregate).length > 0; + + return { + aggregatesByColumn, + isLoading, + hasAggregates, + }; +}; diff --git a/src/widgets/views/Kanban/useKanbanData.ts b/src/widgets/views/Kanban/useKanbanData.ts new file mode 100644 index 000000000..1f027f802 --- /dev/null +++ b/src/widgets/views/Kanban/useKanbanData.ts @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDeepCompareCallback, useDeepCompareEffect } from "use-deep-compare"; +import ConnectionProvider from "@/ConnectionProvider"; +import { useNetworkRequest } from "@/hooks/useNetworkRequest"; +import { mergeParams } from "@/helpers/searchHelper"; +import { Kanban } from "@gisce/ooui"; + +export type KanbanRecord = { + id: number; + [key: string]: any; +}; + +export type KanbanColumn = { + id: string; + label: string; + records: KanbanRecord[]; + count: number; +}; + +type ColumnDefinition = { + id: string; + label: string; +}; + +type UseKanbanDataParams = { + model: string; + domain: any[]; + context: any; + columnField: string; + columnFieldDefinition: any; + searchParams?: any[]; + fieldsToRetrieve?: string[]; + enabled?: boolean; + kanbanDef?: Kanban; + viewId?: number; +}; + +export const useKanbanData = (params: UseKanbanDataParams) => { + const { + model, + domain, + context, + columnField, + columnFieldDefinition, + searchParams = [], + fieldsToRetrieve = [], + enabled = true, + kanbanDef, + viewId, + } = params; + + const [columns, setColumns] = useState([]); + const [records, setRecords] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const colorsForRecords = useRef<{ [key: number]: string }>({}); + const hasInitialDataRef = useRef(false); + const previousViewIdRef = useRef(viewId); + + useEffect(() => { + if (viewId !== undefined && viewId !== previousViewIdRef.current) { + hasInitialDataRef.current = false; + previousViewIdRef.current = viewId; + } + }, [viewId]); + + const [searchRequest, cancelSearchRequest] = useNetworkRequest( + ConnectionProvider.getHandler().search, + ); + + const [parseConditions, cancelParseConditions] = useNetworkRequest( + ConnectionProvider.getHandler().parseConditions, + ); + + useEffect(() => { + return () => { + cancelSearchRequest(); + cancelParseConditions(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const extractColumnId = useCallback((columnValue: any): string | null => { + if (!columnValue) return null; + + if (Array.isArray(columnValue) && columnValue.length === 2) { + return String(columnValue[0]); + } + + return String(columnValue); + }, []); + + const extractColumnInfo = useCallback( + (columnValue: any): { id: string; label: string } | null => { + if (!columnValue) return null; + + if (Array.isArray(columnValue) && columnValue.length === 2) { + return { + id: String(columnValue[0]), + label: columnValue[1], + }; + } + + const value = String(columnValue); + return { + id: value, + label: value, + }; + }, + [], + ); + + const getColumnDefinitions = useCallback((): ColumnDefinition[] => { + if (!columnFieldDefinition) { + return []; + } + + const fieldType = columnFieldDefinition.type; + const selectionValues = + columnFieldDefinition.selection || columnFieldDefinition.selectionValues; + + if (fieldType === "selection" && selectionValues) { + return selectionValues.map(([id, label]: [string, string]) => ({ + id: String(id), + label: String(label), + })); + } + + return []; + }, [columnFieldDefinition]); + + const fetchRecords = useDeepCompareCallback(async () => { + if (!enabled || !model || !columnField) { + return; + } + + // If we already have initial data, this is a refresh + if (hasInitialDataRef.current) { + setIsRefreshing(true); + } else { + setIsLoading(true); + } + setError(null); + + try { + const finalDomain = mergeParams(domain, searchParams); + + const fields = [...new Set([...fieldsToRetrieve, columnField])]; + + const fetchedRecords = await searchRequest({ + model, + params: finalDomain, + context, + fieldsToRetrieve: fields, + limit: 0, + }); + + setRecords(fetchedRecords); + + if (kanbanDef?.colors && fetchedRecords.length > 0) { + try { + const attrsEvaluated = await parseConditions({ + conditions: { colors: kanbanDef.colors }, + values: fetchedRecords, + context, + }); + + if (attrsEvaluated && Array.isArray(attrsEvaluated)) { + attrsEvaluated.forEach((attr: any) => { + if (attr.id !== undefined && attr.colors) { + colorsForRecords.current[attr.id] = attr.colors; + } + }); + } + } catch (err: any) { + if (err.name !== "AbortError") { + console.warn("Error evaluating colors:", err); + } + } + } + + const columnDefs = getColumnDefinitions(); + const dynamicColumnDefs = new Map(); + + if (columnDefs.length === 0 && columnFieldDefinition) { + fetchedRecords.forEach((record: KanbanRecord) => { + const columnValue = record[columnField]; + const columnInfo = extractColumnInfo(columnValue); + + if (!columnInfo) return; + + if (!dynamicColumnDefs.has(columnInfo.id)) { + dynamicColumnDefs.set(columnInfo.id, columnInfo.label); + } + }); + + columnDefs.push( + ...Array.from(dynamicColumnDefs.entries()).map(([id, label]) => ({ + id, + label, + })), + ); + } + + const groupedRecords: Record = {}; + + if (columnDefs.length > 0) { + columnDefs.forEach((col) => { + groupedRecords[col.id] = []; + }); + } + + fetchedRecords.forEach((record: KanbanRecord) => { + const columnValue = record[columnField]; + const colId = extractColumnId(columnValue); + + if (!colId) return; + + if (!groupedRecords[colId]) { + groupedRecords[colId] = []; + } + + groupedRecords[colId].push(record); + }); + + const columnsArray: KanbanColumn[] = Object.entries(groupedRecords).map( + ([colId, colRecords]) => { + const columnDef = columnDefs.find((c) => c.id === colId); + return { + id: colId, + label: columnDef?.label || colId, + records: colRecords, + count: colRecords.length, + }; + }, + ); + + setColumns((prevColumns) => { + if ( + hasInitialDataRef.current && + prevColumns.length > 0 && + columnsArray.length === 0 + ) { + return prevColumns; + } + return columnsArray; + }); + hasInitialDataRef.current = true; + } catch (err: any) { + if (err.name !== "AbortError") { + console.error("Error fetching kanban data:", err); + setError(err); + } + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, [ + enabled, + model, + columnField, + domain, + searchParams, + context, + fieldsToRetrieve, + getColumnDefinitions, + kanbanDef, + parseConditions, + ]); + + useDeepCompareEffect(() => { + fetchRecords(); + }, [ + enabled, + model, + columnField, + domain, + searchParams, + context, + fieldsToRetrieve, + ]); + + const moveRecord = useCallback( + async (recordId: number, fromColumnId: string, toColumnId: string) => {}, + [], + ); + + return { + columns, + records, + isLoading, + isRefreshing, + error, + colorsForRecords, + fetchRecords, + moveRecord, + }; +}; From ae3607daeca88235233ac82d85b5a68a4310d10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 30 Oct 2025 21:55:35 +0100 Subject: [PATCH 04/41] feat: reuse treeactionbar and context for kanban, make it generic https://github.com/gisce/webclient/issues/963 --- src/actionbar/TreeActionBar.tsx | 78 ++++++++++------------ src/context/ActionViewContext.tsx | 38 +++++------ src/hooks/useSearchTreeState.ts | 8 +-- src/types/index.ts | 1 + src/views/ActionView.tsx | 25 +++---- src/views/actionViews/GraphActionView.tsx | 16 ++--- src/views/actionViews/KanbanActionView.tsx | 45 ++++++++++--- src/views/actionViews/TreeActionView.tsx | 22 +++--- src/widgets/views/Kanban/Kanban.tsx | 4 +- src/widgets/views/SearchTree.tsx | 12 ++-- 10 files changed, 134 insertions(+), 115 deletions(-) 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 +35,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 +62,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 +118,7 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { setCurrentView, availableViews, formRef, - searchTreeRef, + viewRef: searchTreeRef, onNewClicked, currentId, setCurrentId, @@ -133,8 +133,8 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { setTotalItems, setSelectedRowItems, selectedRowItems, - searchTreeNameSearch, - setSearchTreeNameSearch, + searchNameSearch: searchTreeNameSearch, + setSearchNameSearch: setSearchTreeNameSearch, goToResourceId, limit: limitProps, isActive, @@ -150,7 +150,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( @@ -249,7 +249,7 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { formHasChanges, setFormHasChanges, formRef, - searchTreeRef, + viewRef: searchTreeRef, onFormSave: callOnFormSave, onNewClicked, currentId, @@ -263,8 +263,8 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { setRemovingItem, formIsLoading, setFormIsLoading, - treeIsLoading, - setTreeIsLoading, + viewIsLoading, + setViewIsLoading, attachments, setAttachments, selectedRowItems, @@ -279,8 +279,8 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { setSorter, totalItems, setTotalItems, - searchTreeNameSearch, - setSearchTreeNameSearch, + searchNameSearch: searchTreeNameSearch, + setSearchNameSearch: setSearchTreeNameSearch, setGraphIsLoading, graphIsLoading, previousView, @@ -334,7 +334,7 @@ export const useActionViewContext = () => { setCurrentView: () => {}, availableViews: [], formRef: { current: null }, - searchTreeRef: { current: null }, + viewRef: { current: null }, onNewClicked: () => {}, currentId: undefined, setCurrentId: () => {}, @@ -349,8 +349,8 @@ export const useActionViewContext = () => { setTotalItems: () => {}, selectedRowItems: [], setSelectedRowItems: () => {}, - setSearchTreeNameSearch: () => {}, - searchTreeNameSearch: undefined, + setSearchNameSearch: () => {}, + searchNameSearch: undefined, goToResourceId: async () => {}, limit: DEFAULT_SEARCH_LIMIT, isActive: undefined, @@ -363,8 +363,8 @@ export const useActionViewContext = () => { setRemovingItem: () => {}, formIsLoading: false, setFormIsLoading: () => {}, - treeIsLoading: false, - setTreeIsLoading: () => {}, + viewIsLoading: false, + setViewIsLoading: () => {}, graphIsLoading: false, setGraphIsLoading: () => {}, attachments: [], 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/types/index.ts b/src/types/index.ts index 7304f3936..92a930fcc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -92,6 +92,7 @@ export type KanbanView = BaseView & { sort?: boolean; set_max_cards?: boolean; colors?: string; + toolbar?: any; }; export type View = TreeView | FormView | DashboardView | GraphView | KanbanView; diff --git a/src/views/ActionView.tsx b/src/views/ActionView.tsx index cbeec4415..3923f0679 100644 --- a/src/views/ActionView.tsx +++ b/src/views/ActionView.tsx @@ -121,7 +121,7 @@ function ActionView(props: Props, ref: any) { }); const formRef = useRef(); - const searchTreeRef = useRef(); + const viewRef = useRef(); const tabManagerContext = useContext( TabManagerContext, @@ -490,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} @@ -505,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} @@ -531,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} /> @@ -562,8 +562,8 @@ const ActionViewContent = ({ setCurrentItemIndex, formForcedValues, limit, - searchTreeRef, - searchTreeNameSearch, + viewRef, + searchNameSearch, setCurrentView, setCurrentId, }: { @@ -581,8 +581,8 @@ const ActionViewContent = ({ setCurrentId: any; setCurrentView: any; limit?: number; - searchTreeRef: React.RefObject; - searchTreeNameSearch?: string; + viewRef: React.RefObject; + searchNameSearch?: string; formForcedValues: any; }) => { useAutoUpdateUrlAndTitle(); @@ -626,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} @@ -683,6 +683,7 @@ const ActionViewContent = ({ context={context} domain={domain} availableViews={availableViews} + viewRef={viewRef} /> ); } 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 index bf217b8b5..d64aa8b73 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -1,7 +1,14 @@ -import { Fragment, useCallback, useState, useRef, memo } from "react"; +import { + Fragment, + useCallback, + useState, + useRef, + memo, + useEffect, +} from "react"; import { FormView, KanbanView, View } from "@/types"; import TitleHeader from "@/ui/TitleHeader"; -import KanbanActionBar from "@/actionbar/KanbanActionBar"; +import TreeActionBar from "@/actionbar/TreeActionBar"; import { KanbanComponent, KanbanRef } from "@/widgets/views/Kanban/Kanban"; import { useActionViewContext } from "@/context/ActionViewContext"; import { KanbanRecord } from "@/widgets/views/Kanban/useKanbanData"; @@ -15,28 +22,39 @@ export type KanbanActionViewProps = { domain: any; context: any; availableViews: View[]; + viewRef: any; }; const KanbanActionViewComponent = (props: KanbanActionViewProps) => { - const { visible, kanbanView, model, context, domain, availableViews } = props; + const { + visible, + kanbanView, + model, + context, + domain, + availableViews, + viewRef, + } = props; - const { searchParams = [] } = useActionViewContext(); + const { searchParams = [], setViewIsLoading } = useActionViewContext(); const [isLoading, setIsLoading] = useState(false); const [showFormModal, setShowFormModal] = useState(false); const [selectedRecord, setSelectedRecord] = useState< KanbanRecord | undefined >(); - const kanbanRef = useRef(null); + + // Use viewRef from props instead of creating a new ref + const kanbanRef = viewRef as React.RefObject; const containerRef = useRef(null); const availableHeight = useAvailableHeight({ elementRef: containerRef, offset: 10, }); - const handleRefresh = useCallback(() => { - kanbanRef.current?.refresh(); - }, []); + useEffect(() => { + setViewIsLoading?.(isLoading); + }, [isLoading, setViewIsLoading]); const handleCardClick = useCallback((record: KanbanRecord) => { setSelectedRecord(record); @@ -51,8 +69,8 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { const onFormModalSubmitSucceed = useCallback(() => { setShowFormModal(false); setSelectedRecord(undefined); - kanbanRef.current?.refresh(); - }, []); + kanbanRef.current?.refreshResults(); + }, [kanbanRef]); if (!visible) { return null; @@ -63,7 +81,12 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { return ( - +
void; setCurrentView: (view: View) => void; availableViews: View[]; - searchTreeNameSearch?: string; + searchNameSearch?: string; limit?: number; }; @@ -55,7 +55,7 @@ export const DEFAULT_TREE_TYPE: TreeType = "legacy"; export const TreeActionView = (props: TreeActionViewProps) => { const { visible, - searchTreeRef, + viewRef, model, context, formView, @@ -66,7 +66,7 @@ export const TreeActionView = (props: TreeActionViewProps) => { setCurrentId, setCurrentView, availableViews, - searchTreeNameSearch, + searchNameSearch, limit, } = props; const previousVisibleRef = useRef(visible); @@ -262,9 +262,9 @@ export const TreeActionView = (props: TreeActionViewProps) => { setSearchValues?.({}); setTimeout(() => { - searchTreeRef?.current?.refreshResults(); + viewRef?.current?.refreshResults(); }, 100); - }, [setCurrentSavedSearch, setSearchParams, setSearchValues, searchTreeRef]); + }, [setCurrentSavedSearch, setSearchParams, setSearchValues, viewRef]); const handleOpenSidebar = useCallback(() => { setSearchVisible?.(true); @@ -364,7 +364,7 @@ export const TreeActionView = (props: TreeActionViewProps) => { {treeType === "infinite" && ( { )} {treeType === "paginated" && ( { )} {treeType === "legacy" && ( void; + refreshResults: () => void; }; const KanbanComponentInner = ( @@ -113,7 +113,7 @@ const KanbanComponentInner = ( }); useImperativeHandle(ref, () => ({ - refresh: () => { + refreshResults: () => { fetchRecords(); }, })); 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); } From 21258e66fdc0b40569c57fb6d7edbc932072ba4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 31 Oct 2025 09:24:57 +0100 Subject: [PATCH 05/41] fix: allow title header to show selected records for kanban --- src/ui/TitleHeader.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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, From 842167f667173caeb5c59a5936af8f825ed5383d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 31 Oct 2025 10:03:15 +0100 Subject: [PATCH 06/41] feat: improve height calculation for kanban board --- src/views/actionViews/KanbanActionView.tsx | 47 +++------- src/widgets/views/Kanban/Kanban.tsx | 101 +++++++++++++-------- 2 files changed, 77 insertions(+), 71 deletions(-) diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index d64aa8b73..1589e9540 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -1,11 +1,4 @@ -import { - Fragment, - useCallback, - useState, - useRef, - memo, - useEffect, -} from "react"; +import { Fragment, useCallback, useState, memo, useEffect } from "react"; import { FormView, KanbanView, View } from "@/types"; import TitleHeader from "@/ui/TitleHeader"; import TreeActionBar from "@/actionbar/TreeActionBar"; @@ -13,7 +6,6 @@ import { KanbanComponent, KanbanRef } from "@/widgets/views/Kanban/Kanban"; import { useActionViewContext } from "@/context/ActionViewContext"; import { KanbanRecord } from "@/widgets/views/Kanban/useKanbanData"; import { FormModal } from "@/widgets/modals/FormModal"; -import { useAvailableHeight } from "@/hooks/useAvailableHeight"; export type KanbanActionViewProps = { kanbanView: KanbanView; @@ -46,11 +38,6 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { // Use viewRef from props instead of creating a new ref const kanbanRef = viewRef as React.RefObject; - const containerRef = useRef(null); - const availableHeight = useAvailableHeight({ - elementRef: containerRef, - offset: 10, - }); useEffect(() => { setViewIsLoading?.(isLoading); @@ -80,7 +67,7 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { return ( - + { treeExpandable={false} /> -
0 ? `${availableHeight}px` : undefined, - overflow: "hidden", - display: "flex", - flexDirection: "column", - }} - > - -
+ {formView && ( (null); const [parsingError, setParsingError] = useState(null); + const containerRef = useRef(null); + const availableHeight = useAvailableHeight({ + elementRef: containerRef, + offset: HEIGHT_OFFSET, + }); useEffect(() => { if (!kanbanView.arch || !kanbanView.fields) { @@ -151,45 +161,42 @@ const KanbanComponentInner = ( [fetchRecords], ); - if (parsingError) { - return ( - - ); - } + const containerStyle = useMemo( + () => ({ + overflow: "hidden", + height: `${availableHeight}px`, + }), + [availableHeight], + ); - if (dataError) { - return ( - - ); - } + const content = useDeepCompareMemo(() => { + if (parsingError) { + return ( + + ); + } - if (!kanbanDef) { - return ( -
- -
- ); - } + if (dataError) { + return ( + + ); + } - return ( -
+ if (!kanbanDef) { + return ; + } + + return ( + ); + }, [ + parsingError, + dataError, + kanbanDef, + availableHeight, + columns, + colorsForRecords, + context, + isLoadingData, + isRefreshingData, + aggregatesByColumn, + isLoadingAggregates, + hasAggregates, + onCardClick, + handleButtonClick, + t, + ]); + + return ( +
+ {content}
); }; From 4040fc6c8082a12be76f90dbc6108a61b43b1fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 6 Nov 2025 09:26:30 +0100 Subject: [PATCH 07/41] feat: more improvements for kanban --- src/context/ActionViewContext.tsx | 16 +- src/types/index.ts | 1 + src/views/actionViews/KanbanActionView.tsx | 171 +++++++++++++++++++-- src/widgets/views/Kanban/Kanban.tsx | 31 +--- src/widgets/views/Kanban/KanbanBoard.tsx | 1 - src/widgets/views/Kanban/KanbanColumn.tsx | 4 +- src/widgets/views/Kanban/useKanbanData.ts | 3 + 7 files changed, 184 insertions(+), 43 deletions(-) diff --git a/src/context/ActionViewContext.tsx b/src/context/ActionViewContext.tsx index 000ba5935..e7c77563c 100644 --- a/src/context/ActionViewContext.tsx +++ b/src/context/ActionViewContext.tsx @@ -6,7 +6,14 @@ import { TreeType, } from "@/views/actionViews/TreeActionView"; import { ColumnState } from "@gisce/react-formiga-table"; -import { createContext, useContext, useEffect, useState, useMemo } from "react"; +import { + createContext, + useContext, + useEffect, + useState, + useMemo, + useCallback, +} from "react"; import { PermissionsMap } from "@/hooks/usePermissions"; type ActionViewProviderProps = { @@ -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) { @@ -251,7 +263,7 @@ const ActionViewProvider = (props: ActionViewProviderProps): any => { formRef, viewRef: searchTreeRef, onFormSave: callOnFormSave, - onNewClicked, + onNewClicked: wrappedOnNewClicked, currentId, setCurrentId, currentItemIndex, diff --git a/src/types/index.ts b/src/types/index.ts index 92a930fcc..20dfe8a42 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -93,6 +93,7 @@ export type KanbanView = BaseView & { set_max_cards?: boolean; colors?: string; toolbar?: any; + search_fields?: SearchFields; }; export type View = TreeView | FormView | DashboardView | GraphView | KanbanView; diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index 1589e9540..82b7755f5 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -1,11 +1,27 @@ -import { Fragment, useCallback, useState, memo, useEffect } from "react"; -import { FormView, KanbanView, View } from "@/types"; +import { + Fragment, + useCallback, + useState, + memo, + useEffect, + 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 } from "@/widgets/views/Kanban/useKanbanData"; import { FormModal } from "@/widgets/modals/FormModal"; +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"; + +const HEIGHT_OFFSET = 10; export type KanbanActionViewProps = { kanbanView: KanbanView; @@ -28,16 +44,44 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { viewRef, } = props; - const { searchParams = [], setViewIsLoading } = useActionViewContext(); + const { setViewIsLoading } = useActionViewContext(); + + const { + searchVisible, + setSearchVisible, + selectedRowItems, + setSelectedRowItems, + searchParams, + setSearchParams, + searchValues, + setSearchValues, + searchTreeNameSearch, + setSearchTreeNameSearch, + } = useSearchTreeState({ useLocalState: false }); const [isLoading, setIsLoading] = useState(false); const [showFormModal, setShowFormModal] = useState(false); const [selectedRecord, setSelectedRecord] = useState< KanbanRecord | undefined >(); + const [totalRows, setTotalRows] = useState(null); - // Use viewRef from props instead of creating a new ref const kanbanRef = viewRef as React.RefObject; + const containerRef = useRef(null); + const availableHeight = useAvailableHeight({ + elementRef: containerRef, + offset: HEIGHT_OFFSET, + }); + + const containerStyle = useMemo( + () => ({ + overflow: "hidden", + height: `${availableHeight}px`, + minHeight: `${availableHeight}px`, + maxHeight: `${availableHeight}px`, + }), + [availableHeight], + ); useEffect(() => { setViewIsLoading?.(isLoading); @@ -59,14 +103,97 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { kanbanRef.current?.refreshResults(); }, [kanbanRef]); + const handleTotalRowsChange = useCallback((total: number) => { + setTotalRows(total); + }, []); + + 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; } - const formView = availableViews.find((v) => v.type === "form") as FormView; - return ( + { treeExpandable={false} /> - setSearchVisible?.(true)} + /> + ) : undefined + } /> +
+ +
{formView && ( void; onLoadingChange?: (isLoading: boolean) => void; + onTotalRowsChange?: (totalRows: number) => void; }; export type KanbanRef = { @@ -47,16 +44,12 @@ const KanbanComponentInner = ( searchParams = [], onCardClick, onLoadingChange, + onTotalRowsChange, } = props; const { t } = useLocale(); const [kanbanDef, setKanbanDef] = useState(null); const [parsingError, setParsingError] = useState(null); - const containerRef = useRef(null); - const availableHeight = useAvailableHeight({ - elementRef: containerRef, - offset: HEIGHT_OFFSET, - }); useEffect(() => { if (!kanbanView.arch || !kanbanView.fields) { @@ -109,6 +102,7 @@ const KanbanComponentInner = ( error: dataError, fetchRecords, colorsForRecords, + totalRows, } = useKanbanData({ model, domain, @@ -154,6 +148,10 @@ const KanbanComponentInner = ( onLoadingChange?.(isLoading); }, [isLoadingData, isRefreshingData, isLoadingAggregates, onLoadingChange]); + useEffect(() => { + onTotalRowsChange?.(totalRows); + }, [totalRows, onTotalRowsChange]); + const handleButtonClick = useCallback( async (buttonName: string, recordId: number) => { await fetchRecords(); @@ -161,14 +159,6 @@ const KanbanComponentInner = ( [fetchRecords], ); - const containerStyle = useMemo( - () => ({ - overflow: "hidden", - height: `${availableHeight}px`, - }), - [availableHeight], - ); - const content = useDeepCompareMemo(() => { if (parsingError) { return ( @@ -215,7 +205,6 @@ const KanbanComponentInner = ( parsingError, dataError, kanbanDef, - availableHeight, columns, colorsForRecords, context, @@ -229,11 +218,7 @@ const KanbanComponentInner = ( t, ]); - return ( -
- {content} -
- ); + return <>{content}; }; export const KanbanComponent = memo( diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index 846afe32b..262805887 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -133,7 +133,6 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { style={{ display: "flex", gap: "16px", - paddingTop: "16px", paddingBottom: "16px", overflowX: "auto", height: "100%", diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index 2e88a7e05..c34222573 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -80,13 +80,13 @@ const KanbanColumnComponent = (props: KanbanColumnProps) => { maxHeight: "100%", }} bodyStyle={{ - padding: "12px", + padding: "6px", overflowY: "auto", flex: 1, backgroundColor: token.colorBgLayout, }} headStyle={{ - backgroundColor: token.colorPrimaryBg, + background: `linear-gradient(to bottom, ${token.colorPrimaryBg} 0%, ${token.colorBgLayout} 90%)`, borderBottom: `1px solid ${token.colorBorder}`, }} title={ diff --git a/src/widgets/views/Kanban/useKanbanData.ts b/src/widgets/views/Kanban/useKanbanData.ts index 1f027f802..96d2c579b 100644 --- a/src/widgets/views/Kanban/useKanbanData.ts +++ b/src/widgets/views/Kanban/useKanbanData.ts @@ -286,6 +286,8 @@ export const useKanbanData = (params: UseKanbanDataParams) => { [], ); + const totalRows = columns.reduce((sum, col) => sum + col.count, 0); + return { columns, records, @@ -295,5 +297,6 @@ export const useKanbanData = (params: UseKanbanDataParams) => { colorsForRecords, fetchRecords, moveRecord, + totalRows, }; }; From 7bb1c116c9abe3525f42a4a4797075f8e1189353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 6 Nov 2025 09:38:01 +0100 Subject: [PATCH 08/41] feat: add new overriding for kanban components from tree ones --- src/widgets/views/Kanban/kanbanComponents.tsx | 15 +++++++++++++++ src/widgets/views/Tree/treeComponents.tsx | 6 ++---- 2 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 src/widgets/views/Kanban/kanbanComponents.tsx 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/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 = ({ From dab70c59eebc442e74c106da42ecaf361f9cbd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 6 Nov 2025 09:41:05 +0100 Subject: [PATCH 09/41] fix: use new kanbancomponents --- src/widgets/views/Kanban/KanbanCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index 9aa4bda1d..497da09b4 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -5,7 +5,7 @@ import { CSS } from "@dnd-kit/utilities"; import { KanbanRecord } from "./useKanbanData"; import { Kanban, Button as KanbanButton } from "@gisce/ooui"; import ConnectionProvider from "@/ConnectionProvider"; -import { COLUMN_COMPONENTS } from "../Tree/treeComponents"; +import { KANBAN_COMPONENTS } from "./kanbanComponents"; const { Text } = Typography; const { useToken } = theme; @@ -59,7 +59,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { const fieldValue = record[fieldName]; const fieldType = field.type as string; - const component = (COLUMN_COMPONENTS as any)?.[fieldType]; + const component = (KANBAN_COMPONENTS as any)?.[fieldType]; if (component) { const renderedContent = component({ From d49a6fd97949a8e0771f1692af79cb1e87a9f173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 6 Nov 2025 11:11:29 +0100 Subject: [PATCH 10/41] feat: more improvements --- src/locales/ca_ES.ts | 1 + src/locales/en_US.ts | 1 + src/locales/es_ES.ts | 1 + src/widgets/views/Kanban/KanbanCard.tsx | 116 ++++++++++++++-------- src/widgets/views/Kanban/KanbanColumn.tsx | 81 +++++++++------ 5 files changed, 130 insertions(+), 70 deletions(-) diff --git a/src/locales/ca_ES.ts b/src/locales/ca_ES.ts index 98d48e460..9558d2a98 100644 --- a/src/locales/ca_ES.ts +++ b/src/locales/ca_ES.ts @@ -145,4 +145,5 @@ export default { 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 7348c67f4..dd0b02a74 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -142,4 +142,5 @@ export default { 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 6d2d652d2..eaf72da93 100644 --- a/src/locales/es_ES.ts +++ b/src/locales/es_ES.ts @@ -147,4 +147,5 @@ export default { 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/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index 497da09b4..cb682a266 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -2,6 +2,7 @@ import { memo, useMemo, useState, MouseEvent, useCallback } from "react"; import { Card as AntCard, Button, Space, Typography, theme } from "antd"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import styled from "styled-components"; import { KanbanRecord } from "./useKanbanData"; import { Kanban, Button as KanbanButton } from "@gisce/ooui"; import ConnectionProvider from "@/ConnectionProvider"; @@ -10,6 +11,33 @@ import { KANBAN_COMPONENTS } from "./kanbanComponents"; const { Text } = Typography; const { useToken } = theme; +const StyledCard = styled(AntCard)<{ + $bgColor: string; + $borderColor: string; + $primaryColor: string; +}>` + position: relative; + margin-bottom: 8px; + background-color: ${(props) => props.$bgColor}; + border: 1px solid ${(props) => props.$borderColor}; + outline: none; + outline-offset: -1px; + + &:hover { + outline: 3px solid ${(props) => props.$primaryColor}; + } +`; + +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; +`; + type KanbanCardProps = { record: KanbanRecord; kanbanDef: Kanban; @@ -32,7 +60,6 @@ const KanbanCardComponent = (props: KanbanCardProps) => { } = props; const { token } = useToken(); const [loadingButton, setLoadingButton] = useState(null); - const [isHovered, setIsHovered] = useState(false); const { attributes, @@ -56,12 +83,24 @@ const KanbanCardComponent = (props: KanbanCardProps) => { const renderField = useCallback( (field: any) => { const fieldName = field.id; - const fieldValue = record[fieldName]; + let fieldValue = record[fieldName]; const fieldType = field.type as string; + if ( + fieldType === "many2one" && + Array.isArray(fieldValue) && + fieldValue.length === 2 + ) { + fieldValue = { + id: fieldValue[0], + value: fieldValue[1], + model: field.relation, + }; + } + const component = (KANBAN_COMPONENTS as any)?.[fieldType]; - if (component) { + if (component && fieldValue) { const renderedContent = component({ value: fieldValue, key: fieldName, @@ -69,23 +108,39 @@ const KanbanCardComponent = (props: KanbanCardProps) => { context, }); + const handleFieldClick = (e: MouseEvent) => { + if (fieldType === "many2one") { + e.stopPropagation(); + } + }; + return ( -
- - {field.label || fieldName}:{" "} - - {renderedContent} +
+ {!field.nolabel && ( + + {field.label || fieldName}:{" "} + + )} + + {renderedContent} +
); } return (
+ {!field.nolabel && ( + + {field.label || fieldName}:{" "} + + )} - {field.label || fieldName}:{" "} - - - {fieldValue?.toString() || "-"} + {fieldValue ? fieldValue.toString() : "-"}
); @@ -142,45 +197,22 @@ const KanbanCardComponent = (props: KanbanCardProps) => { [loadingButton, record, onButtonClick], ); - const handleMouseEnter = useCallback(() => { - setIsHovered(true); - }, []); - - const handleMouseLeave = useCallback(() => { - setIsHovered(false); - }, []); - return (
- - {color && ( -
- )} + {color && }
{kanbanDef.card_fields.map((field: any) => renderField(field))}
@@ -201,7 +233,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { ))} )} - +
); }; diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index c34222573..cb84f4683 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -1,6 +1,6 @@ import { memo, RefObject } from "react"; -import { Badge, Card, Space, theme, Typography } from "antd"; -import { LoadingOutlined } from "@ant-design/icons"; +import { Badge, Button, Space, theme, Typography } from "antd"; +import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; import { useDroppable } from "@dnd-kit/core"; import { SortableContext, @@ -68,28 +68,26 @@ const KanbanColumnComponent = (props: KanbanColumnProps) => { : null; return ( - +
{ ) : null}
- } - > -
+
+ +
{ /> ))} + + {column.records.length === 0 && ( +
+ {t("no_records")} +
+ )}
- {column.records.length === 0 && ( -
+
- )} - + {t("add_card")} + +
+
); }; From af5ad13601bdb62c9bd248664d0d85375b54212a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 6 Nov 2025 12:25:52 +0100 Subject: [PATCH 11/41] fix: minor adjustments for ui --- src/widgets/views/Kanban/KanbanCard.tsx | 87 ++++++++++++++----------- 1 file changed, 49 insertions(+), 38 deletions(-) diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index cb682a266..cd6a955b9 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -11,20 +11,28 @@ import { KANBAN_COMPONENTS } from "./kanbanComponents"; const { Text } = Typography; const { useToken } = theme; +const CardWrapper = styled.div` + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } +`; + const StyledCard = styled(AntCard)<{ $bgColor: string; $borderColor: string; $primaryColor: string; + $color?: string; }>` position: relative; - margin-bottom: 8px; background-color: ${(props) => props.$bgColor}; border: 1px solid ${(props) => props.$borderColor}; outline: none; outline-offset: -1px; &:hover { - outline: 3px solid ${(props) => props.$primaryColor}; + outline: 3px solid ${(props) => props.$color || props.$primaryColor}; } `; @@ -198,43 +206,46 @@ const KanbanCardComponent = (props: KanbanCardProps) => { ); return ( -
- - {color && } -
- {kanbanDef.card_fields.map((field: any) => renderField(field))} -
+ +
+ + {color && } +
+ {kanbanDef.card_fields.map((field: any) => renderField(field))} +
- {visibleButtons.length > 0 && ( - - {visibleButtons.map((button: any) => ( - - ))} - - )} -
-
+ {visibleButtons.length > 0 && ( + + {visibleButtons.map((button: any) => ( + + ))} + + )} +
+
+ ); }; From 3942e5e8b2c3ffdaa9e57bf229d1be7f1b5c4a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 6 Nov 2025 13:36:12 +0100 Subject: [PATCH 12/41] fix: adjust height calculations --- src/views/actionViews/KanbanActionView.tsx | 26 +++++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index 82b7755f5..01ef80d86 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -68,9 +68,11 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { const kanbanRef = viewRef as React.RefObject; const containerRef = useRef(null); + const searchHeaderRef = useRef(null); const availableHeight = useAvailableHeight({ elementRef: containerRef, offset: HEIGHT_OFFSET, + dependencies: [searchHeaderRef.current], }); const containerStyle = useMemo( @@ -202,17 +204,19 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { treeExpandable={false} /> - setSearchVisible?.(true)} - /> - ) : undefined - } - /> +
+ setSearchVisible?.(true)} + /> + ) : undefined + } + /> +
Date: Thu, 6 Nov 2025 16:38:17 +0100 Subject: [PATCH 13/41] feat: ribbon for status --- src/widgets/views/Kanban/Kanban.tsx | 8 +- src/widgets/views/Kanban/KanbanBoard.tsx | 3 + src/widgets/views/Kanban/KanbanCard.tsx | 108 ++++++++++++++-------- src/widgets/views/Kanban/KanbanColumn.tsx | 32 ++++++- src/widgets/views/Kanban/useKanbanData.ts | 28 +++++- 5 files changed, 126 insertions(+), 53 deletions(-) diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index f5102e84b..a9a6a21de 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -10,6 +10,7 @@ import { 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 } from "./KanbanBoard"; import { useKanbanData, KanbanRecord } from "./useKanbanData"; import { useKanbanAggregates } from "./useKanbanAggregates"; @@ -84,10 +85,10 @@ const KanbanComponentInner = ( if ( kanbanDef.buttons.some( - (b: any) => b.states !== undefined && b.states !== null, + (b: KanbanButton) => b.states !== undefined && b.states !== null, ) ) { - fields.push("state", "status"); + fields.push(kanbanDef.column_field); } fields.push("__model"); @@ -102,6 +103,7 @@ const KanbanComponentInner = ( error: dataError, fetchRecords, colorsForRecords, + statusForRecords, totalRows, } = useKanbanData({ model, @@ -191,6 +193,7 @@ const KanbanComponentInner = ( columns={columns} kanbanDef={kanbanDef} colorsForRecords={colorsForRecords} + statusForRecords={statusForRecords} context={context} isLoading={isLoadingData} isRefreshing={isRefreshingData} @@ -207,6 +210,7 @@ const KanbanComponentInner = ( kanbanDef, columns, colorsForRecords, + statusForRecords, context, isLoadingData, isRefreshingData, diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index 262805887..6e9ceffa2 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -21,6 +21,7 @@ import { KanbanAggregatesByColumn } from "./useKanbanAggregates"; type KanbanBoardProps = { colorsForRecords?: RefObject<{ [key: number]: string }>; + statusForRecords?: RefObject<{ [key: number]: string }>; columns: KanbanColumnType[]; kanbanDef: Kanban; context?: any; @@ -36,6 +37,7 @@ type KanbanBoardProps = { const KanbanBoardComponent = (props: KanbanBoardProps) => { const { colorsForRecords, + statusForRecords, columns, kanbanDef, context = {}, @@ -145,6 +147,7 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { kanbanDef={kanbanDef} draggable={kanbanDef.drag} colorsForRecords={colorsForRecords} + statusForRecords={statusForRecords} sortable={kanbanDef.sort} allowSetMaxCards={false} context={context} diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index cd6a955b9..3b881169b 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -1,12 +1,14 @@ import { memo, useMemo, useState, MouseEvent, useCallback } from "react"; -import { Card as AntCard, Button, Space, Typography, theme } from "antd"; +import { Card as AntCard, Badge, Button, Space, Typography, theme } from "antd"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import styled from "styled-components"; import { KanbanRecord } from "./useKanbanData"; -import { Kanban, Button as KanbanButton } from "@gisce/ooui"; +import { Kanban } from "@gisce/ooui"; +import type { KanbanButton } from "@gisce/ooui/dist/Kanban"; import ConnectionProvider from "@/ConnectionProvider"; import { KANBAN_COMPONENTS } from "./kanbanComponents"; +import { Icon } from "@gisce/react-formiga-components"; const { Text } = Typography; const { useToken } = theme; @@ -51,6 +53,7 @@ type KanbanCardProps = { kanbanDef: Kanban; draggable: boolean; color?: string; + status?: string; context?: any; onClick?: () => void; onButtonClick?: (buttonName: string, recordId: number) => void; @@ -62,6 +65,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { kanbanDef, draggable, color, + status, context = {}, onClick, onButtonClick, @@ -157,12 +161,12 @@ const KanbanCardComponent = (props: KanbanCardProps) => { ); const visibleButtons = useMemo(() => { - return kanbanDef.buttons.filter((button: any) => { + return kanbanDef.buttons.filter((button: KanbanButton) => { if (!button.states) { return true; } - const currentState = record.state || record.status; + const currentState = record[kanbanDef.column_field]; if (!currentState) { return true; } @@ -172,7 +176,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { .map((s: string) => s.trim()); return allowedStates.includes(currentState); }); - }, [kanbanDef.buttons, record]); + }, [kanbanDef.buttons, kanbanDef.column_field, record]); const handleButtonClick = useCallback( async (e: MouseEvent, button: KanbanButton) => { @@ -205,45 +209,67 @@ const KanbanCardComponent = (props: KanbanCardProps) => { [loadingButton, record, onButtonClick], ); + const buttonClickHandlers = useMemo(() => { + return visibleButtons.reduce void>>( + (acc, button) => { + acc[button.id] = (e: MouseEvent) => handleButtonClick(e, button); + return acc; + }, + {}, + ); + }, [visibleButtons, handleButtonClick]); + + const cardContent = ( + + {color && } +
+ {kanbanDef.card_fields.map((field: any) => renderField(field))} +
+ + {visibleButtons.length > 0 && ( + + {visibleButtons.map((button: KanbanButton) => ( + + ))} + + )} +
+ ); + return (
- - {color && } -
- {kanbanDef.card_fields.map((field: any) => renderField(field))} -
- - {visibleButtons.length > 0 && ( - - {visibleButtons.map((button: any) => ( - - ))} - - )} -
+ {status ? ( + } color={status}> + {cardContent} + + ) : ( + cardContent + )}
); diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index cb84f4683..87c69a03f 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -1,4 +1,4 @@ -import { memo, RefObject } from "react"; +import { memo, RefObject, useMemo } from "react"; import { Badge, Button, Space, theme, Typography } from "antd"; import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; import { useDroppable } from "@dnd-kit/core"; @@ -23,6 +23,7 @@ type KanbanColumnProps = { kanbanDef: Kanban; draggable: boolean; colorsForRecords?: RefObject<{ [key: number]: string }>; + statusForRecords?: RefObject<{ [key: number]: string }>; sortable: boolean; allowSetMaxCards: boolean; maxCards?: number; @@ -40,6 +41,7 @@ const KanbanColumnComponent = (props: KanbanColumnProps) => { kanbanDef, draggable, colorsForRecords, + statusForRecords, sortable, maxCards, context = {}, @@ -56,16 +58,34 @@ const KanbanColumnComponent = (props: KanbanColumnProps) => { id: column.id, }); - const recordIds = column.records.map((r) => r.id); + const recordIds = useMemo( + () => column.records.map((r) => r.id), + [column.records], + ); const isOverLimit = maxCards !== undefined && column.count > maxCards; - const aggregatesSummary = - aggregates && Object.keys(aggregates).length > 0 + 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 column.records.reduce void>>((acc, record) => { + acc[record.id] = () => onCardClick(record); + return acc; + }, {}); + }, [column.records, onCardClick]); + + const hasStatusRibbon = useMemo(() => { + return column.records.some( + (record) => statusForRecords?.current?.[record.id], + ); + }, [column.records, statusForRecords]); return (
{ ref={setNodeRef} style={{ padding: "6px", + paddingRight: hasStatusRibbon ? "10px" : "6px", overflowY: "auto", flex: 1, backgroundColor: token.colorBgLayout, @@ -148,12 +169,13 @@ const KanbanColumnComponent = (props: KanbanColumnProps) => { {column.records.map((record) => ( onCardClick?.(record)} + onClick={cardClickHandlers[record.id]} onButtonClick={onButtonClick} /> ))} diff --git a/src/widgets/views/Kanban/useKanbanData.ts b/src/widgets/views/Kanban/useKanbanData.ts index 96d2c579b..535c2c14e 100644 --- a/src/widgets/views/Kanban/useKanbanData.ts +++ b/src/widgets/views/Kanban/useKanbanData.ts @@ -55,6 +55,7 @@ export const useKanbanData = (params: UseKanbanDataParams) => { const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); const colorsForRecords = useRef<{ [key: number]: string }>({}); + const statusForRecords = useRef<{ [key: number]: string }>({}); const hasInitialDataRef = useRef(false); const previousViewIdRef = useRef(viewId); @@ -158,24 +159,40 @@ export const useKanbanData = (params: UseKanbanDataParams) => { setRecords(fetchedRecords); - if (kanbanDef?.colors && fetchedRecords.length > 0) { + if ( + (kanbanDef?.colors || kanbanDef?.status) && + fetchedRecords.length > 0 + ) { try { + const conditions: any = {}; + if (kanbanDef.colors) { + conditions.colors = kanbanDef.colors; + } + if (kanbanDef.status) { + conditions.status = kanbanDef.status; + } + const attrsEvaluated = await parseConditions({ - conditions: { colors: kanbanDef.colors }, + conditions, values: fetchedRecords, context, }); if (attrsEvaluated && Array.isArray(attrsEvaluated)) { attrsEvaluated.forEach((attr: any) => { - if (attr.id !== undefined && attr.colors) { - colorsForRecords.current[attr.id] = attr.colors; + if (attr.id !== undefined) { + if (attr.colors) { + colorsForRecords.current[attr.id] = attr.colors; + } + if (attr.status) { + statusForRecords.current[attr.id] = attr.status; + } } }); } } catch (err: any) { if (err.name !== "AbortError") { - console.warn("Error evaluating colors:", err); + console.warn("Error evaluating colors/status:", err); } } } @@ -295,6 +312,7 @@ export const useKanbanData = (params: UseKanbanDataParams) => { isRefreshing, error, colorsForRecords, + statusForRecords, fetchRecords, moveRecord, totalRows, From 993ea4179d56552e12c7ecacf347ca7dce8d2248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 6 Nov 2025 16:45:14 +0100 Subject: [PATCH 14/41] fix: status icon for cards --- src/widgets/views/Kanban/KanbanCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index 3b881169b..b02964ff8 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -264,7 +264,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => {
{status ? ( - } color={status}> + {cardContent} ) : ( From cf8dd654481289169652c47e2edb23c04c76354a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 6 Nov 2025 16:49:07 +0100 Subject: [PATCH 15/41] fix: adjust margin --- src/widgets/views/Kanban/KanbanCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index b02964ff8..a463c5b2f 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -232,7 +232,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { padding: "12px", paddingLeft: "20px", paddingTop: "12px", - paddingRight: status ? "24px" : "12px", + paddingRight: status ? "16px" : "12px", }, }} > From deb27d922a426e96a3e24cf35228b43fabdf37a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 6 Nov 2025 21:03:45 +0100 Subject: [PATCH 16/41] feat: more improvements --- AGENTS.md | 1 - src/hooks/useAvailableHeight.ts | 20 ++- src/views/actionViews/KanbanActionView.tsx | 20 ++- src/widgets/views/Kanban/KanbanColumn.tsx | 35 +++- src/widgets/views/Kanban/useKanbanData.ts | 184 +++++++++++++++++---- 5 files changed, 214 insertions(+), 46 deletions(-) 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/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/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index 01ef80d86..a1df79ce7 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -68,11 +68,13 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { 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( @@ -196,14 +198,16 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { onSubmit={onSideSearchFilterSubmit} onClear={onSideSearchFilterClear} /> - - - +
+ + + +
{
- - +
+ {column.label} { backgroundColor: isOverLimit ? token.colorError : token.colorPrimary, + flexShrink: 0, }} /> - - +
+
{isLoadingAggregates ? ( { {aggregatesSummary} ) : null} - +
diff --git a/src/widgets/views/Kanban/useKanbanData.ts b/src/widgets/views/Kanban/useKanbanData.ts index 535c2c14e..0d61db411 100644 --- a/src/widgets/views/Kanban/useKanbanData.ts +++ b/src/widgets/views/Kanban/useKanbanData.ts @@ -4,6 +4,7 @@ import ConnectionProvider from "@/ConnectionProvider"; import { useNetworkRequest } from "@/hooks/useNetworkRequest"; import { mergeParams } from "@/helpers/searchHelper"; import { Kanban } from "@gisce/ooui"; +import { useLocale } from "@gisce/react-formiga-components"; export type KanbanRecord = { id: number; @@ -49,6 +50,8 @@ export const useKanbanData = (params: UseKanbanDataParams) => { viewId, } = params; + const { t } = useLocale(); + const [columns, setColumns] = useState([]); const [records, setRecords] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -82,34 +85,144 @@ export const useKanbanData = (params: UseKanbanDataParams) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const extractColumnId = useCallback((columnValue: any): string | null => { - if (!columnValue) return null; + const normalizeColumnValue = useCallback( + ( + value: any, + fieldDefinition: any, + ): { originalValue: any; key: string; displayName: string } | null => { + if (value === null || value === undefined) { + if (fieldDefinition?.type !== "boolean") { + return null; + } + } - if (Array.isArray(columnValue) && columnValue.length === 2) { - return String(columnValue[0]); - } + const fieldType = fieldDefinition?.type; - return String(columnValue); - }, []); + switch (fieldType) { + case "many2one": + if (Array.isArray(value) && value.length === 2) { + return { + originalValue: value, + key: String(value[0]), + displayName: value[1], + }; + } + 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 { + originalValue: value, + key: String(selectionKey), + displayName: selectionLabel, + }; + } + + case "boolean": { + const boolValue = + value === true || value === 1 || value === "true" || value === "1"; + return { + originalValue: value, + key: String(boolValue), + displayName: boolValue ? t("yes") : t("no"), + }; + } + + case "reference": { + if (typeof value === "string" && value.includes(",")) { + const [, idPart] = value.split(","); + return { + originalValue: value, + key: idPart, + displayName: value, + }; + } + return null; + } + + default: { + if (value === null || value === undefined) { + return null; + } + const stringValue = String(value); + return { + originalValue: value, + key: stringValue, + displayName: stringValue, + }; + } + } + }, + [t], + ); + + const extractColumnId = useCallback( + (columnValue: any, fieldDefinition?: any): string | null => { + if (!fieldDefinition) { + if (!columnValue && columnValue !== false && columnValue !== 0) + return null; + + if (Array.isArray(columnValue) && columnValue.length === 2) { + return String(columnValue[0]); + } + + return String(columnValue); + } + + const normalized = normalizeColumnValue(columnValue, fieldDefinition); + return normalized ? normalized.key : null; + }, + [normalizeColumnValue], + ); const extractColumnInfo = useCallback( - (columnValue: any): { id: string; label: string } | null => { - if (!columnValue) return null; + ( + columnValue: any, + fieldDefinition?: any, + ): { id: string; label: string } | null => { + if (!fieldDefinition) { + if (!columnValue && columnValue !== false && columnValue !== 0) + return null; + + if (Array.isArray(columnValue) && columnValue.length === 2) { + return { + id: String(columnValue[0]), + label: columnValue[1], + }; + } - if (Array.isArray(columnValue) && columnValue.length === 2) { + const value = String(columnValue); return { - id: String(columnValue[0]), - label: columnValue[1], + id: value, + label: value, }; } - const value = String(columnValue); - return { - id: value, - label: value, - }; + const normalized = normalizeColumnValue(columnValue, fieldDefinition); + return normalized + ? { id: normalized.key, label: normalized.displayName } + : null; }, - [], + [normalizeColumnValue], ); const getColumnDefinitions = useCallback((): ColumnDefinition[] => { @@ -118,18 +231,28 @@ export const useKanbanData = (params: UseKanbanDataParams) => { } const fieldType = columnFieldDefinition.type; - const selectionValues = - columnFieldDefinition.selection || columnFieldDefinition.selectionValues; - - if (fieldType === "selection" && selectionValues) { - return selectionValues.map(([id, label]: [string, string]) => ({ - id: String(id), - label: String(label), - })); + + if (fieldType === "boolean") { + return [ + { id: "false", label: t("no") }, + { id: "true", label: t("yes") }, + ]; + } + + if (fieldType === "selection") { + const selectionValues = + columnFieldDefinition.selection || + columnFieldDefinition.selectionValues; + if (selectionValues) { + return selectionValues.map(([id, label]: [string, string]) => ({ + id: String(id), + label: String(label), + })); + } } return []; - }, [columnFieldDefinition]); + }, [columnFieldDefinition, t]); const fetchRecords = useDeepCompareCallback(async () => { if (!enabled || !model || !columnField) { @@ -203,7 +326,10 @@ export const useKanbanData = (params: UseKanbanDataParams) => { if (columnDefs.length === 0 && columnFieldDefinition) { fetchedRecords.forEach((record: KanbanRecord) => { const columnValue = record[columnField]; - const columnInfo = extractColumnInfo(columnValue); + const columnInfo = extractColumnInfo( + columnValue, + columnFieldDefinition, + ); if (!columnInfo) return; @@ -230,7 +356,7 @@ export const useKanbanData = (params: UseKanbanDataParams) => { fetchedRecords.forEach((record: KanbanRecord) => { const columnValue = record[columnField]; - const colId = extractColumnId(columnValue); + const colId = extractColumnId(columnValue, columnFieldDefinition); if (!colId) return; From 87467fa19d6469df19a1d36f569b58e9d40ae89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Fri, 7 Nov 2025 08:57:15 +0100 Subject: [PATCH 17/41] feat: more adjustments --- src/widgets/views/Kanban/KanbanBoard.tsx | 44 ++++++-- src/widgets/views/Kanban/KanbanCard.tsx | 119 +++++++++++----------- src/widgets/views/Kanban/KanbanColumn.tsx | 13 +-- 3 files changed, 101 insertions(+), 75 deletions(-) diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index 6e9ceffa2..58d9bbd90 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -52,7 +52,7 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { const { t } = useLocale(); const [activeRecord, setActiveRecord] = useState(null); - const [isDragging, setIsDragging] = useState(false); + const [overColumnId, setOverColumnId] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { @@ -71,7 +71,6 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { const record = column.records.find((r) => r.id === recordId); if (record) { setActiveRecord(record); - setIsDragging(true); break; } } @@ -79,16 +78,45 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { [columns], ); - const handleDragOver = useCallback((_event: DragOverEvent) => {}, []); + const handleDragOver = useCallback( + (event: DragOverEvent) => { + const { over } = event; + if (!over) { + setOverColumnId(null); + return; + } + + // Check if we're over a column directly + const overColumn = columns.find((col) => col.id === over.id); + if (overColumn) { + setOverColumnId(overColumn.id); + return; + } + + // Check if we're over a card - find which column it belongs to + for (const column of columns) { + const isOverCard = column.records.some( + (record) => record.id === over.id, + ); + if (isOverCard) { + setOverColumnId(column.id); + return; + } + } + + setOverColumnId(null); + }, + [columns], + ); const handleDragEnd = useCallback(async () => { - setIsDragging(false); setActiveRecord(null); + setOverColumnId(null); }, []); const handleDragCancel = useCallback(() => { - setIsDragging(false); setActiveRecord(null); + setOverColumnId(null); }, []); if (isLoading && !isRefreshing) { @@ -148,7 +176,6 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { draggable={kanbanDef.drag} colorsForRecords={colorsForRecords} statusForRecords={statusForRecords} - sortable={kanbanDef.sort} allowSetMaxCards={false} context={context} aggregates={ @@ -157,6 +184,7 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { isLoadingAggregates={isLoadingAggregates} onCardClick={onCardClick} onButtonClick={onButtonClick} + isOver={overColumnId === column.id} /> ))}
@@ -165,7 +193,6 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { {activeRecord ? (
{ record={activeRecord} kanbanDef={kanbanDef} draggable={false} + color={colorsForRecords?.current?.[activeRecord.id]} + status={statusForRecords?.current?.[activeRecord.id]} + context={context} />
) : null} diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index a463c5b2f..ae2479569 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -1,14 +1,12 @@ import { memo, useMemo, useState, MouseEvent, useCallback } from "react"; -import { Card as AntCard, Badge, Button, Space, Typography, theme } from "antd"; +import { Card as AntCard, Button, Space, Typography, theme } from "antd"; import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; import styled from "styled-components"; import { KanbanRecord } from "./useKanbanData"; import { Kanban } from "@gisce/ooui"; import type { KanbanButton } from "@gisce/ooui/dist/Kanban"; import ConnectionProvider from "@/ConnectionProvider"; import { KANBAN_COMPONENTS } from "./kanbanComponents"; -import { Icon } from "@gisce/react-formiga-components"; const { Text } = Typography; const { useToken } = theme; @@ -32,6 +30,11 @@ const StyledCard = styled(AntCard)<{ border: 1px solid ${(props) => props.$borderColor}; outline: none; outline-offset: -1px; + overflow: visible; + + .ant-card-body { + overflow: visible; + } &:hover { outline: 3px solid ${(props) => props.$color || props.$primaryColor}; @@ -48,6 +51,16 @@ const ColorBar = styled.div<{ $color: string }>` border-radius: 7px 0 0 7px; `; +const StatusDot = styled.div<{ $color: string }>` + position: absolute; + right: 8px; + top: 8px; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: ${(props) => props.$color}; +`; + type KanbanCardProps = { record: KanbanRecord; kanbanDef: Kanban; @@ -73,22 +86,13 @@ const KanbanCardComponent = (props: KanbanCardProps) => { const { token } = useToken(); const [loadingButton, setLoadingButton] = useState(null); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ + const { attributes, listeners, setNodeRef, isDragging } = useSortable({ id: record.id, disabled: !draggable, }); const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, + opacity: isDragging ? 0 : 1, cursor: "pointer", }; @@ -219,57 +223,48 @@ const KanbanCardComponent = (props: KanbanCardProps) => { ); }, [visibleButtons, handleButtonClick]); - const cardContent = ( - - {color && } -
- {kanbanDef.card_fields.map((field: any) => renderField(field))} -
- - {visibleButtons.length > 0 && ( - - {visibleButtons.map((button: KanbanButton) => ( - - ))} - - )} -
- ); - return (
- {status ? ( - - {cardContent} - - ) : ( - cardContent - )} + + {color && } + {status && } +
+ {kanbanDef.card_fields.map((field: any) => renderField(field))} +
+ + {visibleButtons.length > 0 && ( + + {visibleButtons.map((button: KanbanButton) => ( + + ))} + + )} +
); diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index 41f556cfa..0c4a15f2b 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -24,12 +24,12 @@ type KanbanColumnProps = { draggable: boolean; colorsForRecords?: RefObject<{ [key: number]: string }>; statusForRecords?: RefObject<{ [key: number]: string }>; - sortable: boolean; allowSetMaxCards: boolean; maxCards?: number; context?: any; aggregates?: KanbanColumnAggregatesType; isLoadingAggregates?: boolean; + isOver?: boolean; onCardClick?: (record: KanbanRecord) => void; onButtonClick?: (buttonName: string, recordId: number) => void; onMaxCardsChange?: (colId: string, maxCards: number | undefined) => void; @@ -42,11 +42,11 @@ const KanbanColumnComponent = (props: KanbanColumnProps) => { draggable, colorsForRecords, statusForRecords, - sortable, maxCards, context = {}, aggregates, isLoadingAggregates = false, + isOver = false, onCardClick, onButtonClick, } = props; @@ -54,7 +54,7 @@ const KanbanColumnComponent = (props: KanbanColumnProps) => { const { t } = useLocale(); const { token } = useToken(); - const { setNodeRef, isOver } = useDroppable({ + const { setNodeRef } = useDroppable({ id: column.id, }); @@ -89,12 +89,14 @@ const KanbanColumnComponent = (props: KanbanColumnProps) => { return (
{
{ {column.records.map((record) => ( Date: Tue, 11 Nov 2025 12:39:12 +0100 Subject: [PATCH 18/41] feat: share results between tree & kanban + filters --- src/views/actionViews/KanbanActionView.tsx | 2 +- src/widgets/views/Kanban/Kanban.tsx | 130 ++--- src/widgets/views/Kanban/KanbanBoard.tsx | 115 ++--- src/widgets/views/Kanban/KanbanCard.tsx | 2 +- src/widgets/views/Kanban/KanbanColumn.tsx | 118 +++-- src/widgets/views/Kanban/types.ts | 10 + .../views/Kanban/useKanbanAggregates.ts | 146 ------ .../views/Kanban/useKanbanColumnData.ts | 248 ++++++++++ src/widgets/views/Kanban/useKanbanColumns.ts | 259 ++++++++++ src/widgets/views/Kanban/useKanbanData.ts | 446 ------------------ 10 files changed, 707 insertions(+), 769 deletions(-) create mode 100644 src/widgets/views/Kanban/types.ts delete mode 100644 src/widgets/views/Kanban/useKanbanAggregates.ts create mode 100644 src/widgets/views/Kanban/useKanbanColumnData.ts create mode 100644 src/widgets/views/Kanban/useKanbanColumns.ts delete mode 100644 src/widgets/views/Kanban/useKanbanData.ts diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index a1df79ce7..b6a494c75 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -12,7 +12,7 @@ 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 } from "@/widgets/views/Kanban/useKanbanData"; +import { KanbanRecord } from "@/widgets/views/Kanban/types"; import { FormModal } from "@/widgets/modals/FormModal"; import { SearchTreeHeader } from "@/widgets/views/SearchTreeHeader"; import { SideSearchFilter } from "@/widgets/views/searchFilter/SideSearchFilter"; diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index a9a6a21de..540f6551a 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -6,17 +6,18 @@ import { 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 } from "./KanbanBoard"; -import { useKanbanData, KanbanRecord } from "./useKanbanData"; -import { useKanbanAggregates } from "./useKanbanAggregates"; +import { KanbanRecord } from "./types"; +import { useKanbanColumns } from "./useKanbanColumns"; import { Alert, Spin } from "antd"; -import { mergeParams } from "@/helpers/searchHelper"; import { useLocale } from "@gisce/react-formiga-components"; +import { KanbanColumnRef } from "./KanbanColumn"; type KanbanProps = { kanbanView: KanbanView; @@ -98,68 +99,69 @@ const KanbanComponentInner = ( const { columns, - isLoading: isLoadingData, - isRefreshing: isRefreshingData, - error: dataError, - fetchRecords, - colorsForRecords, - statusForRecords, - totalRows, - } = useKanbanData({ + isLoading: isLoadingColumns, + error: columnsError, + } = useKanbanColumns({ model, domain, context, columnField: kanbanDef?.column_field || "", columnFieldDefinition: columnFieldDef, searchParams, - fieldsToRetrieve, enabled: !!kanbanDef && !!columnFieldDef, - kanbanDef: kanbanDef || undefined, - viewId: kanbanView.view_id, }); + const columnRefs = useRef>(new Map()); + const [columnCounts, setColumnCounts] = useState>({}); + useImperativeHandle(ref, () => ({ refreshResults: () => { - fetchRecords(); + // Trigger refresh on all columns + columnRefs.current.forEach((ref) => { + ref.refresh(); + }); }, })); - const columnIds = useMemo(() => columns.map((col) => col.id), [columns]); - - const aggregatedDomain = useMemo( - () => mergeParams(domain, searchParams), - [domain, searchParams], + const handleButtonClick = useCallback( + async (buttonName: string, recordId: number) => { + // Refresh all columns after button click + columnRefs.current.forEach((ref) => { + ref.refresh(); + }); + }, + [], ); - const { - aggregatesByColumn, - isLoading: isLoadingAggregates, - hasAggregates, - } = useKanbanAggregates({ - kanbanDef: kanbanDef || undefined, - model, - domain: aggregatedDomain, - context, - columnField: kanbanDef?.column_field || "", - columnIds, - enabled: !!kanbanDef && columns.length > 0, - }); + const setColumnRef = useCallback( + (columnId: string, ref: KanbanColumnRef | null) => { + if (ref) { + columnRefs.current.set(columnId, ref); + } else { + columnRefs.current.delete(columnId); + } + }, + [], + ); - useEffect(() => { - const isLoading = isLoadingData || isRefreshingData || isLoadingAggregates; - onLoadingChange?.(isLoading); - }, [isLoadingData, isRefreshingData, isLoadingAggregates, onLoadingChange]); + const handleColumnCountChange = useCallback( + (columnId: string, count: number) => { + setColumnCounts((prev) => ({ + ...prev, + [columnId]: count, + })); + }, + [], + ); + // Calculate and report total rows useEffect(() => { + const totalRows = Object.values(columnCounts).reduce( + (sum, count) => sum + count, + 0, + ); onTotalRowsChange?.(totalRows); - }, [totalRows, onTotalRowsChange]); - - const handleButtonClick = useCallback( - async (buttonName: string, recordId: number) => { - await fetchRecords(); - }, - [fetchRecords], - ); + }, [columnCounts, onTotalRowsChange]); const content = useDeepCompareMemo(() => { if (parsingError) { @@ -173,52 +175,52 @@ const KanbanComponentInner = ( ); } - if (dataError) { + if (columnsError) { return ( ); } - if (!kanbanDef) { + if (!kanbanDef || isLoadingColumns) { return ; } return ( ); }, [ parsingError, - dataError, + columnsError, kanbanDef, columns, - colorsForRecords, - statusForRecords, + isLoadingColumns, + model, + domain, context, - isLoadingData, - isRefreshingData, - aggregatesByColumn, - isLoadingAggregates, - hasAggregates, + searchParams, + fieldsToRetrieve, onCardClick, handleButtonClick, + setColumnRef, + handleColumnCountChange, t, ]); diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index 58d9bbd90..afc2a369a 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { memo, useState, RefObject, useCallback } from "react"; +import { memo, useState, useCallback, useRef } from "react"; import { DndContext, DragOverEvent, @@ -8,51 +8,48 @@ import { useSensor, useSensors, } from "@dnd-kit/core"; -import { Spin } from "antd"; -import { KanbanColumn } from "./KanbanColumn"; +import { KanbanColumn, KanbanColumnRef } from "./KanbanColumn"; import { KanbanCard } from "./KanbanCard"; -import { - KanbanColumn as KanbanColumnType, - KanbanRecord, -} from "./useKanbanData"; +import { KanbanRecord, ColumnDefinition } from "./types"; import { Kanban } from "@gisce/ooui"; import { useLocale } from "@gisce/react-formiga-components"; -import { KanbanAggregatesByColumn } from "./useKanbanAggregates"; type KanbanBoardProps = { - colorsForRecords?: RefObject<{ [key: number]: string }>; - statusForRecords?: RefObject<{ [key: number]: string }>; - columns: KanbanColumnType[]; + columns: ColumnDefinition[]; + columnField: string; + model: string; + domain: any[]; + context: any; + searchParams?: any[]; + fieldsToRetrieve?: string[]; kanbanDef: Kanban; - context?: any; - isLoading?: boolean; - isRefreshing?: boolean; - aggregatesByColumn?: KanbanAggregatesByColumn; - isLoadingAggregates?: boolean; - hasAggregates?: boolean; onCardClick?: (record: KanbanRecord) => void; onButtonClick?: (buttonName: string, recordId: number) => void; + setColumnRef: (columnId: string, ref: KanbanColumnRef | null) => void; + onColumnCountChange: (columnId: string, count: number) => void; }; const KanbanBoardComponent = (props: KanbanBoardProps) => { const { - colorsForRecords, - statusForRecords, columns, - kanbanDef, + columnField, + model, + domain, context = {}, - isLoading = false, - isRefreshing = false, - aggregatesByColumn, - isLoadingAggregates = false, - hasAggregates = false, + searchParams, + fieldsToRetrieve, + kanbanDef, onCardClick, onButtonClick, + setColumnRef, + onColumnCountChange, } = props; const { t } = useLocale(); const [activeRecord, setActiveRecord] = useState(null); const [overColumnId, setOverColumnId] = useState(null); + const colorsForRecordsRef = useRef<{ [key: number]: string }>({}); + const statusForRecordsRef = useRef<{ [key: number]: string }>({}); const sensors = useSensors( useSensor(PointerSensor, { @@ -62,21 +59,14 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { }), ); - const handleDragStart = useCallback( - (event: DragStartEvent) => { - const { active } = event; - const recordId = active.id as number; + const handleDragStart = useCallback((event: DragStartEvent) => { + const { active } = event; + const recordId = active.id as number; - for (const column of columns) { - const record = column.records.find((r) => r.id === recordId); - if (record) { - setActiveRecord(record); - break; - } - } - }, - [columns], - ); + // For now, we'll handle drag state without needing all records + // The active record will be stored in state + setActiveRecord({ id: recordId } as KanbanRecord); + }, []); const handleDragOver = useCallback( (event: DragOverEvent) => { @@ -93,17 +83,6 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { return; } - // Check if we're over a card - find which column it belongs to - for (const column of columns) { - const isOverCard = column.records.some( - (record) => record.id === over.id, - ); - if (isOverCard) { - setOverColumnId(column.id); - return; - } - } - setOverColumnId(null); }, [columns], @@ -119,21 +98,6 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { setOverColumnId(null); }, []); - if (isLoading && !isRefreshing) { - return ( -
- -
- ); - } - if (columns.length === 0) { return (
{ {columns.map((column) => ( setColumnRef(column.id, ref)} column={column} + columnField={columnField} + model={model} + domain={domain} + context={context} + searchParams={searchParams} + fieldsToRetrieve={fieldsToRetrieve} kanbanDef={kanbanDef} draggable={kanbanDef.drag} - colorsForRecords={colorsForRecords} - statusForRecords={statusForRecords} allowSetMaxCards={false} - context={context} - aggregates={ - hasAggregates ? aggregatesByColumn?.[column.id] : undefined - } - isLoadingAggregates={isLoadingAggregates} onCardClick={onCardClick} onButtonClick={onButtonClick} + onCountChange={onColumnCountChange} isOver={overColumnId === column.id} /> ))}
- {activeRecord ? ( + {activeRecord && activeRecord.id ? (
{ record={activeRecord} kanbanDef={kanbanDef} draggable={false} - color={colorsForRecords?.current?.[activeRecord.id]} - status={statusForRecords?.current?.[activeRecord.id]} + color={colorsForRecordsRef?.current?.[activeRecord.id]} + status={statusForRecordsRef?.current?.[activeRecord.id]} context={context} />
diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index ae2479569..68ed9860d 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -2,7 +2,7 @@ import { memo, useMemo, useState, MouseEvent, useCallback } from "react"; import { Card as AntCard, Button, Space, Typography, theme } from "antd"; import { useSortable } from "@dnd-kit/sortable"; import styled from "styled-components"; -import { KanbanRecord } from "./useKanbanData"; +import { KanbanRecord } from "./types"; import { Kanban } from "@gisce/ooui"; import type { KanbanButton } from "@gisce/ooui/dist/Kanban"; import ConnectionProvider from "@/ConnectionProvider"; diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index 0c4a15f2b..7e66e9d56 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -1,4 +1,10 @@ -import { memo, RefObject, useMemo } from "react"; +import { + memo, + useMemo, + forwardRef, + useImperativeHandle, + useEffect, +} from "react"; import { Badge, Button, Space, theme, Typography } from "antd"; import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; import { useDroppable } from "@dnd-kit/core"; @@ -7,63 +13,103 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { KanbanCard } from "./KanbanCard"; -import { - KanbanColumn as KanbanColumnType, - KanbanRecord, -} from "./useKanbanData"; +import { KanbanRecord, ColumnDefinition } from "./types"; import { Kanban } from "@gisce/ooui"; import { useLocale } from "@gisce/react-formiga-components"; -import { KanbanColumnAggregates as KanbanColumnAggregatesType } from "./useKanbanAggregates"; +import { useKanbanColumnData } from "./useKanbanColumnData"; const { Text } = Typography; const { useToken } = theme; +export type KanbanColumnRef = { + refresh: () => void; +}; + type KanbanColumnProps = { - column: KanbanColumnType; + column: ColumnDefinition; + columnField: string; + model: string; + domain: any[]; + context: any; + searchParams?: any[]; + fieldsToRetrieve?: string[]; kanbanDef: Kanban; draggable: boolean; - colorsForRecords?: RefObject<{ [key: number]: string }>; - statusForRecords?: RefObject<{ [key: number]: string }>; allowSetMaxCards: boolean; maxCards?: number; - context?: any; - aggregates?: KanbanColumnAggregatesType; - isLoadingAggregates?: boolean; isOver?: boolean; onCardClick?: (record: KanbanRecord) => void; onButtonClick?: (buttonName: string, recordId: number) => void; onMaxCardsChange?: (colId: string, maxCards: number | undefined) => void; + onCountChange: (columnId: string, count: number) => void; }; -const KanbanColumnComponent = (props: KanbanColumnProps) => { +const KanbanColumnComponent = ( + props: KanbanColumnProps, + ref: React.Ref, +) => { const { column, + columnField, + model, + domain, + context = {}, + searchParams, + fieldsToRetrieve, kanbanDef, draggable, - colorsForRecords, - statusForRecords, maxCards, - context = {}, - aggregates, - isLoadingAggregates = false, isOver = false, onCardClick, onButtonClick, + onCountChange, } = props; + const { + id: columnId, + label: columnLabel, + originalValue: columnOriginalValue, + } = column; + const { t } = useLocale(); const { token } = useToken(); + const { + records, + count, + aggregates, + colorsForRecords, + statusForRecords, + isLoading, + refresh, + } = useKanbanColumnData({ + model, + domain, + context, + columnField, + columnValue: columnOriginalValue, + searchParams, + fieldsToRetrieve, + enabled: true, + kanbanDef, + }); + + useImperativeHandle(ref, () => ({ + refresh, + })); + + // Report count changes to parent + useEffect(() => { + onCountChange(columnId, count); + }, [columnId, count, onCountChange]); + const { setNodeRef } = useDroppable({ - id: column.id, + id: columnId, }); - const recordIds = useMemo( - () => column.records.map((r) => r.id), - [column.records], - ); + const recordIds = useMemo(() => records.map((r) => r.id), [records]); - const isOverLimit = maxCards !== undefined && column.count > maxCards; + const isOverLimit = maxCards !== undefined && count > maxCards; const aggregatesSummary = useMemo(() => { return aggregates && Object.keys(aggregates).length > 0 @@ -75,17 +121,15 @@ const KanbanColumnComponent = (props: KanbanColumnProps) => { const cardClickHandlers = useMemo(() => { if (!onCardClick) return {}; - return column.records.reduce void>>((acc, record) => { + return records.reduce void>>((acc, record) => { acc[record.id] = () => onCardClick(record); return acc; }, {}); - }, [column.records, onCardClick]); + }, [records, onCardClick]); const hasStatusRibbon = useMemo(() => { - return column.records.some( - (record) => statusForRecords?.current?.[record.id], - ); - }, [column.records, statusForRecords]); + return records.some((record) => statusForRecords?.current?.[record.id]); + }, [records, statusForRecords]); return (
{ wordBreak: "break-word", }} > - {column.label} + {columnLabel} { alignItems: "center", }} > - {isLoadingAggregates ? ( + {isLoading ? ( { strategy={verticalListSortingStrategy} disabled={true} > - {column.records.map((record) => ( + {records.map((record) => ( { ))} - {column.records.length === 0 && ( + {records.length === 0 && (
{ ); }; -export const KanbanColumn = memo(KanbanColumnComponent); +export const KanbanColumn = memo( + forwardRef(KanbanColumnComponent), +); 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/useKanbanAggregates.ts b/src/widgets/views/Kanban/useKanbanAggregates.ts deleted file mode 100644 index b9a7229e7..000000000 --- a/src/widgets/views/Kanban/useKanbanAggregates.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { useState } from "react"; -import ConnectionProvider from "@/ConnectionProvider"; -import { useNetworkRequest } from "@/hooks/useNetworkRequest"; -import { Kanban } from "@gisce/ooui"; -import { - useDeepCompareEffect, - useDeepCompareCallback, - useDeepCompareMemo, -} from "use-deep-compare"; - -export type KanbanColumnAggregates = { - [fieldName: string]: { - label: string; - amount: number | string; - }; -}; - -export type KanbanAggregatesByColumn = { - [columnId: string]: KanbanColumnAggregates; -}; - -export const useKanbanAggregates = ({ - kanbanDef, - model, - domain, - context, - columnField, - columnIds, - enabled = true, -}: { - kanbanDef?: Kanban; - model: string; - domain: any[]; - context: any; - columnField: string; - columnIds: string[]; - enabled?: boolean; -}): { - aggregatesByColumn: KanbanAggregatesByColumn; - isLoading: boolean; - hasAggregates: boolean; -} => { - const [aggregatesByColumn, setAggregatesByColumn] = - useState({}); - const [isLoading, setIsLoading] = useState(false); - - const [readAggregates, cancelReadAggregates] = useNetworkRequest( - ConnectionProvider.getHandler().readAggregates, - ); - - 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 fetchAggregates = useDeepCompareCallback(async () => { - if (!enabled || !fieldsToAggregate || !columnField) { - return; - } - - setIsLoading(true); - - try { - const aggregatesPromises = columnIds.map(async (columnId) => { - const columnDomain = [ - ...domain, - [columnField, "=", columnId === "" ? false : columnId], - ]; - - const retrievedData = await readAggregates({ - model, - domain: columnDomain, - 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, - }; - }); - - return { columnId, aggregates: columnAggregates }; - }); - - const results = await Promise.all(aggregatesPromises); - - const newAggregatesByColumn: KanbanAggregatesByColumn = {}; - results.forEach(({ columnId, aggregates }) => { - newAggregatesByColumn[columnId] = aggregates; - }); - - setAggregatesByColumn(newAggregatesByColumn); - } catch (err) { - console.error("Error fetching kanban aggregates:", err); - setAggregatesByColumn({}); - } finally { - setIsLoading(false); - } - }, [ - enabled, - fieldsToAggregate, - columnField, - columnIds, - domain, - model, - context, - kanbanDef?.aggregations, - readAggregates, - ]); - - useDeepCompareEffect(() => { - if (!fieldsToAggregate || columnIds.length === 0) { - setAggregatesByColumn({}); - return; - } - - fetchAggregates(); - - return () => { - cancelReadAggregates(); - }; - }, [fieldsToAggregate, columnIds, domain, context]); - - const hasAggregates = - fieldsToAggregate !== undefined && - Object.keys(fieldsToAggregate).length > 0; - - return { - aggregatesByColumn, - isLoading, - hasAggregates, - }; -}; diff --git a/src/widgets/views/Kanban/useKanbanColumnData.ts b/src/widgets/views/Kanban/useKanbanColumnData.ts new file mode 100644 index 000000000..c2bdc6b5f --- /dev/null +++ b/src/widgets/views/Kanban/useKanbanColumnData.ts @@ -0,0 +1,248 @@ +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; + columnField: string; + columnValue: string; + searchParams?: any[]; + fieldsToRetrieve?: string[]; + enabled?: boolean; + kanbanDef?: Kanban; +}; + +export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { + const { + model, + domain, + context, + columnField, + columnValue, + searchParams = [], + fieldsToRetrieve = [], + enabled = true, + kanbanDef, + } = params; + + const [records, setRecords] = useState([]); + const [aggregates, setAggregates] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const colorsForRecords = useRef<{ [key: number]: string }>({}); + const statusForRecords = useRef<{ [key: number]: string }>({}); + + const [searchRequest, cancelSearchRequest] = useNetworkRequest( + ConnectionProvider.getHandler().search, + ); + + const [parseConditions, cancelParseConditions] = useNetworkRequest( + ConnectionProvider.getHandler().parseConditions, + ); + + const [readAggregates, cancelReadAggregates] = useNetworkRequest( + ConnectionProvider.getHandler().readAggregates, + ); + + useEffect(() => { + return () => { + cancelSearchRequest(); + cancelParseConditions(); + cancelReadAggregates(); + }; + // 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 () => { + if (!enabled || !model || !columnField) { + return; + } + + setIsLoading(true); + setError(null); + + try { + // Merge domain with searchParams and add column filter + const baseDomain = 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, [columnField, "=", searchValue]]; + + const fields = [...new Set([...fieldsToRetrieve, columnField])]; + + // Fetch records + const fetchedRecords = await searchRequest({ + model, + params: columnDomain, + context, + fieldsToRetrieve: fields, + limit: 0, + }); + + setRecords(fetchedRecords); + + // Fetch aggregates if defined + if (fieldsToAggregate) { + try { + const retrievedData = await readAggregates({ + model, + domain: columnDomain, + 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({}); + } + } + + // Parse colors and status if defined + if ( + (kanbanDef?.colors || kanbanDef?.status) && + fetchedRecords.length > 0 + ) { + try { + const conditions: any = {}; + if (kanbanDef.colors) { + conditions.colors = kanbanDef.colors; + } + if (kanbanDef.status) { + conditions.status = kanbanDef.status; + } + + const attrsEvaluated = await parseConditions({ + conditions, + values: fetchedRecords, + context, + }); + + 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; + } + } + }); + + colorsForRecords.current = newColors; + statusForRecords.current = newStatus; + } + } catch (err: any) { + if (err.name !== "AbortError") { + console.warn("Error evaluating colors/status:", err); + } + } + } + } catch (err: any) { + if (err.name !== "AbortError") { + console.error("Error fetching column data:", err); + setError(err); + } + } finally { + setIsLoading(false); + } + }, [ + enabled, + model, + columnField, + columnValue, + domain, + searchParams, + context, + fieldsToRetrieve, + fieldsToAggregate, + kanbanDef, + searchRequest, + readAggregates, + parseConditions, + ]); + + useDeepCompareEffect(() => { + fetchData(); + }, [ + enabled, + model, + columnField, + columnValue, + domain, + searchParams, + context, + fieldsToRetrieve, + ]); + + const refresh = useCallback(() => { + fetchData(); + }, [fetchData]); + + return { + records, + count: records.length, + aggregates, + colorsForRecords, + statusForRecords, + isLoading, + error, + refresh, + }; +}; diff --git a/src/widgets/views/Kanban/useKanbanColumns.ts b/src/widgets/views/Kanban/useKanbanColumns.ts new file mode 100644 index 000000000..70ab800bd --- /dev/null +++ b/src/widgets/views/Kanban/useKanbanColumns.ts @@ -0,0 +1,259 @@ +import { useState, useCallback, useMemo } 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 { ColumnDefinition } from "./types"; + +type UseKanbanColumnsParams = { + model: string; + domain: any[]; + context: any; + columnField: string; + columnFieldDefinition: any; + searchParams?: any[]; + enabled?: boolean; +}; + +export const useKanbanColumns = (params: UseKanbanColumnsParams) => { + const { + model, + domain, + context, + columnField, + columnFieldDefinition, + searchParams = [], + enabled = true, + } = 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 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 normalizeColumnValue = useCallback( + ( + value: any, + fieldDefinition: any, + ): { 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: String(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: String(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, + }; + } + } + }, + [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 dynamic columns (many2one, etc.), extract unique values from records + setIsLoading(true); + setError(null); + + try { + const finalDomain = mergeParams(domain, searchParams); + + // Fetch only the column field to minimize data transfer + const fetchedRecords = await searchRequest({ + model, + params: finalDomain, + context, + fieldsToRetrieve: [columnField], + limit: 0, + }); + + // 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, + ); + + 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, + ]); + + useDeepCompareEffect(() => { + fetchDynamicColumns(); + + return () => { + cancelSearchRequest(); + }; + }, [enabled, model, columnField, domain, searchParams, context]); + + const refresh = useCallback(() => { + fetchDynamicColumns(); + }, [fetchDynamicColumns]); + + return { + columns, + isLoading, + error, + refresh, + }; +}; diff --git a/src/widgets/views/Kanban/useKanbanData.ts b/src/widgets/views/Kanban/useKanbanData.ts deleted file mode 100644 index 0d61db411..000000000 --- a/src/widgets/views/Kanban/useKanbanData.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useDeepCompareCallback, useDeepCompareEffect } from "use-deep-compare"; -import ConnectionProvider from "@/ConnectionProvider"; -import { useNetworkRequest } from "@/hooks/useNetworkRequest"; -import { mergeParams } from "@/helpers/searchHelper"; -import { Kanban } from "@gisce/ooui"; -import { useLocale } from "@gisce/react-formiga-components"; - -export type KanbanRecord = { - id: number; - [key: string]: any; -}; - -export type KanbanColumn = { - id: string; - label: string; - records: KanbanRecord[]; - count: number; -}; - -type ColumnDefinition = { - id: string; - label: string; -}; - -type UseKanbanDataParams = { - model: string; - domain: any[]; - context: any; - columnField: string; - columnFieldDefinition: any; - searchParams?: any[]; - fieldsToRetrieve?: string[]; - enabled?: boolean; - kanbanDef?: Kanban; - viewId?: number; -}; - -export const useKanbanData = (params: UseKanbanDataParams) => { - const { - model, - domain, - context, - columnField, - columnFieldDefinition, - searchParams = [], - fieldsToRetrieve = [], - enabled = true, - kanbanDef, - viewId, - } = params; - - const { t } = useLocale(); - - const [columns, setColumns] = useState([]); - const [records, setRecords] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isRefreshing, setIsRefreshing] = useState(false); - const [error, setError] = useState(null); - const colorsForRecords = useRef<{ [key: number]: string }>({}); - const statusForRecords = useRef<{ [key: number]: string }>({}); - const hasInitialDataRef = useRef(false); - const previousViewIdRef = useRef(viewId); - - useEffect(() => { - if (viewId !== undefined && viewId !== previousViewIdRef.current) { - hasInitialDataRef.current = false; - previousViewIdRef.current = viewId; - } - }, [viewId]); - - const [searchRequest, cancelSearchRequest] = useNetworkRequest( - ConnectionProvider.getHandler().search, - ); - - const [parseConditions, cancelParseConditions] = useNetworkRequest( - ConnectionProvider.getHandler().parseConditions, - ); - - useEffect(() => { - return () => { - cancelSearchRequest(); - cancelParseConditions(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const normalizeColumnValue = useCallback( - ( - value: any, - fieldDefinition: any, - ): { originalValue: any; key: string; displayName: string } | 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 { - originalValue: value, - key: String(value[0]), - displayName: value[1], - }; - } - 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 { - originalValue: value, - key: String(selectionKey), - displayName: selectionLabel, - }; - } - - case "boolean": { - const boolValue = - value === true || value === 1 || value === "true" || value === "1"; - return { - originalValue: value, - key: String(boolValue), - displayName: boolValue ? t("yes") : t("no"), - }; - } - - case "reference": { - if (typeof value === "string" && value.includes(",")) { - const [, idPart] = value.split(","); - return { - originalValue: value, - key: idPart, - displayName: value, - }; - } - return null; - } - - default: { - if (value === null || value === undefined) { - return null; - } - const stringValue = String(value); - return { - originalValue: value, - key: stringValue, - displayName: stringValue, - }; - } - } - }, - [t], - ); - - const extractColumnId = useCallback( - (columnValue: any, fieldDefinition?: any): string | null => { - if (!fieldDefinition) { - if (!columnValue && columnValue !== false && columnValue !== 0) - return null; - - if (Array.isArray(columnValue) && columnValue.length === 2) { - return String(columnValue[0]); - } - - return String(columnValue); - } - - const normalized = normalizeColumnValue(columnValue, fieldDefinition); - return normalized ? normalized.key : null; - }, - [normalizeColumnValue], - ); - - const extractColumnInfo = useCallback( - ( - columnValue: any, - fieldDefinition?: any, - ): { id: string; label: string } | null => { - if (!fieldDefinition) { - if (!columnValue && columnValue !== false && columnValue !== 0) - return null; - - if (Array.isArray(columnValue) && columnValue.length === 2) { - return { - id: String(columnValue[0]), - label: columnValue[1], - }; - } - - const value = String(columnValue); - return { - id: value, - label: value, - }; - } - - const normalized = normalizeColumnValue(columnValue, fieldDefinition); - return normalized - ? { id: normalized.key, label: normalized.displayName } - : null; - }, - [normalizeColumnValue], - ); - - const getColumnDefinitions = useCallback((): ColumnDefinition[] => { - if (!columnFieldDefinition) { - return []; - } - - const fieldType = columnFieldDefinition.type; - - if (fieldType === "boolean") { - return [ - { id: "false", label: t("no") }, - { id: "true", label: t("yes") }, - ]; - } - - if (fieldType === "selection") { - const selectionValues = - columnFieldDefinition.selection || - columnFieldDefinition.selectionValues; - if (selectionValues) { - return selectionValues.map(([id, label]: [string, string]) => ({ - id: String(id), - label: String(label), - })); - } - } - - return []; - }, [columnFieldDefinition, t]); - - const fetchRecords = useDeepCompareCallback(async () => { - if (!enabled || !model || !columnField) { - return; - } - - // If we already have initial data, this is a refresh - if (hasInitialDataRef.current) { - setIsRefreshing(true); - } else { - setIsLoading(true); - } - setError(null); - - try { - const finalDomain = mergeParams(domain, searchParams); - - const fields = [...new Set([...fieldsToRetrieve, columnField])]; - - const fetchedRecords = await searchRequest({ - model, - params: finalDomain, - context, - fieldsToRetrieve: fields, - limit: 0, - }); - - setRecords(fetchedRecords); - - if ( - (kanbanDef?.colors || kanbanDef?.status) && - fetchedRecords.length > 0 - ) { - try { - const conditions: any = {}; - if (kanbanDef.colors) { - conditions.colors = kanbanDef.colors; - } - if (kanbanDef.status) { - conditions.status = kanbanDef.status; - } - - const attrsEvaluated = await parseConditions({ - conditions, - values: fetchedRecords, - context, - }); - - if (attrsEvaluated && Array.isArray(attrsEvaluated)) { - attrsEvaluated.forEach((attr: any) => { - if (attr.id !== undefined) { - if (attr.colors) { - colorsForRecords.current[attr.id] = attr.colors; - } - if (attr.status) { - statusForRecords.current[attr.id] = attr.status; - } - } - }); - } - } catch (err: any) { - if (err.name !== "AbortError") { - console.warn("Error evaluating colors/status:", err); - } - } - } - - const columnDefs = getColumnDefinitions(); - const dynamicColumnDefs = new Map(); - - if (columnDefs.length === 0 && columnFieldDefinition) { - fetchedRecords.forEach((record: KanbanRecord) => { - const columnValue = record[columnField]; - const columnInfo = extractColumnInfo( - columnValue, - columnFieldDefinition, - ); - - if (!columnInfo) return; - - if (!dynamicColumnDefs.has(columnInfo.id)) { - dynamicColumnDefs.set(columnInfo.id, columnInfo.label); - } - }); - - columnDefs.push( - ...Array.from(dynamicColumnDefs.entries()).map(([id, label]) => ({ - id, - label, - })), - ); - } - - const groupedRecords: Record = {}; - - if (columnDefs.length > 0) { - columnDefs.forEach((col) => { - groupedRecords[col.id] = []; - }); - } - - fetchedRecords.forEach((record: KanbanRecord) => { - const columnValue = record[columnField]; - const colId = extractColumnId(columnValue, columnFieldDefinition); - - if (!colId) return; - - if (!groupedRecords[colId]) { - groupedRecords[colId] = []; - } - - groupedRecords[colId].push(record); - }); - - const columnsArray: KanbanColumn[] = Object.entries(groupedRecords).map( - ([colId, colRecords]) => { - const columnDef = columnDefs.find((c) => c.id === colId); - return { - id: colId, - label: columnDef?.label || colId, - records: colRecords, - count: colRecords.length, - }; - }, - ); - - setColumns((prevColumns) => { - if ( - hasInitialDataRef.current && - prevColumns.length > 0 && - columnsArray.length === 0 - ) { - return prevColumns; - } - return columnsArray; - }); - hasInitialDataRef.current = true; - } catch (err: any) { - if (err.name !== "AbortError") { - console.error("Error fetching kanban data:", err); - setError(err); - } - } finally { - setIsLoading(false); - setIsRefreshing(false); - } - }, [ - enabled, - model, - columnField, - domain, - searchParams, - context, - fieldsToRetrieve, - getColumnDefinitions, - kanbanDef, - parseConditions, - ]); - - useDeepCompareEffect(() => { - fetchRecords(); - }, [ - enabled, - model, - columnField, - domain, - searchParams, - context, - fieldsToRetrieve, - ]); - - const moveRecord = useCallback( - async (recordId: number, fromColumnId: string, toColumnId: string) => {}, - [], - ); - - const totalRows = columns.reduce((sum, col) => sum + col.count, 0); - - return { - columns, - records, - isLoading, - isRefreshing, - error, - colorsForRecords, - statusForRecords, - fetchRecords, - moveRecord, - totalRows, - }; -}; From e19d576b54eaa0d8a8f9ffbd7fd78e7c20bab697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Tue, 11 Nov 2025 14:48:42 +0100 Subject: [PATCH 19/41] feat: support name search --- src/views/actionViews/KanbanActionView.tsx | 3 +- src/widgets/views/Kanban/Kanban.tsx | 28 ++- src/widgets/views/Kanban/KanbanBoard.tsx | 32 +++- src/widgets/views/Kanban/KanbanColumn.tsx | 13 ++ .../views/Kanban/useKanbanColumnData.ts | 159 +++++++++--------- 5 files changed, 150 insertions(+), 85 deletions(-) diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index b6a494c75..f26bdc2b7 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -59,7 +59,7 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { setSearchTreeNameSearch, } = useSearchTreeState({ useLocalState: false }); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [showFormModal, setShowFormModal] = useState(false); const [selectedRecord, setSelectedRecord] = useState< KanbanRecord | undefined @@ -229,6 +229,7 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { domain={domain} context={context} searchParams={searchParams || []} + nameSearch={searchTreeNameSearch} onCardClick={handleCardClick} onLoadingChange={setIsLoading} onTotalRowsChange={handleTotalRowsChange} diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index 540f6551a..c3a6704b7 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -25,6 +25,7 @@ type KanbanProps = { domain: any[]; context: any; searchParams?: any[]; + nameSearch?: string; onCardClick?: (record: KanbanRecord) => void; onLoadingChange?: (isLoading: boolean) => void; onTotalRowsChange?: (totalRows: number) => void; @@ -44,11 +45,14 @@ const KanbanComponentInner = ( domain, context, searchParams = [], + nameSearch, onCardClick, onLoadingChange, onTotalRowsChange, } = props; + const prevNameSearch = useRef(nameSearch); + const { t } = useLocale(); const [kanbanDef, setKanbanDef] = useState(null); const [parsingError, setParsingError] = useState(null); @@ -114,9 +118,24 @@ const KanbanComponentInner = ( const columnRefs = useRef>(new Map()); const [columnCounts, setColumnCounts] = useState>({}); + 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]); + useImperativeHandle(ref, () => ({ refreshResults: () => { - // Trigger refresh on all columns columnRefs.current.forEach((ref) => { ref.refresh(); }); @@ -154,7 +173,6 @@ const KanbanComponentInner = ( [], ); - // Calculate and report total rows useEffect(() => { const totalRows = Object.values(columnCounts).reduce( (sum, count) => sum + count, @@ -163,6 +181,10 @@ const KanbanComponentInner = ( onTotalRowsChange?.(totalRows); }, [columnCounts, onTotalRowsChange]); + useEffect(() => { + onLoadingChange?.(isLoadingColumns); + }, [isLoadingColumns, onLoadingChange]); + const content = useDeepCompareMemo(() => { if (parsingError) { return ( @@ -198,6 +220,7 @@ const KanbanComponentInner = ( domain={domain} context={context} searchParams={searchParams} + nameSearch={nameSearch} fieldsToRetrieve={fieldsToRetrieve} kanbanDef={kanbanDef} onCardClick={onCardClick} @@ -216,6 +239,7 @@ const KanbanComponentInner = ( domain, context, searchParams, + nameSearch, fieldsToRetrieve, onCardClick, handleButtonClick, diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index afc2a369a..bf194599a 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -21,6 +21,7 @@ type KanbanBoardProps = { domain: any[]; context: any; searchParams?: any[]; + nameSearch?: string; fieldsToRetrieve?: string[]; kanbanDef: Kanban; onCardClick?: (record: KanbanRecord) => void; @@ -37,6 +38,7 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { domain, context = {}, searchParams, + nameSearch, fieldsToRetrieve, kanbanDef, onCardClick, @@ -50,6 +52,7 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { const [overColumnId, setOverColumnId] = useState(null); const colorsForRecordsRef = useRef<{ [key: number]: string }>({}); const statusForRecordsRef = useRef<{ [key: number]: string }>({}); + const allRecordsRef = useRef<{ [key: number]: KanbanRecord }>({}); const sensors = useSensors( useSensor(PointerSensor, { @@ -63,9 +66,14 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { const { active } = event; const recordId = active.id as number; - // For now, we'll handle drag state without needing all records - // The active record will be stored in state - setActiveRecord({ id: recordId } as KanbanRecord); + // Look up the full record from our records map + const fullRecord = allRecordsRef.current[recordId]; + if (fullRecord) { + setActiveRecord(fullRecord); + } else { + // Fallback: set just the ID if record not found + setActiveRecord({ id: recordId } as KanbanRecord); + } }, []); const handleDragOver = useCallback( @@ -98,6 +106,22 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { setOverColumnId(null); }, []); + const handleRecordsUpdate = useCallback( + (records: KanbanRecord[], colors: any, status: any) => { + // Update allRecordsRef with the new records + records.forEach((record) => { + allRecordsRef.current[record.id] = record; + if (colors?.current?.[record.id]) { + colorsForRecordsRef.current[record.id] = colors.current[record.id]; + } + if (status?.current?.[record.id]) { + statusForRecordsRef.current[record.id] = status.current[record.id]; + } + }); + }, + [], + ); + if (columns.length === 0) { return (
{ domain={domain} context={context} searchParams={searchParams} + nameSearch={nameSearch} fieldsToRetrieve={fieldsToRetrieve} kanbanDef={kanbanDef} draggable={kanbanDef.drag} @@ -149,6 +174,7 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { onCardClick={onCardClick} onButtonClick={onButtonClick} onCountChange={onColumnCountChange} + onRecordsUpdate={handleRecordsUpdate} isOver={overColumnId === column.id} /> ))} diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index 7e66e9d56..b9b1c11fe 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -5,6 +5,7 @@ import { useImperativeHandle, useEffect, } 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"; @@ -32,6 +33,7 @@ type KanbanColumnProps = { domain: any[]; context: any; searchParams?: any[]; + nameSearch?: string; fieldsToRetrieve?: string[]; kanbanDef: Kanban; draggable: boolean; @@ -42,6 +44,7 @@ type KanbanColumnProps = { onButtonClick?: (buttonName: string, recordId: number) => void; onMaxCardsChange?: (colId: string, maxCards: number | undefined) => void; onCountChange: (columnId: string, count: number) => void; + onRecordsUpdate?: (records: KanbanRecord[], colors: any, status: any) => void; }; const KanbanColumnComponent = ( @@ -55,6 +58,7 @@ const KanbanColumnComponent = ( domain, context = {}, searchParams, + nameSearch, fieldsToRetrieve, kanbanDef, draggable, @@ -63,6 +67,7 @@ const KanbanColumnComponent = ( onCardClick, onButtonClick, onCountChange, + onRecordsUpdate, } = props; const { @@ -89,6 +94,7 @@ const KanbanColumnComponent = ( columnField, columnValue: columnOriginalValue, searchParams, + nameSearch, fieldsToRetrieve, enabled: true, kanbanDef, @@ -103,6 +109,13 @@ const KanbanColumnComponent = ( 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, }); diff --git a/src/widgets/views/Kanban/useKanbanColumnData.ts b/src/widgets/views/Kanban/useKanbanColumnData.ts index c2bdc6b5f..de59fa430 100644 --- a/src/widgets/views/Kanban/useKanbanColumnData.ts +++ b/src/widgets/views/Kanban/useKanbanColumnData.ts @@ -24,6 +24,7 @@ type UseKanbanColumnDataParams = { columnField: string; columnValue: string; searchParams?: any[]; + nameSearch?: string; fieldsToRetrieve?: string[]; enabled?: boolean; kanbanDef?: Kanban; @@ -37,6 +38,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { columnField, columnValue, searchParams = [], + nameSearch, fieldsToRetrieve = [], enabled = true, kanbanDef, @@ -49,12 +51,8 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { const colorsForRecords = useRef<{ [key: number]: string }>({}); const statusForRecords = useRef<{ [key: number]: string }>({}); - const [searchRequest, cancelSearchRequest] = useNetworkRequest( - ConnectionProvider.getHandler().search, - ); - - const [parseConditions, cancelParseConditions] = useNetworkRequest( - ConnectionProvider.getHandler().parseConditions, + const [searchForTree, cancelSearchForTree] = useNetworkRequest( + ConnectionProvider.getHandler().searchForTree, ); const [readAggregates, cancelReadAggregates] = useNetworkRequest( @@ -63,8 +61,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { useEffect(() => { return () => { - cancelSearchRequest(); - cancelParseConditions(); + cancelSearchForTree(); cancelReadAggregates(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -93,8 +90,11 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { setError(null); try { - // Merge domain with searchParams and add column filter - const baseDomain = mergeParams(domain, searchParams); + // 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 @@ -109,90 +109,90 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { const columnDomain = [...baseDomain, [columnField, "=", searchValue]]; - const fields = [...new Set([...fieldsToRetrieve, columnField])]; - - // Fetch records - const fetchedRecords = await searchRequest({ + // Build fields object for searchForTree + // searchForTree expects an object of field definitions, not an array of field names + const fieldsToFetch = [...new Set([...fieldsToRetrieve, columnField])]; + const fieldsObject = kanbanDef?.fields + ? Object.keys(kanbanDef.fields).reduce( + (acc: any, fieldName: string) => { + if (fieldsToFetch.includes(fieldName)) { + acc[fieldName] = kanbanDef.fields[fieldName]; + } + return acc; + }, + {}, + ) + : {}; + + // Fetch records using searchForTree which supports name_search + const { results: fetchedRecords, attrsEvaluated } = await searchForTree({ model, params: columnDomain, context, - fieldsToRetrieve: fields, + fields: fieldsObject, limit: 0, + offset: 0, + name_search: nameSearch, }); setRecords(fetchedRecords); // Fetch aggregates if defined if (fieldsToAggregate) { - try { - const retrievedData = await readAggregates({ - model, - domain: columnDomain, - 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); + if (fetchedRecords.length > 0) { + try { + // Use the IDs from the fetched records to calculate aggregates + // This ensures aggregates match the actual filtered results (including name search) + const recordIds = fetchedRecords.map((r) => r.id); + const aggregateDomain = [["id", "in", recordIds]]; + + 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 if defined - if ( - (kanbanDef?.colors || kanbanDef?.status) && - fetchedRecords.length > 0 - ) { - try { - const conditions: any = {}; - if (kanbanDef.colors) { - conditions.colors = kanbanDef.colors; + // 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 (kanbanDef.status) { - conditions.status = kanbanDef.status; - } - - const attrsEvaluated = await parseConditions({ - conditions, - values: fetchedRecords, - context, - }); - - 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; - } - } - }); + }); - colorsForRecords.current = newColors; - statusForRecords.current = newStatus; - } - } catch (err: any) { - if (err.name !== "AbortError") { - console.warn("Error evaluating colors/status:", err); - } - } + colorsForRecords.current = newColors; + statusForRecords.current = newStatus; } } catch (err: any) { if (err.name !== "AbortError") { @@ -209,13 +209,13 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { columnValue, domain, searchParams, + nameSearch, context, fieldsToRetrieve, fieldsToAggregate, kanbanDef, - searchRequest, + searchForTree, readAggregates, - parseConditions, ]); useDeepCompareEffect(() => { @@ -227,6 +227,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { columnValue, domain, searchParams, + nameSearch, context, fieldsToRetrieve, ]); From bbad40994a656441668f9c5aa7956dd65f622eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Tue, 11 Nov 2025 14:55:29 +0100 Subject: [PATCH 20/41] fix: minor ts issues --- src/widgets/views/Kanban/useKanbanColumnData.ts | 2 +- src/widgets/views/Kanban/useKanbanColumns.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widgets/views/Kanban/useKanbanColumnData.ts b/src/widgets/views/Kanban/useKanbanColumnData.ts index de59fa430..95b7413a4 100644 --- a/src/widgets/views/Kanban/useKanbanColumnData.ts +++ b/src/widgets/views/Kanban/useKanbanColumnData.ts @@ -143,7 +143,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { try { // Use the IDs from the fetched records to calculate aggregates // This ensures aggregates match the actual filtered results (including name search) - const recordIds = fetchedRecords.map((r) => r.id); + const recordIds = fetchedRecords.map((r: any) => r.id); const aggregateDomain = [["id", "in", recordIds]]; const retrievedData = await readAggregates({ diff --git a/src/widgets/views/Kanban/useKanbanColumns.ts b/src/widgets/views/Kanban/useKanbanColumns.ts index 70ab800bd..86599c935 100644 --- a/src/widgets/views/Kanban/useKanbanColumns.ts +++ b/src/widgets/views/Kanban/useKanbanColumns.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback } from "react"; import { useDeepCompareCallback, useDeepCompareEffect } from "use-deep-compare"; import ConnectionProvider from "@/ConnectionProvider"; import { useNetworkRequest } from "@/hooks/useNetworkRequest"; From 690072bfbcdfd6ca04f260740661fe689ba4eabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Tue, 11 Nov 2025 20:35:16 +0100 Subject: [PATCH 21/41] feat: add order and fix color+status --- src/widgets/views/Kanban/useKanbanColumnData.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/widgets/views/Kanban/useKanbanColumnData.ts b/src/widgets/views/Kanban/useKanbanColumnData.ts index 95b7413a4..daf254b6a 100644 --- a/src/widgets/views/Kanban/useKanbanColumnData.ts +++ b/src/widgets/views/Kanban/useKanbanColumnData.ts @@ -124,7 +124,19 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { ) : {}; - // Fetch records using searchForTree which supports name_search + 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 { results: fetchedRecords, attrsEvaluated } = await searchForTree({ model, params: columnDomain, @@ -132,6 +144,8 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { fields: fieldsObject, limit: 0, offset: 0, + order, + attrs: Object.keys(attrs).length > 0 ? attrs : undefined, name_search: nameSearch, }); From 65baaf9248f531264713222e7fd594b8e07900e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Wed, 12 Nov 2025 22:03:33 +0100 Subject: [PATCH 22/41] feat: column domain, virtualizataion, and lots of improvements --- package.json | 1 + src/actionbar/KanbanActionBar.tsx | 68 ---- src/hooks/useActionViewSavedSearches.tsx | 251 +++++++++++++ src/views/actionViews/KanbanActionView.tsx | 15 +- src/views/actionViews/TreeActionView.tsx | 222 +----------- src/widgets/views/Kanban/Kanban.tsx | 1 + src/widgets/views/Kanban/KanbanCard.tsx | 8 +- src/widgets/views/Kanban/KanbanColumn.tsx | 119 ++++++- .../views/Kanban/useKanbanColumnData.ts | 331 +++++++++++------- src/widgets/views/Kanban/useKanbanColumns.ts | 75 +++- 10 files changed, 654 insertions(+), 437 deletions(-) delete mode 100644 src/actionbar/KanbanActionBar.tsx create mode 100644 src/hooks/useActionViewSavedSearches.tsx 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/KanbanActionBar.tsx b/src/actionbar/KanbanActionBar.tsx deleted file mode 100644 index c57fc701f..000000000 --- a/src/actionbar/KanbanActionBar.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { memo, useContext } from "react"; -import { - ActionViewContext, - ActionViewContextType, -} from "@/context/ActionViewContext"; -import { Space, Spin } from "antd"; -import ChangeViewButton from "./ChangeViewButton"; -import ActionButton from "./ActionButton"; -import { ShareUrlButton } from "./ShareUrlButton"; -import { ActionBarSeparator } from "./ActionBarSeparator"; -import { ReloadOutlined } from "@ant-design/icons"; -import { useLocale } from "@gisce/react-formiga-components"; -import { View } from "@/types"; - -type KanbanActionBarProps = { - onRefresh?: () => void; - isLoading?: boolean; -}; - -const KanbanActionBarComponent = (props: KanbanActionBarProps) => { - const { onRefresh, isLoading = false } = props; - const { t } = useLocale(); - - const { - availableViews, - currentView, - setCurrentView, - searchParams, - previousView, - setPreviousView, - } = useContext(ActionViewContext) as ActionViewContextType; - - return ( - - {isLoading && ( - <> - - - - - )} - } - tooltip={t("refresh")} - disabled={isLoading} - onClick={onRefresh} - /> - - { - setPreviousView?.(currentView); - setCurrentView?.(newView); - }} - previousView={previousView} - disabled={isLoading} - /> - - - - - - ); -}; - -const KanbanActionBar = memo(KanbanActionBarComponent); -export default KanbanActionBar; 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/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index f26bdc2b7..fef0b13f7 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -20,6 +20,7 @@ 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"; const HEIGHT_OFFSET = 10; @@ -111,6 +112,16 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { setTotalRows(total); }, []); + const { fetchSavedSearches, handleClearSavedSearch, subtitle } = + useActionViewSavedSearches({ + model, + context, + viewRef: kanbanRef, + setSearchParams, + setSearchValues, + setSearchVisible, + }); + const onSideSearchFilterClose = useCallback( () => setSearchVisible?.(false), [setSearchVisible], @@ -199,12 +210,14 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { onClear={onSideSearchFilterClear} />
- +
diff --git a/src/views/actionViews/TreeActionView.tsx b/src/views/actionViews/TreeActionView.tsx index faec789b1..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,22 +14,14 @@ 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; @@ -72,7 +63,7 @@ export const TreeActionView = (props: TreeActionViewProps) => { 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(() => { - viewRef?.current?.refreshResults(); - }, 100); - }, [setCurrentSavedSearch, setSearchParams, setSearchValues, viewRef]); - - 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; } diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index c3a6704b7..29c906e69 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -113,6 +113,7 @@ const KanbanComponentInner = ( columnFieldDefinition: columnFieldDef, searchParams, enabled: !!kanbanDef && !!columnFieldDef, + columnDomain: kanbanDef?.column_domain, }); const columnRefs = useRef>(new Map()); diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index 68ed9860d..b90ec6f11 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -11,13 +11,7 @@ import { KANBAN_COMPONENTS } from "./kanbanComponents"; const { Text } = Typography; const { useToken } = theme; -const CardWrapper = styled.div` - margin-bottom: 8px; - - &:last-child { - margin-bottom: 0; - } -`; +const CardWrapper = styled.div``; const StyledCard = styled(AntCard)<{ $bgColor: string; diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index b9b1c11fe..0fb827ce0 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -4,6 +4,8 @@ import { forwardRef, useImperativeHandle, useEffect, + useRef, + useCallback, } from "react"; import { useDeepCompareEffect } from "use-deep-compare"; import { Badge, Button, Space, theme, Typography } from "antd"; @@ -13,6 +15,7 @@ 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"; @@ -86,7 +89,10 @@ const KanbanColumnComponent = ( colorsForRecords, statusForRecords, isLoading, + isLoadingMore, + hasMore, refresh, + fetchNextPage, } = useKanbanColumnData({ model, domain, @@ -144,6 +150,48 @@ const KanbanColumnComponent = ( return records.some((record) => statusForRecords?.current?.[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]); + return (
- {records.map((record) => ( - - ))} +
+ {virtualItems.map((virtualRow) => { + const record = records[virtualRow.index]; + return ( +
+ +
+ ); + })} +
- {records.length === 0 && ( + {isLoadingMore && ( +
+ + + + {t("loading")} + + +
+ )} + + {records.length === 0 && !isLoading && (
{ const [records, setRecords] = useState([]); const [aggregates, setAggregates] = useState({}); const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); + const [currentOffset, setCurrentOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); const colorsForRecords = useRef<{ [key: number]: string }>({}); const statusForRecords = useRef<{ [key: number]: string }>({}); + const PAGE_SIZE = 30; + const [searchForTree, cancelSearchForTree] = useNetworkRequest( ConnectionProvider.getHandler().searchForTree, ); @@ -81,156 +86,197 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { return result; }, [kanbanDef?.aggregations]); - const fetchData = useDeepCompareCallback(async () => { - if (!enabled || !model || !columnField) { - return; - } + const fetchData = useDeepCompareCallback( + async (isLoadingNextPage = false) => { + if (!enabled || !model || !columnField) { + return; + } - setIsLoading(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; + if (isLoadingNextPage) { + setIsLoadingMore(true); + } else { + setIsLoading(true); + setCurrentOffset(0); + setHasMore(true); } + setError(null); - const columnDomain = [...baseDomain, [columnField, "=", searchValue]]; + 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); - // Build fields object for searchForTree - // searchForTree expects an object of field definitions, not an array of field names - const fieldsToFetch = [...new Set([...fieldsToRetrieve, columnField])]; - 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`; - } + // 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 attrs: any = {}; - if (kanbanDef?.colors) { - attrs.colors = kanbanDef.colors; - } - if (kanbanDef?.status) { - attrs.status = kanbanDef.status; - } + const columnDomain = [...baseDomain, [columnField, "=", searchValue]]; + + // Build fields object for searchForTree + // searchForTree expects an object of field definitions, not an array of field names + const fieldsToFetch = [...new Set([...fieldsToRetrieve, columnField])]; + 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, + }, + ); + + if (nameSearch) { + setRecords(fetchedRecords); + setCurrentOffset(0); + setHasMore(false); + } else if (isLoadingNextPage) { + setRecords((prev) => [...prev, ...fetchedRecords]); + setCurrentOffset((prev) => prev + PAGE_SIZE); + setHasMore(fetchedRecords.length === PAGE_SIZE); + } else { + setRecords(fetchedRecords); + 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 { results: fetchedRecords, attrsEvaluated } = await searchForTree({ - model, - params: columnDomain, - context, - fields: fieldsObject, - limit: 0, - offset: 0, - order, - attrs: Object.keys(attrs).length > 0 ? attrs : undefined, - name_search: nameSearch, - }); - - setRecords(fetchedRecords); - - // Fetch aggregates if defined - if (fieldsToAggregate) { - if (fetchedRecords.length > 0) { - try { - // Use the IDs from the fetched records to calculate aggregates - // This ensures aggregates match the actual filtered results (including name search) - const recordIds = fetchedRecords.map((r: any) => r.id); - const aggregateDomain = [["id", "in", recordIds]]; - - 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); + 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({}); } - } 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 } = {}; + // 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; + attrsEvaluated.forEach((attr: any) => { + if (attr.id !== undefined) { + if (attr.colors) { + newColors[attr.id] = attr.colors; + } + if (attr.status) { + newStatus[attr.id] = attr.status; + } } - } - }); + }); - colorsForRecords.current = newColors; - statusForRecords.current = newStatus; - } - } catch (err: any) { - if (err.name !== "AbortError") { - console.error("Error fetching column data:", err); - setError(err); + if (isLoadingNextPage) { + colorsForRecords.current = { + ...colorsForRecords.current, + ...newColors, + }; + statusForRecords.current = { + ...statusForRecords.current, + ...newStatus, + }; + } else { + colorsForRecords.current = newColors; + statusForRecords.current = 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); + } } - } finally { - setIsLoading(false); - } - }, [ - enabled, - model, - columnField, - columnValue, - domain, - searchParams, - nameSearch, - context, - fieldsToRetrieve, - fieldsToAggregate, - kanbanDef, - searchForTree, - readAggregates, - ]); + }, + [ + enabled, + model, + columnField, + columnValue, + domain, + searchParams, + nameSearch, + context, + fieldsToRetrieve, + fieldsToAggregate, + kanbanDef, + searchForTree, + readAggregates, + currentOffset, + PAGE_SIZE, + ], + ); useDeepCompareEffect(() => { fetchData(); @@ -247,9 +293,21 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { ]); const refresh = useCallback(() => { + setRecords([]); + setCurrentOffset(0); + setHasMore(true); + setAggregates({}); + colorsForRecords.current = {}; + statusForRecords.current = {}; fetchData(); }, [fetchData]); + const fetchNextPage = useCallback(() => { + if (!isLoadingMore && hasMore) { + fetchData(true); + } + }, [fetchData, isLoadingMore, hasMore]); + return { records, count: records.length, @@ -257,7 +315,10 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { colorsForRecords, statusForRecords, isLoading, + isLoadingMore, + hasMore, error, refresh, + fetchNextPage, }; }; diff --git a/src/widgets/views/Kanban/useKanbanColumns.ts b/src/widgets/views/Kanban/useKanbanColumns.ts index 86599c935..e7a22ccd4 100644 --- a/src/widgets/views/Kanban/useKanbanColumns.ts +++ b/src/widgets/views/Kanban/useKanbanColumns.ts @@ -14,6 +14,7 @@ type UseKanbanColumnsParams = { columnFieldDefinition: any; searchParams?: any[]; enabled?: boolean; + columnDomain?: string | null; }; export const useKanbanColumns = (params: UseKanbanColumnsParams) => { @@ -25,6 +26,7 @@ export const useKanbanColumns = (params: UseKanbanColumnsParams) => { columnFieldDefinition, searchParams = [], enabled = true, + columnDomain = null, } = params; const { t } = useLocale(); @@ -36,6 +38,14 @@ export const useKanbanColumns = (params: UseKanbanColumnsParams) => { ConnectionProvider.getHandler().search, ); + const [getFieldsRequest, cancelGetFieldsRequest] = useNetworkRequest( + ConnectionProvider.getHandler().getFields, + ); + + const [evalDomainRequest, cancelEvalDomainRequest] = useNetworkRequest( + ConnectionProvider.getHandler().evalDomain, + ); + const getStaticColumnDefinitions = useCallback((): | ColumnDefinition[] | null => { @@ -171,7 +181,61 @@ export const useKanbanColumns = (params: UseKanbanColumnsParams) => { return; } - // For dynamic columns (many2one, etc.), extract unique values from records + // 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); @@ -179,12 +243,14 @@ export const useKanbanColumns = (params: UseKanbanColumnsParams) => { 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: 0, + limit: 1000, }); // Extract unique column values @@ -236,6 +302,9 @@ export const useKanbanColumns = (params: UseKanbanColumnsParams) => { normalizeColumnValue, columnFieldDefinition, searchRequest, + getFieldsRequest, + evalDomainRequest, + columnDomain, ]); useDeepCompareEffect(() => { @@ -243,6 +312,8 @@ export const useKanbanColumns = (params: UseKanbanColumnsParams) => { return () => { cancelSearchRequest(); + cancelGetFieldsRequest(); + cancelEvalDomainRequest(); }; }, [enabled, model, columnField, domain, searchParams, context]); From 732c33753a350d1728aff8a8a206b85775df82a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Wed, 12 Nov 2025 22:29:36 +0100 Subject: [PATCH 23/41] fix: add justify center --- src/widgets/views/Kanban/KanbanBoard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index bf194599a..223cc0213 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -154,6 +154,7 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { paddingBottom: "16px", overflowX: "auto", height: "100%", + justifyContent: "center", }} > {columns.map((column) => ( From d63c9b6a1ceac709b8259aae2318261170d8365b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Wed, 12 Nov 2025 22:43:25 +0100 Subject: [PATCH 24/41] fix: use search count for virtualization --- .../views/Kanban/useKanbanColumnData.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/widgets/views/Kanban/useKanbanColumnData.ts b/src/widgets/views/Kanban/useKanbanColumnData.ts index 6b0f03803..6a56c5895 100644 --- a/src/widgets/views/Kanban/useKanbanColumnData.ts +++ b/src/widgets/views/Kanban/useKanbanColumnData.ts @@ -51,6 +51,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { const [error, setError] = useState(null); const [currentOffset, setCurrentOffset] = useState(0); const [hasMore, setHasMore] = useState(true); + const [totalCount, setTotalCount] = useState(0); const colorsForRecords = useRef<{ [key: number]: string }>({}); const statusForRecords = useRef<{ [key: number]: string }>({}); @@ -64,10 +65,15 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { ConnectionProvider.getHandler().readAggregates, ); + const [searchCount, cancelSearchCount] = useNetworkRequest( + ConnectionProvider.getHandler().searchCount, + ); + useEffect(() => { return () => { cancelSearchForTree(); cancelReadAggregates(); + cancelSearchCount(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -121,6 +127,23 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { const columnDomain = [...baseDomain, [columnField, "=", 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, columnField])]; @@ -272,6 +295,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { fieldsToAggregate, kanbanDef, searchForTree, + searchCount, readAggregates, currentOffset, PAGE_SIZE, @@ -297,6 +321,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { setCurrentOffset(0); setHasMore(true); setAggregates({}); + setTotalCount(0); colorsForRecords.current = {}; statusForRecords.current = {}; fetchData(); @@ -310,7 +335,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { return { records, - count: records.length, + count: totalCount, aggregates, colorsForRecords, statusForRecords, From 7c858cefe04b61ba641f14e51189a39ff714443b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Wed, 12 Nov 2025 22:44:25 +0100 Subject: [PATCH 25/41] fix: ditch justify --- src/widgets/views/Kanban/KanbanBoard.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index 223cc0213..bf194599a 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -154,7 +154,6 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { paddingBottom: "16px", overflowX: "auto", height: "100%", - justifyContent: "center", }} > {columns.map((column) => ( From 538edf04fb9cde92e64494f86ef8f29784a1eb52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 13 Nov 2025 09:54:17 +0100 Subject: [PATCH 26/41] feat: more work in kanban --- src/helpers/kanbanHelper.ts | 87 ++++++++++++++ src/views/actionViews/KanbanActionView.tsx | 99 ++++++++++++++-- src/widgets/views/Form.tsx | 19 ++++ src/widgets/views/Kanban/Kanban.tsx | 106 ++++++++++++++++-- src/widgets/views/Kanban/KanbanBoard.tsx | 88 ++++++++++++++- src/widgets/views/Kanban/KanbanCard.tsx | 66 +++++++++-- src/widgets/views/Kanban/KanbanColumn.tsx | 84 ++++++++++---- .../views/Kanban/useKanbanColumnData.ts | 23 ++-- src/widgets/views/Kanban/useKanbanColumns.ts | 92 +-------------- 9 files changed, 513 insertions(+), 151 deletions(-) create mode 100644 src/helpers/kanbanHelper.ts diff --git a/src/helpers/kanbanHelper.ts b/src/helpers/kanbanHelper.ts new file mode 100644 index 000000000..9eaf88193 --- /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: String(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: String(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/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index fef0b13f7..6a56e7e38 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -14,6 +14,7 @@ import { KanbanComponent, KanbanRef } from "@/widgets/views/Kanban/Kanban"; import { useActionViewContext } from "@/context/ActionViewContext"; import { KanbanRecord } 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"; @@ -21,6 +22,8 @@ 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; @@ -46,6 +49,7 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { } = props; const { setViewIsLoading } = useActionViewContext(); + const { t } = useLocale(); const { searchVisible, @@ -92,21 +96,98 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { setViewIsLoading?.(isLoading); }, [isLoading, setViewIsLoading]); + 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 onCancelFormModal = useCallback(() => { - setShowFormModal(false); - setSelectedRecord(undefined); - }, []); + 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, + ); - const onFormModalSubmitSucceed = useCallback(() => { - setShowFormModal(false); - setSelectedRecord(undefined); - kanbanRef.current?.refreshResults(); - }, [kanbanRef]); + if (oldColumnId && newColumnId) { + const columnsToRefresh = + oldColumnId === newColumnId + ? [oldColumnId] + : [oldColumnId, newColumnId]; + kanbanRef.current?.refreshColumns(columnsToRefresh); + } else { + kanbanRef.current?.refreshResults(); + } + return; + } + } + + kanbanRef.current?.updateRecord(id, values); + }, + [kanbanColumnField, getColumnIdFromValue, kanbanRef], + ); + + const onCancelFormModal = useCallback( + (params?: { id?: number; values?: any }) => { + setShowFormModal(false); + const oldRecord = selectedRecord; + setSelectedRecord(undefined); + handleCardValuesChanged(params?.id, params?.values, oldRecord); + }, + [selectedRecord, handleCardValuesChanged], + ); + + const onFormModalSubmitSucceed = useCallback( + (id?: number, values?: any) => { + setShowFormModal(false); + const oldRecord = selectedRecord; + setSelectedRecord(undefined); + handleCardValuesChanged(id, values, oldRecord); + }, + [selectedRecord, handleCardValuesChanged], + ); const handleTotalRowsChange = useCallback((total: number) => { setTotalRows(total); diff --git a/src/widgets/views/Form.tsx b/src/widgets/views/Form.tsx index d34370b54..594653fbc 100644 --- a/src/widgets/views/Form.tsx +++ b/src/widgets/views/Form.tsx @@ -141,6 +141,7 @@ function Form(props: FormProps, ref: any) { const createdId = useRef(); const originalFormValues = useRef({}); + const initialFormValues = useRef(null); const lastAssignedValues = useRef({}); const warningIsShown = useRef(false); const formSubmitting = useRef(false); @@ -532,6 +533,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 +765,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?.(); diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index 29c906e69..92a1f6cf9 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -12,12 +12,13 @@ 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 } from "./KanbanBoard"; +import { KanbanBoard, KanbanBoardRef } from "./KanbanBoard"; import { KanbanRecord } from "./types"; import { useKanbanColumns } from "./useKanbanColumns"; import { Alert, Spin } from "antd"; import { useLocale } from "@gisce/react-formiga-components"; import { KanbanColumnRef } from "./KanbanColumn"; +import { normalizeColumnValue } from "@/helpers/kanbanHelper"; type KanbanProps = { kanbanView: KanbanView; @@ -33,6 +34,8 @@ type KanbanProps = { export type KanbanRef = { refreshResults: () => void; + refreshColumns: (columnIds: string[]) => void; + updateRecord: (id: number, updatedValues: Partial) => void; }; const KanbanComponentInner = ( @@ -117,6 +120,7 @@ const KanbanComponentInner = ( }); const columnRefs = useRef>(new Map()); + const boardRef = useRef(null); const [columnCounts, setColumnCounts] = useState>({}); useEffect(() => { @@ -135,22 +139,99 @@ const KanbanComponentInner = ( prevNameSearch.current = nameSearch; }, [nameSearch]); - useImperativeHandle(ref, () => ({ - refreshResults: () => { - columnRefs.current.forEach((ref) => { - ref.refresh(); - }); + const updateRecord = useCallback( + (id: number, updatedValues: Partial) => { + boardRef.current?.updateRecord(id, updatedValues); }, - })); + [], + ); + + 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, + updateRecord, + }), + [refreshColumns, updateRecord], + ); const handleButtonClick = useCallback( - async (buttonName: string, recordId: number) => { - // Refresh all columns after button click + async ( + _buttonName: string, + _recordId: number, + oldRecord: KanbanRecord, + newRecord?: KanbanRecord, + ) => { + const columnField = kanbanDef?.column_field; + + if (newRecord && columnField) { + const oldColumnValue = oldRecord[columnField]; + const newColumnValue = newRecord[columnField]; + + if (newColumnValue !== undefined && oldColumnValue !== newColumnValue) { + const columnFieldDef = kanbanDef?.fields?.[columnField]; + + if (columnFieldDef) { + const oldColumnInfo = normalizeColumnValue( + oldColumnValue, + columnFieldDef, + t, + ); + const newColumnInfo = normalizeColumnValue( + newColumnValue, + columnFieldDef, + t, + ); + + const oldColumnId = oldColumnInfo?.id ?? null; + const newColumnId = newColumnInfo?.id ?? null; + + if (oldColumnId && newColumnId) { + const columnsToRefresh = + oldColumnId === newColumnId + ? [oldColumnId] + : [oldColumnId, newColumnId]; + refreshColumns(columnsToRefresh); + return; + } + } + } else { + // Column value didn't change, just refresh the current column + const columnFieldDef = kanbanDef?.fields?.[columnField]; + if (columnFieldDef) { + const columnInfo = normalizeColumnValue( + oldColumnValue, + columnFieldDef, + t, + ); + if (columnInfo?.id) { + refreshColumns([columnInfo.id]); + return; + } + } + } + } + + // Fallback: refresh all columns columnRefs.current.forEach((ref) => { ref.refresh(); }); }, - [], + [kanbanDef, t, refreshColumns], ); const setColumnRef = useCallback( @@ -209,12 +290,15 @@ const KanbanComponentInner = ( ); } - if (!kanbanDef || isLoadingColumns) { + // Only show spinner on initial load (when no columns yet) + // Keep board visible with previous columns during refresh + if (!kanbanDef || (isLoadingColumns && columns.length === 0)) { return ; } return ( ) => void; +}; + type KanbanBoardProps = { columns: ColumnDefinition[]; columnField: string; @@ -25,12 +37,20 @@ type KanbanBoardProps = { fieldsToRetrieve?: string[]; kanbanDef: Kanban; onCardClick?: (record: KanbanRecord) => void; - onButtonClick?: (buttonName: string, recordId: number) => void; + onButtonClick?: ( + buttonName: string, + recordId: number, + oldRecord: KanbanRecord, + newRecord?: KanbanRecord, + ) => void; setColumnRef: (columnId: string, ref: KanbanColumnRef | null) => void; onColumnCountChange: (columnId: string, count: number) => void; }; -const KanbanBoardComponent = (props: KanbanBoardProps) => { +const KanbanBoardComponent = ( + props: KanbanBoardProps, + ref: React.Ref, +) => { const { columns, columnField, @@ -53,6 +73,7 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { 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 sensors = useSensors( useSensor(PointerSensor, { @@ -122,6 +143,60 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { [], ); + const updateRecord = useCallback( + (id: number, updatedValues: Partial) => { + allRecordsRef.current[id] = { + ...allRecordsRef.current[id], + ...updatedValues, + }; + + let updated = false; + Object.values(columnRefsRef.current).forEach((columnRef) => { + if (columnRef) { + columnRef.updateRecord(id, updatedValues); + updated = true; + } + }); + + if (!updated) { + console.warn( + `Could not find column containing record ${id} for update. Consider refreshing the view.`, + ); + } + }, + [], + ); + + useImperativeHandle( + ref, + () => ({ + updateRecord, + }), + [updateRecord], + ); + + 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 = useMemo(() => { + const callbacks: Record void> = {}; + columns.forEach((column) => { + callbacks[column.id] = (ref: KanbanColumnRef | null) => { + handleColumnRef(column.id, ref); + }; + }); + return callbacks; + }, [columns, handleColumnRef]); + if (columns.length === 0) { return (
{ {columns.map((column) => ( setColumnRef(column.id, ref)} + ref={columnRefCallbacks[column.id]} column={column} columnField={columnField} model={model} @@ -192,6 +267,7 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { record={activeRecord} kanbanDef={kanbanDef} draggable={false} + model={model} color={colorsForRecordsRef?.current?.[activeRecord.id]} status={statusForRecordsRef?.current?.[activeRecord.id]} context={context} @@ -203,4 +279,6 @@ const KanbanBoardComponent = (props: KanbanBoardProps) => { ); }; -export const KanbanBoard = memo(KanbanBoardComponent); +export const KanbanBoard = memo( + forwardRef(KanbanBoardComponent), +); diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index b90ec6f11..e6de0a05d 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -7,6 +7,7 @@ import { Kanban } from "@gisce/ooui"; import type { KanbanButton } from "@gisce/ooui/dist/Kanban"; import ConnectionProvider from "@/ConnectionProvider"; import { KANBAN_COMPONENTS } from "./kanbanComponents"; +import { useErrorNotification } from "@/hooks/useErrorNotification"; const { Text } = Typography; const { useToken } = theme; @@ -59,11 +60,17 @@ type KanbanCardProps = { record: KanbanRecord; kanbanDef: Kanban; draggable: boolean; + model: string; color?: string; status?: string; context?: any; onClick?: () => void; - onButtonClick?: (buttonName: string, recordId: number) => void; + onButtonClick?: ( + buttonName: string, + recordId: number, + oldRecord: KanbanRecord, + newRecord?: KanbanRecord, + ) => void; }; const KanbanCardComponent = (props: KanbanCardProps) => { @@ -71,6 +78,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { record, kanbanDef, draggable, + model, color, status, context = {}, @@ -79,6 +87,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { } = props; const { token } = useToken(); const [loadingButton, setLoadingButton] = useState(null); + const { showErrorNotification } = useErrorNotification(); const { attributes, listeners, setNodeRef, isDragging } = useSortable({ id: record.id, @@ -189,22 +198,63 @@ const KanbanCardComponent = (props: KanbanCardProps) => { try { if (button.buttonType === "object") { await ConnectionProvider.getHandler().execute({ - model: record.__model || "", - method: button.id, - args: [[record.id]], - } as any); + model, + action: button.id, + payload: [record.id], + context: { + ...context, + active_id: record.id, + active_ids: [record.id], + }, + }); + + let newRecord: KanbanRecord | undefined; + + try { + const fieldsObject = kanbanDef?.fields + ? Object.keys(kanbanDef.fields).reduce( + (acc: any, fieldName: string) => { + acc[fieldName] = kanbanDef.fields[fieldName]; + return acc; + }, + {}, + ) + : {}; + + const updatedRecords = + await ConnectionProvider.getHandler().readObjects({ + model, + ids: [record.id], + fields: fieldsObject, + context, + }); + + if (updatedRecords && updatedRecords.length > 0) { + newRecord = updatedRecords[0]; + } + } catch (readErr) { + console.warn("Failed to fetch updated record:", readErr); + } if (onButtonClick) { - onButtonClick(button.id, record.id); + onButtonClick(button.id, record.id, record, newRecord); } } } catch (err) { - console.error("Error executing button action:", err); + showErrorNotification(err); } finally { setLoadingButton(null); } }, - [loadingButton, record, onButtonClick], + [ + loadingButton, + model, + record, + context, + onButtonClick, + showErrorNotification, + kanbanDef, + ], ); const buttonClickHandlers = useMemo(() => { diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index 0fb827ce0..72624357e 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useCallback, + useState, } from "react"; import { useDeepCompareEffect } from "use-deep-compare"; import { Badge, Button, Space, theme, Typography } from "antd"; @@ -27,6 +28,7 @@ const { useToken } = theme; export type KanbanColumnRef = { refresh: () => void; + updateRecord: (id: number, updatedValues: Partial) => void; }; type KanbanColumnProps = { @@ -44,7 +46,12 @@ type KanbanColumnProps = { maxCards?: number; isOver?: boolean; onCardClick?: (record: KanbanRecord) => void; - onButtonClick?: (buttonName: string, recordId: number) => void; + onButtonClick?: ( + buttonName: string, + recordId: number, + oldRecord: KanbanRecord, + newRecord?: KanbanRecord, + ) => void; onMaxCardsChange?: (colId: string, maxCards: number | undefined) => void; onCountChange: (columnId: string, count: number) => void; onRecordsUpdate?: (records: KanbanRecord[], colors: any, status: any) => void; @@ -83,13 +90,14 @@ const KanbanColumnComponent = ( const { token } = useToken(); const { - records, + records: hookRecords, count, aggregates, colorsForRecords, statusForRecords, isLoading, isLoadingMore, + isRefreshing, hasMore, refresh, fetchNextPage, @@ -106,9 +114,31 @@ const KanbanColumnComponent = ( kanbanDef, }); - useImperativeHandle(ref, () => ({ - refresh, - })); + const [localRecords, setLocalRecords] = useState(hookRecords); + + useDeepCompareEffect(() => { + setLocalRecords(hookRecords); + }, [hookRecords]); + + const updateRecord = useCallback( + (id: number, updatedValues: Partial) => { + setLocalRecords((prevRecords) => + prevRecords.map((record) => + record.id === id ? { ...record, ...updatedValues } : record, + ), + ); + }, + [], + ); + + useImperativeHandle( + ref, + () => ({ + refresh, + updateRecord, + }), + [refresh, updateRecord], + ); // Report count changes to parent useEffect(() => { @@ -117,16 +147,19 @@ const KanbanColumnComponent = ( // Report records updates to parent (for drag overlay) useDeepCompareEffect(() => { - if (onRecordsUpdate && records.length > 0) { - onRecordsUpdate(records, colorsForRecords, statusForRecords); + if (onRecordsUpdate && localRecords.length > 0) { + onRecordsUpdate(localRecords, colorsForRecords, statusForRecords); } - }, [records, colorsForRecords, statusForRecords, onRecordsUpdate]); + }, [localRecords, colorsForRecords, statusForRecords, onRecordsUpdate]); const { setNodeRef } = useDroppable({ id: columnId, }); - const recordIds = useMemo(() => records.map((r) => r.id), [records]); + const recordIds = useMemo( + () => localRecords.map((r) => r.id), + [localRecords], + ); const isOverLimit = maxCards !== undefined && count > maxCards; @@ -140,15 +173,17 @@ const KanbanColumnComponent = ( const cardClickHandlers = useMemo(() => { if (!onCardClick) return {}; - return records.reduce void>>((acc, record) => { + return localRecords.reduce void>>((acc, record) => { acc[record.id] = () => onCardClick(record); return acc; }, {}); - }, [records, onCardClick]); + }, [localRecords, onCardClick]); const hasStatusRibbon = useMemo(() => { - return records.some((record) => statusForRecords?.current?.[record.id]); - }, [records, statusForRecords]); + return localRecords.some( + (record) => statusForRecords?.current?.[record.id], + ); + }, [localRecords, statusForRecords]); const scrollContainerRef = useRef(null); @@ -172,7 +207,7 @@ const KanbanColumnComponent = ( }, [kanbanDef.card_fields.length, kanbanDef.buttons.length]); const virtualizer = useVirtualizer({ - count: records.length, + count: localRecords.length, getScrollElement: () => scrollContainerRef.current, estimateSize: useCallback(() => estimatedCardHeight, [estimatedCardHeight]), overscan: 5, @@ -187,10 +222,20 @@ const KanbanColumnComponent = ( return; } - if (lastItem.index >= records.length - 5 && hasMore && !isLoadingMore) { + if ( + lastItem.index >= localRecords.length - 5 && + hasMore && + !isLoadingMore + ) { fetchNextPage(); } - }, [virtualItems, records.length, hasMore, isLoadingMore, fetchNextPage]); + }, [ + virtualItems, + localRecords.length, + hasMore, + isLoadingMore, + fetchNextPage, + ]); return (
- {isLoading ? ( + {isLoading || isRefreshing ? ( {virtualItems.map((virtualRow) => { - const record = records[virtualRow.index]; + const record = localRecords[virtualRow.index]; return (
@@ -351,7 +397,7 @@ const KanbanColumnComponent = (
)} - {records.length === 0 && !isLoading && ( + {localRecords.length === 0 && !isLoading && (
{ 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); @@ -98,12 +99,17 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { return; } + const isInitialLoad = records.length === 0 && !isLoadingNextPage; + if (isLoadingNextPage) { setIsLoadingMore(true); } else { setIsLoading(true); - setCurrentOffset(0); - setHasMore(true); + // Only reset offset/hasMore on initial load, not on refresh + if (isInitialLoad) { + setCurrentOffset(0); + setHasMore(true); + } } setError(null); @@ -197,6 +203,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { setCurrentOffset((prev) => prev + PAGE_SIZE); setHasMore(fetchedRecords.length === PAGE_SIZE); } else { + // For refresh: replace old data with new data smoothly setRecords(fetchedRecords); setCurrentOffset(PAGE_SIZE); setHasMore(fetchedRecords.length === PAGE_SIZE); @@ -279,6 +286,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { setIsLoadingMore(false); } else { setIsLoading(false); + setIsRefreshing(false); } } }, @@ -299,6 +307,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { readAggregates, currentOffset, PAGE_SIZE, + records.length, ], ); @@ -317,13 +326,8 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { ]); const refresh = useCallback(() => { - setRecords([]); - setCurrentOffset(0); - setHasMore(true); - setAggregates({}); - setTotalCount(0); - colorsForRecords.current = {}; - statusForRecords.current = {}; + // Don't clear data - keep previous data visible during refresh + setIsRefreshing(true); fetchData(); }, [fetchData]); @@ -341,6 +345,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { statusForRecords, isLoading, isLoadingMore, + isRefreshing, hasMore, error, refresh, diff --git a/src/widgets/views/Kanban/useKanbanColumns.ts b/src/widgets/views/Kanban/useKanbanColumns.ts index e7a22ccd4..0e0a55e9b 100644 --- a/src/widgets/views/Kanban/useKanbanColumns.ts +++ b/src/widgets/views/Kanban/useKanbanColumns.ts @@ -4,6 +4,7 @@ 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 = { @@ -78,96 +79,6 @@ export const useKanbanColumns = (params: UseKanbanColumnsParams) => { return null; }, [columnFieldDefinition, t]); - const normalizeColumnValue = useCallback( - ( - value: any, - fieldDefinition: any, - ): { 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: String(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: String(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, - }; - } - } - }, - [t], - ); - const fetchDynamicColumns = useDeepCompareCallback(async () => { if (!enabled || !model || !columnField) { return; @@ -264,6 +175,7 @@ export const useKanbanColumns = (params: UseKanbanColumnsParams) => { const columnInfo = normalizeColumnValue( columnValue, columnFieldDefinition, + t, ); if (columnInfo && !dynamicColumnMap.has(columnInfo.id)) { From afc63fe6a725dd2da7323b5638f8f3081f068be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 13 Nov 2025 11:00:55 +0100 Subject: [PATCH 27/41] fix: update package-lock.json --- package-lock.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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", From f015644907b7781e3b10e1a2e984e1cfa8e6053a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 13 Nov 2025 11:36:27 +0100 Subject: [PATCH 28/41] feat: improve kanban --- src/widgets/views/Kanban/Kanban.tsx | 7 ++- src/widgets/views/Kanban/KanbanCard.tsx | 79 +++++++++++++++---------- 2 files changed, 53 insertions(+), 33 deletions(-) diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index 92a1f6cf9..da79c4189 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -94,15 +94,16 @@ const KanbanComponentInner = ( if ( kanbanDef.buttons.some( (b: KanbanButton) => b.states !== undefined && b.states !== null, - ) + ) && + kanbanView.fields["state"] ) { - fields.push(kanbanDef.column_field); + fields.push("state"); } fields.push("__model"); return [...new Set(fields)]; - }, [kanbanDef]); + }, [kanbanDef, kanbanView.fields]); const { columns, diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index e6de0a05d..8b98ea813 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -1,13 +1,18 @@ -import { memo, useMemo, useState, MouseEvent, useCallback } from "react"; +import { memo, useState, useCallback, MouseEvent } from "react"; import { Card as AntCard, Button, Space, Typography, theme } from "antd"; import { useSortable } from "@dnd-kit/sortable"; import styled from "styled-components"; +import { useDeepCompareMemo } from "use-deep-compare"; import { KanbanRecord } from "./types"; -import { Kanban } from "@gisce/ooui"; -import type { KanbanButton } from "@gisce/ooui/dist/Kanban"; +import { + Kanban, + Button as ButtonOoui, + KanbanCard as OouiKanbanCard, +} from "@gisce/ooui"; import ConnectionProvider from "@/ConnectionProvider"; -import { KANBAN_COMPONENTS } from "./kanbanComponents"; import { useErrorNotification } from "@/hooks/useErrorNotification"; +import { KANBAN_COMPONENTS } from "./kanbanComponents"; +import { Icon } from "@gisce/react-formiga-components"; const { Text } = Typography; const { useToken } = theme; @@ -99,11 +104,42 @@ const KanbanCardComponent = (props: KanbanCardProps) => { 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" && @@ -113,7 +149,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { fieldValue = { id: fieldValue[0], value: fieldValue[1], - model: field.relation, + model: fieldDef?.relation, }; } @@ -164,29 +200,11 @@ const KanbanCardComponent = (props: KanbanCardProps) => {
); }, - [record, context], + [record, context, kanbanDef.fields], ); - const visibleButtons = useMemo(() => { - return kanbanDef.buttons.filter((button: KanbanButton) => { - if (!button.states) { - return true; - } - - const currentState = record[kanbanDef.column_field]; - if (!currentState) { - return true; - } - - const allowedStates = button.states - .split(",") - .map((s: string) => s.trim()); - return allowedStates.includes(currentState); - }); - }, [kanbanDef.buttons, kanbanDef.column_field, record]); - const handleButtonClick = useCallback( - async (e: MouseEvent, button: KanbanButton) => { + async (e: MouseEvent, button: ButtonOoui) => { e.stopPropagation(); if (loadingButton) { @@ -257,7 +275,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { ], ); - const buttonClickHandlers = useMemo(() => { + const buttonClickHandlers = useDeepCompareMemo(() => { return visibleButtons.reduce void>>( (acc, button) => { acc[button.id] = (e: MouseEvent) => handleButtonClick(e, button); @@ -289,12 +307,12 @@ const KanbanCardComponent = (props: KanbanCardProps) => { {color && } {status && }
- {kanbanDef.card_fields.map((field: any) => renderField(field))} + {visibleFields.map((field: any) => renderField(field))}
{visibleButtons.length > 0 && ( - - {visibleButtons.map((button: KanbanButton) => ( + + {visibleButtons.map((button: ButtonOoui) => ( ))} From 7beece9c865f2ccf5cf01ec72c49fff29144fb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Thu, 13 Nov 2025 11:52:24 +0100 Subject: [PATCH 29/41] feat: add card feature --- src/views/actionViews/KanbanActionView.tsx | 39 ++++++++++++++++++---- src/widgets/views/Kanban/Kanban.tsx | 6 +++- src/widgets/views/Kanban/KanbanBoard.tsx | 16 +++++++-- src/widgets/views/Kanban/KanbanColumn.tsx | 3 ++ 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index 6a56e7e38..1a40d66fd 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -12,7 +12,7 @@ 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 } from "@/widgets/views/Kanban/types"; +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"; @@ -69,6 +69,8 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { const [selectedRecord, setSelectedRecord] = useState< KanbanRecord | undefined >(); + const [creatingInColumn, setCreatingInColumn] = + useState(null); const [totalRows, setTotalRows] = useState(null); const kanbanRef = viewRef as React.RefObject; @@ -131,6 +133,12 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { 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) { @@ -174,6 +182,7 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { setShowFormModal(false); const oldRecord = selectedRecord; setSelectedRecord(undefined); + setCreatingInColumn(null); handleCardValuesChanged(params?.id, params?.values, oldRecord); }, [selectedRecord, handleCardValuesChanged], @@ -182,11 +191,23 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { const onFormModalSubmitSucceed = useCallback( (id?: number, values?: any) => { setShowFormModal(false); - const oldRecord = selectedRecord; - setSelectedRecord(undefined); - handleCardValuesChanged(id, values, oldRecord); + + if (creatingInColumn && kanbanColumnField) { + kanbanRef.current?.refreshColumns([creatingInColumn.id]); + setCreatingInColumn(null); + } else { + const oldRecord = selectedRecord; + setSelectedRecord(undefined); + handleCardValuesChanged(id, values, oldRecord); + } }, - [selectedRecord, handleCardValuesChanged], + [ + creatingInColumn, + kanbanColumnField, + kanbanRef, + selectedRecord, + handleCardValuesChanged, + ], ); const handleTotalRowsChange = useCallback((total: number) => { @@ -327,17 +348,23 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { onCardClick={handleCardClick} onLoadingChange={setIsLoading} onTotalRowsChange={handleTotalRowsChange} + onAddCardClick={handleAddCard} />
{formView && ( )} diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index da79c4189..dde8eed3f 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -13,7 +13,7 @@ import { KanbanView } from "@/types"; import { Kanban } from "@gisce/ooui"; import type { KanbanButton } from "@gisce/ooui/dist/Kanban"; import { KanbanBoard, KanbanBoardRef } from "./KanbanBoard"; -import { KanbanRecord } from "./types"; +import { KanbanRecord, ColumnDefinition } from "./types"; import { useKanbanColumns } from "./useKanbanColumns"; import { Alert, Spin } from "antd"; import { useLocale } from "@gisce/react-formiga-components"; @@ -30,6 +30,7 @@ type KanbanProps = { onCardClick?: (record: KanbanRecord) => void; onLoadingChange?: (isLoading: boolean) => void; onTotalRowsChange?: (totalRows: number) => void; + onAddCardClick?: (column: ColumnDefinition) => void; }; export type KanbanRef = { @@ -52,6 +53,7 @@ const KanbanComponentInner = ( onCardClick, onLoadingChange, onTotalRowsChange, + onAddCardClick, } = props; const prevNameSearch = useRef(nameSearch); @@ -313,6 +315,7 @@ const KanbanComponentInner = ( onButtonClick={handleButtonClick} setColumnRef={setColumnRef} onColumnCountChange={handleColumnCountChange} + onAddCardClick={onAddCardClick} /> ); }, [ @@ -331,6 +334,7 @@ const KanbanComponentInner = ( handleButtonClick, setColumnRef, handleColumnCountChange, + onAddCardClick, t, ]); diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index dc8bc7d5c..178fd131f 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -5,8 +5,8 @@ import { useRef, forwardRef, useImperativeHandle, - useMemo, } from "react"; +import { useDeepCompareMemo } from "use-deep-compare"; import { DndContext, DragOverEvent, @@ -45,6 +45,7 @@ type KanbanBoardProps = { ) => void; setColumnRef: (columnId: string, ref: KanbanColumnRef | null) => void; onColumnCountChange: (columnId: string, count: number) => void; + onAddCardClick?: (column: ColumnDefinition) => void; }; const KanbanBoardComponent = ( @@ -65,6 +66,7 @@ const KanbanBoardComponent = ( onButtonClick, setColumnRef, onColumnCountChange, + onAddCardClick, } = props; const { t } = useLocale(); @@ -187,7 +189,7 @@ const KanbanBoardComponent = ( [setColumnRef], ); - const columnRefCallbacks = useMemo(() => { + const columnRefCallbacks = useDeepCompareMemo(() => { const callbacks: Record void> = {}; columns.forEach((column) => { callbacks[column.id] = (ref: KanbanColumnRef | null) => { @@ -197,6 +199,15 @@ const KanbanBoardComponent = ( 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 (
))}
diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index 72624357e..b9560a05a 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -55,6 +55,7 @@ type KanbanColumnProps = { onMaxCardsChange?: (colId: string, maxCards: number | undefined) => void; onCountChange: (columnId: string, count: number) => void; onRecordsUpdate?: (records: KanbanRecord[], colors: any, status: any) => void; + onAddCardClick?: () => void; }; const KanbanColumnComponent = ( @@ -78,6 +79,7 @@ const KanbanColumnComponent = ( onButtonClick, onCountChange, onRecordsUpdate, + onAddCardClick, } = props; const { @@ -419,6 +421,7 @@ const KanbanColumnComponent = (
- + {activeRecord && activeRecord.id ? (
void; + isMoving?: boolean; }; const KanbanCardComponent = (props: KanbanCardProps) => { @@ -89,6 +90,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { context = {}, onClick, onButtonClick, + isMoving = false, } = props; const { token } = useToken(); const [loadingButton, setLoadingButton] = useState(null); @@ -100,7 +102,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { }); const style = { - opacity: isDragging ? 0 : 1, + opacity: isDragging || isMoving ? 0 : 1, cursor: "pointer", }; diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index b9560a05a..5baaec6fb 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -124,13 +124,54 @@ const KanbanColumnComponent = ( const updateRecord = useCallback( (id: number, updatedValues: Partial) => { - setLocalRecords((prevRecords) => - prevRecords.map((record) => - record.id === id ? { ...record, ...updatedValues } : record, - ), - ); + setLocalRecords((prevRecords) => { + const existingIndex = prevRecords.findIndex((r) => r.id === id); + const existingRecord = prevRecords[existingIndex]; + + const updatedRecord = existingRecord + ? { ...existingRecord, ...updatedValues } + : ({ id, ...updatedValues } as KanbanRecord); + + const recordColumnValue = updatedRecord[columnField]; + + const shouldBeInThisColumn = (() => { + if ( + Array.isArray(columnOriginalValue) && + columnOriginalValue.length === 2 + ) { + if ( + Array.isArray(recordColumnValue) && + recordColumnValue.length === 2 + ) { + return recordColumnValue[0] === columnOriginalValue[0]; + } + return recordColumnValue === columnOriginalValue[0]; + } + + if ( + Array.isArray(recordColumnValue) && + recordColumnValue.length === 2 + ) { + return recordColumnValue[0] === columnOriginalValue; + } + + return recordColumnValue === columnOriginalValue; + })(); + + if (shouldBeInThisColumn) { + if (existingRecord) { + const updated = [...prevRecords]; + updated[existingIndex] = updatedRecord; + return updated; + } else { + return [updatedRecord, ...prevRecords]; + } + } else { + return prevRecords.filter((r) => r.id !== id); + } + }); }, - [], + [columnField, columnOriginalValue], ); useImperativeHandle( From 0ac2b96ce06a1e8faec982ad63a594eab1cdf868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Tue, 18 Nov 2025 09:42:41 +0100 Subject: [PATCH 32/41] feat: new useProcessAction hook --- src/hooks/useProcessAction.ts | 69 ++++++++++++++++++++++ src/widgets/views/Form.tsx | 108 +++++++++++++++++++--------------- 2 files changed, 130 insertions(+), 47 deletions(-) create mode 100644 src/hooks/useProcessAction.ts 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/widgets/views/Form.tsx b/src/widgets/views/Form.tsx index 594653fbc..fb7d8f158 100644 --- a/src/widgets/views/Form.tsx +++ b/src/widgets/views/Form.tsx @@ -59,6 +59,7 @@ import { } from "../../hooks/useFieldMessages"; import { ACTION_TYPE_WINDOW_CLOSE, MODEL_ACTIONS } from "@/models/constants"; import { useConfigContext } from "@/context/ConfigContext"; +import { useProcessAction } from "@/hooks/useProcessAction"; export type FormProps = { model: string; @@ -174,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(); @@ -424,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?.(); @@ -1129,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 }); } From 95299bf009a176e5d827b5bd70cc6a8daaed37e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Tue, 18 Nov 2025 10:06:31 +0100 Subject: [PATCH 33/41] fix: remove useless things --- src/views/actionViews/KanbanActionView.tsx | 17 ++------------ src/widgets/views/Kanban/Kanban.tsx | 27 ++++++++-------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index 1a40d66fd..c46992d86 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -1,12 +1,4 @@ -import { - Fragment, - useCallback, - useState, - memo, - useEffect, - useMemo, - useRef, -} from "react"; +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"; @@ -64,7 +56,6 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { setSearchTreeNameSearch, } = useSearchTreeState({ useLocalState: false }); - const [isLoading, setIsLoading] = useState(true); const [showFormModal, setShowFormModal] = useState(false); const [selectedRecord, setSelectedRecord] = useState< KanbanRecord | undefined @@ -94,10 +85,6 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { [availableHeight], ); - useEffect(() => { - setViewIsLoading?.(isLoading); - }, [isLoading, setViewIsLoading]); - const kanbanColumnField = useMemo(() => { if (!kanbanView.arch || !kanbanView.fields) { return null; @@ -346,7 +333,7 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { searchParams={searchParams || []} nameSearch={searchTreeNameSearch} onCardClick={handleCardClick} - onLoadingChange={setIsLoading} + onLoadingChange={setViewIsLoading} onTotalRowsChange={handleTotalRowsChange} onAddCardClick={handleAddCard} /> diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index 8d502bedb..f522dfb82 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -102,8 +102,6 @@ const KanbanComponentInner = ( fields.push("state"); } - fields.push("__model"); - return [...new Set(fields)]; }, [kanbanDef, kanbanView.fields]); @@ -124,7 +122,7 @@ const KanbanComponentInner = ( const columnRefs = useRef>(new Map()); const boardRef = useRef(null); - const [columnCounts, setColumnCounts] = useState>({}); + const columnCountsRef = useRef>({}); useEffect(() => { const isNameSearchActive = nameSearch && nameSearch.trim().length > 0; @@ -250,12 +248,17 @@ const KanbanComponentInner = ( const handleColumnCountChange = useCallback( (columnId: string, count: number) => { - setColumnCounts((prev) => ({ - ...prev, + columnCountsRef.current = { + ...columnCountsRef.current, [columnId]: count, - })); + }; + const totalRows = Object.values(columnCountsRef.current).reduce( + (sum, c) => sum + c, + 0, + ); + onTotalRowsChange?.(totalRows); }, - [], + [onTotalRowsChange], ); const handleDragSuccess = useCallback( @@ -265,14 +268,6 @@ const KanbanComponentInner = ( [refreshColumns], ); - useEffect(() => { - const totalRows = Object.values(columnCounts).reduce( - (sum, count) => sum + count, - 0, - ); - onTotalRowsChange?.(totalRows); - }, [columnCounts, onTotalRowsChange]); - useEffect(() => { onLoadingChange?.(isLoadingColumns); }, [isLoadingColumns, onLoadingChange]); @@ -300,8 +295,6 @@ const KanbanComponentInner = ( ); } - // Only show spinner on initial load (when no columns yet) - // Keep board visible with previous columns during refresh if (!kanbanDef || (isLoadingColumns && columns.length === 0)) { return ; } From 12488ea6416d7ba7c15528c638206a2223551935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Tue, 18 Nov 2025 11:31:43 +0100 Subject: [PATCH 34/41] fix: adjust drag and empty columns --- src/widgets/views/Kanban/Kanban.tsx | 3 +- src/widgets/views/Kanban/KanbanBoard.tsx | 30 +++++++------------ src/widgets/views/Kanban/KanbanColumn.tsx | 15 +++++----- .../views/Kanban/useKanbanColumnData.ts | 17 ++++++----- 4 files changed, 28 insertions(+), 37 deletions(-) diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index f522dfb82..2c3a81586 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -302,15 +302,14 @@ const KanbanComponentInner = ( return ( { const { columns, - columnField, model, domain, context = {}, @@ -81,7 +79,6 @@ const KanbanBoardComponent = ( const { showErrorNotification } = useErrorNotification(); const [activeRecord, setActiveRecord] = useState(null); const [overColumnId, setOverColumnId] = useState(null); - const [isDragging, setIsDragging] = useState(false); const colorsForRecordsRef = useRef<{ [key: number]: string }>({}); const statusForRecordsRef = useRef<{ [key: number]: string }>({}); const allRecordsRef = useRef<{ [key: number]: KanbanRecord }>({}); @@ -110,8 +107,6 @@ const KanbanBoardComponent = ( const { active } = event; const recordId = active.id as number; - setIsDragging(true); - const fullRecord = allRecordsRef.current[recordId]; if (fullRecord) { setActiveRecord(fullRecord); @@ -122,7 +117,7 @@ const KanbanBoardComponent = ( const findColumnByValue = useCallback( (value: any): ColumnDefinition | undefined => { - const columnFieldDef = kanbanDef.fields?.[columnField]; + const columnFieldDef = kanbanDef.fields?.[kanbanDef.column_field]; if (!columnFieldDef) return undefined; const normalizedValue = normalizeColumnValue(value, columnFieldDef, t); @@ -130,7 +125,7 @@ const KanbanBoardComponent = ( return columns.find((col) => col.id === normalizedValue.id); }, - [columns, columnField, kanbanDef, t], + [columns, kanbanDef, t], ); const handleDragOver = useCallback( @@ -150,7 +145,7 @@ const KanbanBoardComponent = ( const overRecordId = over.id as number; const overRecord = allRecordsRef.current[overRecordId]; if (overRecord) { - const recordColumnValue = overRecord[columnField]; + const recordColumnValue = overRecord[kanbanDef.column_field]; const recordColumn = findColumnByValue(recordColumnValue); if (recordColumn) { setOverColumnId(recordColumn.id); @@ -160,13 +155,12 @@ const KanbanBoardComponent = ( setOverColumnId(null); }, - [columns, columnField, findColumnByValue], + [columns, kanbanDef.column_field, findColumnByValue], ); const handleDragCancel = useCallback(() => { setActiveRecord(null); setOverColumnId(null); - setIsDragging(false); }, []); const handleRecordsUpdate = useCallback( @@ -207,7 +201,6 @@ const KanbanBoardComponent = ( const cleanup = () => { setActiveRecord(null); setOverColumnId(null); - setIsDragging(false); }; if (!over) { @@ -223,8 +216,8 @@ const KanbanBoardComponent = ( return; } - const sourceColumnValue = record[columnField]; - const columnFieldDef = kanbanDef.fields?.[columnField]; + const sourceColumnValue = record[kanbanDef.column_field]; + const columnFieldDef = kanbanDef.fields?.[kanbanDef.column_field]; const sourceColumnNormalized = columnFieldDef ? normalizeColumnValue(sourceColumnValue, columnFieldDef, t) : null; @@ -240,7 +233,7 @@ const KanbanBoardComponent = ( const overRecordId = over.id as number; const overRecord = allRecordsRef.current[overRecordId]; if (overRecord) { - const overRecordColumnValue = overRecord[columnField]; + const overRecordColumnValue = overRecord[kanbanDef.column_field]; targetColumn = findColumnByValue(overRecordColumnValue); } } @@ -268,7 +261,7 @@ const KanbanBoardComponent = ( updateRecord(recordId, { ...record, - [columnField]: targetColumn.originalValue, + [kanbanDef.column_field]: targetColumn.originalValue, }); cleanup(); @@ -282,7 +275,7 @@ const KanbanBoardComponent = ( action: methodName, payload: [ [recordId], - columnField, + kanbanDef.column_field, fromValue, toValue, { @@ -309,7 +302,6 @@ const KanbanBoardComponent = ( }, [ columns, - columnField, model, context, kanbanDef, @@ -399,16 +391,14 @@ const KanbanBoardComponent = ( { const { column, - columnField, model, domain, context = {}, @@ -72,7 +69,6 @@ const KanbanColumnComponent = ( nameSearch, fieldsToRetrieve, kanbanDef, - draggable, maxCards, isOver = false, onCardClick, @@ -107,7 +103,6 @@ const KanbanColumnComponent = ( model, domain, context, - columnField, columnValue: columnOriginalValue, searchParams, nameSearch, @@ -132,7 +127,7 @@ const KanbanColumnComponent = ( ? { ...existingRecord, ...updatedValues } : ({ id, ...updatedValues } as KanbanRecord); - const recordColumnValue = updatedRecord[columnField]; + const recordColumnValue = updatedRecord[kanbanDef.column_field]; const shouldBeInThisColumn = (() => { if ( @@ -171,7 +166,7 @@ const KanbanColumnComponent = ( } }); }, - [columnField, columnOriginalValue], + [kanbanDef.column_field, columnOriginalValue], ); useImperativeHandle( @@ -280,6 +275,10 @@ const KanbanColumnComponent = ( fetchNextPage, ]); + if (count === 0 && !kanbanDef.drag) { + return null; + } + return (
{ model, domain, context, - columnField, columnValue, searchParams = [], nameSearch, @@ -95,7 +93,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { const fetchData = useDeepCompareCallback( async (isLoadingNextPage = false) => { - if (!enabled || !model || !columnField) { + if (!enabled || !model || !kanbanDef?.column_field) { return; } @@ -131,7 +129,10 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { searchValue = false; } - const columnDomain = [...baseDomain, [columnField, "=", searchValue]]; + const columnDomain = [ + ...baseDomain, + [kanbanDef.column_field, "=", searchValue], + ]; if (!isLoadingNextPage) { try { @@ -152,7 +153,9 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { // Build fields object for searchForTree // searchForTree expects an object of field definitions, not an array of field names - const fieldsToFetch = [...new Set([...fieldsToRetrieve, columnField])]; + const fieldsToFetch = [ + ...new Set([...fieldsToRetrieve, kanbanDef.column_field]), + ]; const fieldsObject = kanbanDef?.fields ? Object.keys(kanbanDef.fields).reduce( (acc: any, fieldName: string) => { @@ -293,7 +296,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { [ enabled, model, - columnField, + kanbanDef?.column_field, columnValue, domain, searchParams, @@ -316,7 +319,7 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { }, [ enabled, model, - columnField, + kanbanDef?.column_field, columnValue, domain, searchParams, From 6a9357ccf8b30418af281d895c518b986482728d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Tue, 18 Nov 2025 11:41:30 +0100 Subject: [PATCH 35/41] fix: more adjustments --- src/helpers/kanbanHelper.ts | 4 ++-- src/widgets/views/Kanban/KanbanBoard.tsx | 24 +++++++++++++----------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/helpers/kanbanHelper.ts b/src/helpers/kanbanHelper.ts index 9eaf88193..9e0630952 100644 --- a/src/helpers/kanbanHelper.ts +++ b/src/helpers/kanbanHelper.ts @@ -15,7 +15,7 @@ export const normalizeColumnValue = ( case "many2one": if (Array.isArray(value) && value.length === 2) { return { - id: String(value[0]), + id: value[0], label: value[1], originalValue: value, }; @@ -44,7 +44,7 @@ export const normalizeColumnValue = ( } return { - id: String(selectionKey), + id: selectionKey, label: selectionLabel, originalValue: selectionKey, }; diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index e9ac64f80..1c045f4ed 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -248,15 +248,17 @@ const KanbanBoardComponent = ( return; } - const getBackendValue = (value: any) => { - if (Array.isArray(value) && value.length === 2) { - return value[0]; - } - return value; - }; - - const fromValue = getBackendValue(sourceColumnValue); - const toValue = getBackendValue(targetColumn.originalValue); + const fromValue = normalizeColumnValue( + sourceColumnValue, + columnFieldDef, + t, + ); + + const toValue = normalizeColumnValue( + targetColumn.originalValue, + columnFieldDef, + t, + ); const originalRecord = { ...record }; updateRecord(recordId, { @@ -276,8 +278,8 @@ const KanbanBoardComponent = ( payload: [ [recordId], kanbanDef.column_field, - fromValue, - toValue, + fromValue?.id, + toValue?.id, { ...context, active_id: recordId, From b204913ca6fd3258392ee7c7fcbba18d6ec16fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Tue, 18 Nov 2025 12:25:58 +0100 Subject: [PATCH 36/41] fix: improvmentes --- src/widgets/views/Kanban/useKanbanColumnData.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/widgets/views/Kanban/useKanbanColumnData.ts b/src/widgets/views/Kanban/useKanbanColumnData.ts index b1e820fac..e334094c6 100644 --- a/src/widgets/views/Kanban/useKanbanColumnData.ts +++ b/src/widgets/views/Kanban/useKanbanColumnData.ts @@ -197,17 +197,15 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { }, ); + setRecords(fetchedRecords); + if (nameSearch) { - setRecords(fetchedRecords); setCurrentOffset(0); setHasMore(false); } else if (isLoadingNextPage) { - setRecords((prev) => [...prev, ...fetchedRecords]); setCurrentOffset((prev) => prev + PAGE_SIZE); setHasMore(fetchedRecords.length === PAGE_SIZE); } else { - // For refresh: replace old data with new data smoothly - setRecords(fetchedRecords); setCurrentOffset(PAGE_SIZE); setHasMore(fetchedRecords.length === PAGE_SIZE); } From 77400aefb1b26d4bd889d2708b367a2e4660cab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Tue, 18 Nov 2025 12:34:57 +0100 Subject: [PATCH 37/41] fix: simplify update records approach --- src/views/actionViews/KanbanActionView.tsx | 2 +- src/widgets/views/Kanban/Kanban.tsx | 11 +-- src/widgets/views/Kanban/KanbanBoard.tsx | 43 ++++----- src/widgets/views/Kanban/KanbanColumn.tsx | 106 +++------------------ 4 files changed, 33 insertions(+), 129 deletions(-) diff --git a/src/views/actionViews/KanbanActionView.tsx b/src/views/actionViews/KanbanActionView.tsx index c46992d86..bdb0d3334 100644 --- a/src/views/actionViews/KanbanActionView.tsx +++ b/src/views/actionViews/KanbanActionView.tsx @@ -159,7 +159,7 @@ const KanbanActionViewComponent = (props: KanbanActionViewProps) => { } } - kanbanRef.current?.updateRecord(id, values); + kanbanRef.current?.refreshResults(); }, [kanbanColumnField, getColumnIdFromValue, kanbanRef], ); diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index 2c3a81586..facf08772 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -36,7 +36,6 @@ type KanbanProps = { export type KanbanRef = { refreshResults: () => void; refreshColumns: (columnIds: string[]) => void; - updateRecord: (id: number, updatedValues: Partial) => void; }; const KanbanComponentInner = ( @@ -140,13 +139,6 @@ const KanbanComponentInner = ( prevNameSearch.current = nameSearch; }, [nameSearch]); - const updateRecord = useCallback( - (id: number, updatedValues: Partial) => { - boardRef.current?.updateRecord(id, updatedValues); - }, - [], - ); - const refreshColumns = useCallback((columnIds: string[]) => { columnIds.forEach((columnId) => { const ref = columnRefs.current.get(columnId); @@ -165,9 +157,8 @@ const KanbanComponentInner = ( }); }, refreshColumns, - updateRecord, }), - [refreshColumns, updateRecord], + [refreshColumns], ); const handleButtonClick = useCallback( diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index 1c045f4ed..afe7c887d 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -29,7 +29,7 @@ import { useNetworkRequest } from "@/hooks/useNetworkRequest"; import { normalizeColumnValue } from "@/helpers/kanbanHelper"; export type KanbanBoardRef = { - updateRecord: (id: number, updatedValues: Partial) => void; + refreshAllColumns: () => void; }; type KanbanBoardProps = { @@ -178,21 +178,13 @@ const KanbanBoardComponent = ( [], ); - const updateRecord = useCallback( - (id: number, updatedValues: Partial) => { - allRecordsRef.current[id] = { - ...allRecordsRef.current[id], - ...updatedValues, - }; - - Object.values(columnRefsRef.current).forEach((columnRef) => { - if (columnRef) { - columnRef.updateRecord(id, updatedValues); - } - }); - }, - [], - ); + const refreshAllColumns = useCallback(() => { + Object.values(columnRefsRef.current).forEach((columnRef) => { + if (columnRef) { + columnRef.refresh(); + } + }); + }, []); const handleDragEnd = useCallback( async (event: DragEndEvent) => { @@ -259,12 +251,6 @@ const KanbanBoardComponent = ( columnFieldDef, t, ); - const originalRecord = { ...record }; - - updateRecord(recordId, { - ...record, - [kanbanDef.column_field]: targetColumn.originalValue, - }); cleanup(); @@ -292,9 +278,13 @@ const KanbanBoardComponent = ( if (targetColumnRef) { targetColumnRef.refresh(); } - } catch (err) { - updateRecord(recordId, originalRecord); + const sourceColumnRef = + columnRefsRef.current[sourceColumnNormalized.id]; + if (sourceColumnRef) { + sourceColumnRef.refresh(); + } + } catch (err) { if (onDragSuccess) { onDragSuccess(targetColumn.id, sourceColumnNormalized.id); } @@ -311,7 +301,6 @@ const KanbanBoardComponent = ( onDragSuccess, executeColumnChange, findColumnByValue, - updateRecord, t, ], ); @@ -319,9 +308,9 @@ const KanbanBoardComponent = ( useImperativeHandle( ref, () => ({ - updateRecord, + refreshAllColumns, }), - [updateRecord], + [refreshAllColumns], ); const handleColumnRef = useCallback( diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index f34683e2c..b23eb28ae 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -6,7 +6,6 @@ import { useEffect, useRef, useCallback, - useState, } from "react"; import { useDeepCompareEffect } from "use-deep-compare"; import { Badge, Button, Space, theme, Typography } from "antd"; @@ -28,7 +27,6 @@ const { useToken } = theme; export type KanbanColumnRef = { refresh: () => void; - updateRecord: (id: number, updatedValues: Partial) => void; }; type KanbanColumnProps = { @@ -88,7 +86,7 @@ const KanbanColumnComponent = ( const { token } = useToken(); const { - records: hookRecords, + records, count, aggregates, colorsForRecords, @@ -111,71 +109,12 @@ const KanbanColumnComponent = ( kanbanDef, }); - const [localRecords, setLocalRecords] = useState(hookRecords); - - useDeepCompareEffect(() => { - setLocalRecords(hookRecords); - }, [hookRecords]); - - const updateRecord = useCallback( - (id: number, updatedValues: Partial) => { - setLocalRecords((prevRecords) => { - const existingIndex = prevRecords.findIndex((r) => r.id === id); - const existingRecord = prevRecords[existingIndex]; - - const updatedRecord = existingRecord - ? { ...existingRecord, ...updatedValues } - : ({ id, ...updatedValues } as KanbanRecord); - - const recordColumnValue = updatedRecord[kanbanDef.column_field]; - - const shouldBeInThisColumn = (() => { - if ( - Array.isArray(columnOriginalValue) && - columnOriginalValue.length === 2 - ) { - if ( - Array.isArray(recordColumnValue) && - recordColumnValue.length === 2 - ) { - return recordColumnValue[0] === columnOriginalValue[0]; - } - return recordColumnValue === columnOriginalValue[0]; - } - - if ( - Array.isArray(recordColumnValue) && - recordColumnValue.length === 2 - ) { - return recordColumnValue[0] === columnOriginalValue; - } - - return recordColumnValue === columnOriginalValue; - })(); - - if (shouldBeInThisColumn) { - if (existingRecord) { - const updated = [...prevRecords]; - updated[existingIndex] = updatedRecord; - return updated; - } else { - return [updatedRecord, ...prevRecords]; - } - } else { - return prevRecords.filter((r) => r.id !== id); - } - }); - }, - [kanbanDef.column_field, columnOriginalValue], - ); - useImperativeHandle( ref, () => ({ refresh, - updateRecord, }), - [refresh, updateRecord], + [refresh], ); // Report count changes to parent @@ -185,19 +124,16 @@ const KanbanColumnComponent = ( // Report records updates to parent (for drag overlay) useDeepCompareEffect(() => { - if (onRecordsUpdate && localRecords.length > 0) { - onRecordsUpdate(localRecords, colorsForRecords, statusForRecords); + if (onRecordsUpdate && records.length > 0) { + onRecordsUpdate(records, colorsForRecords, statusForRecords); } - }, [localRecords, colorsForRecords, statusForRecords, onRecordsUpdate]); + }, [records, colorsForRecords, statusForRecords, onRecordsUpdate]); const { setNodeRef } = useDroppable({ id: columnId, }); - const recordIds = useMemo( - () => localRecords.map((r) => r.id), - [localRecords], - ); + const recordIds = useMemo(() => records.map((r) => r.id), [records]); const isOverLimit = maxCards !== undefined && count > maxCards; @@ -211,17 +147,15 @@ const KanbanColumnComponent = ( const cardClickHandlers = useMemo(() => { if (!onCardClick) return {}; - return localRecords.reduce void>>((acc, record) => { + return records.reduce void>>((acc, record) => { acc[record.id] = () => onCardClick(record); return acc; }, {}); - }, [localRecords, onCardClick]); + }, [records, onCardClick]); const hasStatusRibbon = useMemo(() => { - return localRecords.some( - (record) => statusForRecords?.current?.[record.id], - ); - }, [localRecords, statusForRecords]); + return records.some((record) => statusForRecords?.current?.[record.id]); + }, [records, statusForRecords]); const scrollContainerRef = useRef(null); @@ -245,7 +179,7 @@ const KanbanColumnComponent = ( }, [kanbanDef.card_fields.length, kanbanDef.buttons.length]); const virtualizer = useVirtualizer({ - count: localRecords.length, + count: records.length, getScrollElement: () => scrollContainerRef.current, estimateSize: useCallback(() => estimatedCardHeight, [estimatedCardHeight]), overscan: 5, @@ -260,20 +194,10 @@ const KanbanColumnComponent = ( return; } - if ( - lastItem.index >= localRecords.length - 5 && - hasMore && - !isLoadingMore - ) { + if (lastItem.index >= records.length - 5 && hasMore && !isLoadingMore) { fetchNextPage(); } - }, [ - virtualItems, - localRecords.length, - hasMore, - isLoadingMore, - fetchNextPage, - ]); + }, [virtualItems, records.length, hasMore, isLoadingMore, fetchNextPage]); if (count === 0 && !kanbanDef.drag) { return null; @@ -389,7 +313,7 @@ const KanbanColumnComponent = ( }} > {virtualItems.map((virtualRow) => { - const record = localRecords[virtualRow.index]; + const record = records[virtualRow.index]; return (
)} - {localRecords.length === 0 && !isLoading && ( + {records.length === 0 && !isLoading && (
Date: Tue, 18 Nov 2025 13:40:23 +0100 Subject: [PATCH 38/41] feat: more improvements --- src/widgets/views/Kanban/Kanban.tsx | 68 ------ src/widgets/views/Kanban/KanbanBoard.tsx | 58 +++-- src/widgets/views/Kanban/KanbanCard.styles.ts | 44 ++++ src/widgets/views/Kanban/KanbanCard.tsx | 207 +++++++----------- src/widgets/views/Kanban/KanbanColumn.tsx | 11 +- 5 files changed, 165 insertions(+), 223 deletions(-) create mode 100644 src/widgets/views/Kanban/KanbanCard.styles.ts diff --git a/src/widgets/views/Kanban/Kanban.tsx b/src/widgets/views/Kanban/Kanban.tsx index facf08772..cfc55017b 100644 --- a/src/widgets/views/Kanban/Kanban.tsx +++ b/src/widgets/views/Kanban/Kanban.tsx @@ -18,7 +18,6 @@ import { useKanbanColumns } from "./useKanbanColumns"; import { Alert, Spin } from "antd"; import { useLocale } from "@gisce/react-formiga-components"; import { KanbanColumnRef } from "./KanbanColumn"; -import { normalizeColumnValue } from "@/helpers/kanbanHelper"; type KanbanProps = { kanbanView: KanbanView; @@ -161,71 +160,6 @@ const KanbanComponentInner = ( [refreshColumns], ); - const handleButtonClick = useCallback( - async ( - _buttonName: string, - _recordId: number, - oldRecord: KanbanRecord, - newRecord?: KanbanRecord, - ) => { - const columnField = kanbanDef?.column_field; - - if (newRecord && columnField) { - const oldColumnValue = oldRecord[columnField]; - const newColumnValue = newRecord[columnField]; - - if (newColumnValue !== undefined && oldColumnValue !== newColumnValue) { - const columnFieldDef = kanbanDef?.fields?.[columnField]; - - if (columnFieldDef) { - const oldColumnInfo = normalizeColumnValue( - oldColumnValue, - columnFieldDef, - t, - ); - const newColumnInfo = normalizeColumnValue( - newColumnValue, - columnFieldDef, - t, - ); - - const oldColumnId = oldColumnInfo?.id ?? null; - const newColumnId = newColumnInfo?.id ?? null; - - if (oldColumnId && newColumnId) { - const columnsToRefresh = - oldColumnId === newColumnId - ? [oldColumnId] - : [oldColumnId, newColumnId]; - refreshColumns(columnsToRefresh); - return; - } - } - } else { - // Column value didn't change, just refresh the current column - const columnFieldDef = kanbanDef?.fields?.[columnField]; - if (columnFieldDef) { - const columnInfo = normalizeColumnValue( - oldColumnValue, - columnFieldDef, - t, - ); - if (columnInfo?.id) { - refreshColumns([columnInfo.id]); - return; - } - } - } - } - - // Fallback: refresh all columns - columnRefs.current.forEach((ref) => { - ref.refresh(); - }); - }, - [kanbanDef, t, refreshColumns], - ); - const setColumnRef = useCallback( (columnId: string, ref: KanbanColumnRef | null) => { if (ref) { @@ -302,7 +236,6 @@ const KanbanComponentInner = ( nameSearch={nameSearch} fieldsToRetrieve={fieldsToRetrieve} onCardClick={onCardClick} - onButtonClick={handleButtonClick} setColumnRef={setColumnRef} onColumnCountChange={handleColumnCountChange} onAddCardClick={onAddCardClick} @@ -322,7 +255,6 @@ const KanbanComponentInner = ( nameSearch, fieldsToRetrieve, onCardClick, - handleButtonClick, setColumnRef, handleColumnCountChange, onAddCardClick, diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index afe7c887d..5240c39e7 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -27,6 +27,7 @@ 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; @@ -42,12 +43,6 @@ type KanbanBoardProps = { fieldsToRetrieve?: string[]; kanbanDef: Kanban; onCardClick?: (record: KanbanRecord) => void; - onButtonClick?: ( - buttonName: string, - recordId: number, - oldRecord: KanbanRecord, - newRecord?: KanbanRecord, - ) => void; setColumnRef: (columnId: string, ref: KanbanColumnRef | null) => void; onColumnCountChange: (columnId: string, count: number) => void; onAddCardClick?: (column: ColumnDefinition) => void; @@ -68,7 +63,6 @@ const KanbanBoardComponent = ( fieldsToRetrieve, kanbanDef, onCardClick, - onButtonClick, setColumnRef, onColumnCountChange, onAddCardClick, @@ -83,7 +77,6 @@ const KanbanBoardComponent = ( 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, ); @@ -186,6 +179,29 @@ const KanbanBoardComponent = ( }); }, []); + 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; @@ -258,7 +274,7 @@ const KanbanBoardComponent = ( const methodName = kanbanDef.on_change_column?.method || "on_change_column"; - await executeColumnChange({ + const result = await executeColumnChange({ model, action: methodName, payload: [ @@ -274,16 +290,20 @@ const KanbanBoardComponent = ( ], }); - const targetColumnRef = columnRefsRef.current[targetColumn.id]; - if (targetColumnRef) { - targetColumnRef.refresh(); + if (result && typeof result === "object" && result.type) { + await runAction({ + actionData: result, + // additionalContext: { + // active_id: recordId, + // active_ids: [recordId], + // }, + // overrideValues: record, + // overrideFields: kanbanDef?.fields || {}, + }); + return; } - const sourceColumnRef = - columnRefsRef.current[sourceColumnNormalized.id]; - if (sourceColumnRef) { - sourceColumnRef.refresh(); - } + refreshSourceAndTarget(sourceColumnNormalized.id, targetColumn.id); } catch (err) { if (onDragSuccess) { onDragSuccess(targetColumn.id, sourceColumnNormalized.id); @@ -302,6 +322,8 @@ const KanbanBoardComponent = ( executeColumnChange, findColumnByValue, t, + runAction, + refreshSourceAndTarget, ], ); @@ -392,11 +414,11 @@ const KanbanBoardComponent = ( fieldsToRetrieve={fieldsToRetrieve} allowSetMaxCards={false} onCardClick={onCardClick} - onButtonClick={onButtonClick} onCountChange={onColumnCountChange} onRecordsUpdate={handleRecordsUpdate} isOver={overColumnId === column.id} onAddCardClick={columnAddCardCallbacks[column.id]} + onRefreshAll={refreshAllColumns} /> ))}
diff --git a/src/widgets/views/Kanban/KanbanCard.styles.ts b/src/widgets/views/Kanban/KanbanCard.styles.ts new file mode 100644 index 000000000..145092d3c --- /dev/null +++ b/src/widgets/views/Kanban/KanbanCard.styles.ts @@ -0,0 +1,44 @@ +import { Card as AntCard } from "antd"; +import styled from "styled-components"; + +export const StyledCard = styled(AntCard)<{ + $bgColor: string; + $borderColor: string; + $primaryColor: string; + $color?: string; +}>` + 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: 3px solid ${(props) => 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}; +`; diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index e90e8b14b..450e0dac0 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -1,7 +1,6 @@ -import { memo, useState, useCallback, MouseEvent } from "react"; -import { Card as AntCard, Button, Space, Typography, theme } from "antd"; +import { memo, useState, useCallback, MouseEvent, useEffect } from "react"; +import { Button, Space, Typography, theme } from "antd"; import { useSortable } from "@dnd-kit/sortable"; -import styled from "styled-components"; import { useDeepCompareMemo } from "use-deep-compare"; import { KanbanRecord } from "./types"; import { @@ -11,56 +10,15 @@ import { } 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 } from "./KanbanCard.styles"; const { Text } = Typography; const { useToken } = theme; -const CardWrapper = styled.div``; - -const StyledCard = styled(AntCard)<{ - $bgColor: string; - $borderColor: string; - $primaryColor: string; - $color?: string; -}>` - 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: 3px solid ${(props) => props.$color || props.$primaryColor}; - } -`; - -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; -`; - -const StatusDot = styled.div<{ $color: string }>` - position: absolute; - right: 8px; - top: 8px; - width: 10px; - height: 10px; - border-radius: 50%; - background-color: ${(props) => props.$color}; -`; - type KanbanCardProps = { record: KanbanRecord; kanbanDef: Kanban; @@ -70,12 +28,7 @@ type KanbanCardProps = { status?: string; context?: any; onClick?: () => void; - onButtonClick?: ( - buttonName: string, - recordId: number, - oldRecord: KanbanRecord, - newRecord?: KanbanRecord, - ) => void; + onRefreshAll?: () => void; isMoving?: boolean; }; @@ -89,13 +42,33 @@ const KanbanCardComponent = (props: KanbanCardProps) => { status, context = {}, onClick, - onButtonClick, + onRefreshAll, isMoving = false, } = 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, @@ -217,7 +190,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { try { if (button.buttonType === "object") { - await ConnectionProvider.getHandler().execute({ + const result = await executeButton({ model, action: button.id, payload: [record.id], @@ -228,37 +201,14 @@ const KanbanCardComponent = (props: KanbanCardProps) => { }, }); - let newRecord: KanbanRecord | undefined; - - try { - const fieldsObject = kanbanDef?.fields - ? Object.keys(kanbanDef.fields).reduce( - (acc: any, fieldName: string) => { - acc[fieldName] = kanbanDef.fields[fieldName]; - return acc; - }, - {}, - ) - : {}; - - const updatedRecords = - await ConnectionProvider.getHandler().readObjects({ - model, - ids: [record.id], - fields: fieldsObject, - context, - }); - - if (updatedRecords && updatedRecords.length > 0) { - newRecord = updatedRecords[0]; - } - } catch (readErr) { - console.warn("Failed to fetch updated record:", readErr); + if (result && typeof result === "object" && result.type) { + await runAction({ + actionData: result, + }); + return; } - if (onButtonClick) { - onButtonClick(button.id, record.id, record, newRecord); - } + onRefreshAll?.(); } } catch (err) { showErrorNotification(err); @@ -271,9 +221,10 @@ const KanbanCardComponent = (props: KanbanCardProps) => { model, record, context, - onButtonClick, + executeButton, + runAction, + onRefreshAll, showErrorNotification, - kanbanDef, ], ); @@ -288,50 +239,48 @@ const KanbanCardComponent = (props: KanbanCardProps) => { }, [visibleButtons, handleButtonClick]); return ( - -
- - {color && } - {status && } -
- {visibleFields.map((field: any) => renderField(field))} -
+
+ + {color && } + {status && } +
+ {visibleFields.map((field: any) => renderField(field))} +
- {visibleButtons.length > 0 && ( - - {visibleButtons.map((button: ButtonOoui) => ( - - ))} - - )} -
-
- + {visibleButtons.length > 0 && ( + + {visibleButtons.map((button: ButtonOoui) => ( + + ))} + + )} +
+
); }; diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index b23eb28ae..b77de45fa 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -42,16 +42,11 @@ type KanbanColumnProps = { maxCards?: number; isOver?: boolean; onCardClick?: (record: KanbanRecord) => void; - onButtonClick?: ( - buttonName: string, - recordId: number, - oldRecord: KanbanRecord, - newRecord?: KanbanRecord, - ) => void; onMaxCardsChange?: (colId: string, maxCards: number | undefined) => void; onCountChange: (columnId: string, count: number) => void; onRecordsUpdate?: (records: KanbanRecord[], colors: any, status: any) => void; onAddCardClick?: () => void; + onRefreshAll?: () => void; }; const KanbanColumnComponent = ( @@ -70,10 +65,10 @@ const KanbanColumnComponent = ( maxCards, isOver = false, onCardClick, - onButtonClick, onCountChange, onRecordsUpdate, onAddCardClick, + onRefreshAll, } = props; const { @@ -337,7 +332,7 @@ const KanbanColumnComponent = ( context={context} model={model} onClick={cardClickHandlers[record.id]} - onButtonClick={onButtonClick} + onRefreshAll={onRefreshAll} />
); From f4ad31fb7a4011324efd10f941fd0b7d23ccacce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Wed, 19 Nov 2025 16:23:00 +0100 Subject: [PATCH 39/41] fix: column hover --- src/widgets/views/Kanban/KanbanBoard.tsx | 9 +++++++++ src/widgets/views/Kanban/KanbanCard.tsx | 3 +++ src/widgets/views/Kanban/KanbanColumn.tsx | 1 + 3 files changed, 13 insertions(+) diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index 5240c39e7..a7246ee39 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -129,12 +129,21 @@ const KanbanBoardComponent = ( return; } + // First, check if hovering over a column directly const overColumn = columns.find((col) => col.id === over.id); if (overColumn) { setOverColumnId(overColumn.id); 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) { diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index 450e0dac0..4f655536d 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -30,6 +30,7 @@ type KanbanCardProps = { onClick?: () => void; onRefreshAll?: () => void; isMoving?: boolean; + columnId?: string; }; const KanbanCardComponent = (props: KanbanCardProps) => { @@ -44,6 +45,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { onClick, onRefreshAll, isMoving = false, + columnId, } = props; const { token } = useToken(); const [loadingButton, setLoadingButton] = useState(null); @@ -72,6 +74,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { const { attributes, listeners, setNodeRef, isDragging } = useSortable({ id: record.id, disabled: !draggable, + data: { columnId }, }); const style = { diff --git a/src/widgets/views/Kanban/KanbanColumn.tsx b/src/widgets/views/Kanban/KanbanColumn.tsx index b77de45fa..0a5e4364a 100644 --- a/src/widgets/views/Kanban/KanbanColumn.tsx +++ b/src/widgets/views/Kanban/KanbanColumn.tsx @@ -333,6 +333,7 @@ const KanbanColumnComponent = ( model={model} onClick={cardClickHandlers[record.id]} onRefreshAll={onRefreshAll} + columnId={columnId} />
); From 681e9ed40f0e2f43dc356c54df501c8edbe76a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Wed, 19 Nov 2025 16:25:57 +0100 Subject: [PATCH 40/41] fix: overlay card with status dot + color and base for drop indicator --- src/widgets/views/Kanban/KanbanBoard.tsx | 31 +++++++++++++++---- src/widgets/views/Kanban/KanbanCard.styles.ts | 8 +++++ src/widgets/views/Kanban/KanbanCard.tsx | 14 ++++++++- src/widgets/views/Kanban/KanbanColumn.tsx | 19 +++++++++--- .../views/Kanban/useKanbanColumnData.ts | 24 ++++++++------ 5 files changed, 74 insertions(+), 22 deletions(-) diff --git a/src/widgets/views/Kanban/KanbanBoard.tsx b/src/widgets/views/Kanban/KanbanBoard.tsx index a7246ee39..7ccbe2692 100644 --- a/src/widgets/views/Kanban/KanbanBoard.tsx +++ b/src/widgets/views/Kanban/KanbanBoard.tsx @@ -17,6 +17,7 @@ import { PointerSensor, useSensor, useSensors, + closestCenter, } from "@dnd-kit/core"; import { KanbanColumn, KanbanColumnRef } from "./KanbanColumn"; import { KanbanCard } from "./KanbanCard"; @@ -73,6 +74,7 @@ const KanbanBoardComponent = ( 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 }>({}); @@ -123,9 +125,10 @@ const KanbanBoardComponent = ( const handleDragOver = useCallback( (event: DragOverEvent) => { - const { over } = event; + const { over, active } = event; if (!over) { setOverColumnId(null); + setOverId(null); return; } @@ -133,6 +136,7 @@ const KanbanBoardComponent = ( const overColumn = columns.find((col) => col.id === over.id); if (overColumn) { setOverColumnId(overColumn.id); + setOverId(null); return; } @@ -151,11 +155,17 @@ const KanbanBoardComponent = ( 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], ); @@ -163,17 +173,22 @@ const KanbanBoardComponent = ( const handleDragCancel = useCallback(() => { setActiveRecord(null); setOverColumnId(null); + setOverId(null); }, []); const handleRecordsUpdate = useCallback( - (records: KanbanRecord[], colors: any, status: any) => { + ( + records: KanbanRecord[], + colors: { [key: number]: string }, + status: { [key: number]: string }, + ) => { records.forEach((record) => { allRecordsRef.current[record.id] = record; - if (colors?.current?.[record.id]) { - colorsForRecordsRef.current[record.id] = colors.current[record.id]; + if (colors?.[record.id]) { + colorsForRecordsRef.current[record.id] = colors[record.id]; } - if (status?.current?.[record.id]) { - statusForRecordsRef.current[record.id] = status.current[record.id]; + if (status?.[record.id]) { + statusForRecordsRef.current[record.id] = status[record.id]; } }); }, @@ -218,6 +233,7 @@ const KanbanBoardComponent = ( const cleanup = () => { setActiveRecord(null); setOverColumnId(null); + setOverId(null); }; if (!over) { @@ -395,6 +411,7 @@ const KanbanBoardComponent = ( return ( ))}
diff --git a/src/widgets/views/Kanban/KanbanCard.styles.ts b/src/widgets/views/Kanban/KanbanCard.styles.ts index 145092d3c..55f403762 100644 --- a/src/widgets/views/Kanban/KanbanCard.styles.ts +++ b/src/widgets/views/Kanban/KanbanCard.styles.ts @@ -42,3 +42,11 @@ export const StatusDot = styled.div<{ $color: string }>` 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 index 4f655536d..cf38f2841 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -14,7 +14,12 @@ 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 } from "./KanbanCard.styles"; +import { + StyledCard, + ColorBar, + StatusDot, + DropIndicator, +} from "./KanbanCard.styles"; const { Text } = Typography; const { useToken } = theme; @@ -30,6 +35,8 @@ type KanbanCardProps = { onClick?: () => void; onRefreshAll?: () => void; isMoving?: boolean; + isDropTarget?: boolean; + activeId?: number | null; columnId?: string; }; @@ -46,6 +53,8 @@ const KanbanCardComponent = (props: KanbanCardProps) => { onRefreshAll, isMoving = false, columnId, + isDropTarget = false, + activeId = null, } = props; const { token } = useToken(); const [loadingButton, setLoadingButton] = useState(null); @@ -241,8 +250,11 @@ const KanbanCardComponent = (props: KanbanCardProps) => { ); }, [visibleButtons, handleButtonClick]); + const showDropIndicator = isDropTarget && activeId !== null; + return (
+ {showDropIndicator && } void; onMaxCardsChange?: (colId: string, maxCards: number | undefined) => void; onCountChange: (columnId: string, count: number) => void; - onRecordsUpdate?: (records: KanbanRecord[], colors: any, status: any) => 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 = ( @@ -69,6 +75,8 @@ const KanbanColumnComponent = ( onRecordsUpdate, onAddCardClick, onRefreshAll, + activeId = null, + overId = null, } = props; const { @@ -149,7 +157,7 @@ const KanbanColumnComponent = ( }, [records, onCardClick]); const hasStatusRibbon = useMemo(() => { - return records.some((record) => statusForRecords?.current?.[record.id]); + return records.some((record) => statusForRecords?.[record.id]); }, [records, statusForRecords]); const scrollContainerRef = useRef(null); @@ -298,7 +306,6 @@ const KanbanColumnComponent = (
); diff --git a/src/widgets/views/Kanban/useKanbanColumnData.ts b/src/widgets/views/Kanban/useKanbanColumnData.ts index e334094c6..b75d22193 100644 --- a/src/widgets/views/Kanban/useKanbanColumnData.ts +++ b/src/widgets/views/Kanban/useKanbanColumnData.ts @@ -51,8 +51,12 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { const [currentOffset, setCurrentOffset] = useState(0); const [hasMore, setHasMore] = useState(true); const [totalCount, setTotalCount] = useState(0); - const colorsForRecords = useRef<{ [key: number]: string }>({}); - const statusForRecords = useRef<{ [key: number]: string }>({}); + const [colorsForRecords, setColorsForRecords] = useState<{ + [key: number]: string; + }>({}); + const [statusForRecords, setStatusForRecords] = useState<{ + [key: number]: string; + }>({}); const PAGE_SIZE = 30; @@ -264,17 +268,17 @@ export const useKanbanColumnData = (params: UseKanbanColumnDataParams) => { }); if (isLoadingNextPage) { - colorsForRecords.current = { - ...colorsForRecords.current, + setColorsForRecords((prev) => ({ + ...prev, ...newColors, - }; - statusForRecords.current = { - ...statusForRecords.current, + })); + setStatusForRecords((prev) => ({ + ...prev, ...newStatus, - }; + })); } else { - colorsForRecords.current = newColors; - statusForRecords.current = newStatus; + setColorsForRecords(newColors); + setStatusForRecords(newStatus); } } } catch (err: any) { From fc4e83dbbb2093f729d99c50e8cc95bfd6bcfaff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Gu=CC=88ell=20Segarra?= Date: Wed, 19 Nov 2025 16:29:53 +0100 Subject: [PATCH 41/41] fix: hover card issue --- src/widgets/views/Kanban/KanbanCard.styles.ts | 6 +++++- src/widgets/views/Kanban/KanbanCard.tsx | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/widgets/views/Kanban/KanbanCard.styles.ts b/src/widgets/views/Kanban/KanbanCard.styles.ts index 55f403762..ebb1ac9df 100644 --- a/src/widgets/views/Kanban/KanbanCard.styles.ts +++ b/src/widgets/views/Kanban/KanbanCard.styles.ts @@ -6,6 +6,7 @@ export const StyledCard = styled(AntCard)<{ $borderColor: string; $primaryColor: string; $color?: string; + $isDraggingActive?: boolean; }>` position: relative; background-color: ${(props) => props.$bgColor}; @@ -19,7 +20,10 @@ export const StyledCard = styled(AntCard)<{ } &:hover { - outline: 3px solid ${(props) => props.$color || props.$primaryColor}; + outline: ${(props) => + props.$isDraggingActive + ? "none" + : `3px solid ${props.$color || props.$primaryColor}`}; } `; diff --git a/src/widgets/views/Kanban/KanbanCard.tsx b/src/widgets/views/Kanban/KanbanCard.tsx index cf38f2841..523688be3 100644 --- a/src/widgets/views/Kanban/KanbanCard.tsx +++ b/src/widgets/views/Kanban/KanbanCard.tsx @@ -262,6 +262,7 @@ const KanbanCardComponent = (props: KanbanCardProps) => { $borderColor={token.colorBorder} $primaryColor={token.colorPrimary} $color={color} + $isDraggingActive={activeId !== null} styles={{ body: { padding: "12px",