diff --git a/docs/pages/01_ui_react.adoc b/docs/pages/01_ui_react.adoc index 1b29184f..20ee00df 100644 --- a/docs/pages/01_ui_react.adoc +++ b/docs/pages/01_ui_react.adoc @@ -789,6 +789,111 @@ const SidekickGalaxiesComponent: FC> = }; ---- +=== Implementing Card Containers for Tables with Card representations + +We can model tables to represent cards. In case we did so, by default we will get a simplistic +representation of cards. To specify how each card should look like (and other aspects as wel), we +can register a configuration. + +The interface key for these hooks is the unique name of the table plus "_CARDS_CONTAINER_CONFIG_HOOK_INTERFACE_KEY". + +*src/custom/application-customizer.tsx:* +[source,typescriptjsx] +---- +import type { BundleContext } from '@pandino/pandino-api'; +import type { ApplicationCustomizer } from './interfaces'; +import { + VIEW_GALAXY_TABLE_TABLE_COMPONENT_CARDS_CONTAINER_CONFIG_HOOK_INTERFACE_KEY, + ViewGalaxyTableTableComponentCardsContainerConfigHook, +} from '~/containers/View/Galaxy/Table/components/ViewGalaxyTableTableComponent/customization'; +import {CardProps, ToolbarElementProps } from '~/components-api/components/CardsContainer'; +import {ViewGalaxy, ViewGalaxyStored } from '~/services/data-api/model/ViewGalaxy'; +import {CardsFilter, CardsFilterDefinition } from '~/components/widgets/CardsFilter'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; +import CardActions from '@mui/material/CardActions'; +import Button from '@mui/material/Button'; +import Grid from '@mui/material/Grid'; +import { Filter, FilterType } from '~/components-api'; +import { _BooleanOperation, _StringOperation } from '~/services/data-api/common'; +import { mapCardsFiltersToFilters } from '~/utilities'; + +export class DefaultApplicationCustomizer implements ApplicationCustomizer { + async customize(context: BundleContext): Promise { + context.registerService( + VIEW_GALAXY_TABLE_TABLE_COMPONENT_CARDS_CONTAINER_CONFIG_HOOK_INTERFACE_KEY, + cardsConfigHook + ); + } +} + +const cardsConfigHook: ViewGalaxyTableTableComponentCardsContainerConfigHook = () => { + // We are returning a full configuration, but all of the attributes below are optional + return { + layout: 'horizontal', + ToolbarElement: CustomToolbar, // a valid React component implementing FC> + CardElement: CustomCard, // a valid React component implementing FC> + }; +}; + +function CustomToolbar({ handleFiltersChange }: ToolbarElementProps) { + // We can call other hooks here + + const filterDefs: CardsFilterDefinition[] = [ + { + type: FilterType.boolean, field: 'nakedEye', label: 'Naked Eye', + values: [ + { value: true, label: 'Yes' }, + { value: false, label: 'No' } + ], + }, + { + type: FilterType.string, field: 'constellation', label: 'Constellation', + values: [ + { value: 'Andromeda', label: 'Andromeda' }, + { value: 'Corvus', label: 'Corvus' }, + { value: 'Ursa Major', label: 'Ursa Major', + }, + ], + }, + ]; + + // We are using a built in component CardsFilter here, but we can implement anything + return ( + ) => { + const newFilters: Filter[] = mapCardsFiltersToFilters(filterDefs, values); + handleFiltersChange(newFilters); + }} /> + ); +} + +function CustomCard({ row, columns, onRowClick }: CardProps) { + // We can call other hooks here + + // Every custom Card component MUST be wrapped in a ` + + + {columns.map((k, idx) => ( + + {row[k.field as keyof ViewGalaxyStored]?.toString()} + + ))} + + + + + + + ); +} +---- + === Implementing container actions Every container has a set of Actions. These are typically actions triggered by buttons, or visual lifecycle calculated diff --git a/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiPageContainerHelper.java b/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiPageContainerHelper.java index 1516a495..c34b4648 100644 --- a/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiPageContainerHelper.java +++ b/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiPageContainerHelper.java @@ -65,6 +65,12 @@ public static List getTagsForPageContainers(Application application) { .collect(Collectors.toList()); } + public static List
getCardsForPageContainers(Application application) { + return application.getPageContainers().stream().flatMap(c -> ((List
) c.getTables()).stream()) + .filter(UiTableHelper::isTableCard) + .collect(Collectors.toList()); + } + public static String pageContainerActionDefinitionsName(PageContainer pageContainer) { return containerComponentName(pageContainer) + "ActionDefinitions"; } diff --git a/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiTableHelper.java b/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiTableHelper.java index 0443eb20..4cca7218 100644 --- a/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiTableHelper.java +++ b/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiTableHelper.java @@ -310,4 +310,8 @@ public static boolean checkboxSelectionEnabled(Table table) { public static boolean isTableTag(Table table) { return TableRepresentation.TAG.equals(table.getRepresentationComponent()); } + + public static boolean isTableCard(Table table) { + return TableRepresentation.CARD.equals(table.getRepresentationComponent()); + } } diff --git a/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiWidgetHelper.java b/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiWidgetHelper.java index 7f65eaea..cb389c66 100644 --- a/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiWidgetHelper.java +++ b/judo-ui-react/src/main/java/hu/blackbelt/judo/ui/generator/react/UiWidgetHelper.java @@ -207,6 +207,10 @@ public static String tagComponentName(Table table) { return tableComponentName(table); } + public static String cardsComponentName(Table table) { + return tableComponentName(table); + } + public static Column getFirstAutocompleteColumnForLink(Link link) { Optional column = link.getParts().stream() .filter(c -> c.getAttributeType().getDataType() instanceof StringType && !c.getAttributeType().getIsMemberTypeTransient()) diff --git a/judo-ui-react/src/main/resources/actor/src/components-api/components/CardsContainer.ts.hbs b/judo-ui-react/src/main/resources/actor/src/components-api/components/CardsContainer.ts.hbs new file mode 100644 index 00000000..a9917040 --- /dev/null +++ b/judo-ui-react/src/main/resources/actor/src/components-api/components/CardsContainer.ts.hbs @@ -0,0 +1,23 @@ +import type { + GridColDef, + GridValidRowModel, + GridSortModel, +} from '@mui/x-data-grid{{ getMUIDataGridPlanSuffix }}'; +import type { Filter, FilterOption } from '~/components-api'; + +export interface CardProps { + row: T; + columns: GridColDef[]; + onRowClick?: (row: T) => void; +} + +export interface ToolbarElementProps { + columns: GridColDef[], + filterOptions: FilterOption[]; + filters: Filter[]; + handleFiltersChange: (newFilters: Filter[]) => void, + sortModel: GridSortModel, + handleSortModelChange: (newModel: GridSortModel) => void, + refresh: () => Promise, + isLoading: boolean, +} diff --git a/judo-ui-react/src/main/resources/actor/src/components-api/components/index.ts.hbs b/judo-ui-react/src/main/resources/actor/src/components-api/components/index.ts.hbs index 37fc494c..e74f780e 100644 --- a/judo-ui-react/src/main/resources/actor/src/components-api/components/index.ts.hbs +++ b/judo-ui-react/src/main/resources/actor/src/components-api/components/index.ts.hbs @@ -3,6 +3,7 @@ export * from './Action'; export * from './ActionGroup'; export * from './Button'; +export * from './CardsContainer'; export * from './LabeledElement'; export * from './MenuTree'; export * from './NamedElement'; diff --git a/judo-ui-react/src/main/resources/actor/src/components/widgets/CardsContainer.tsx.hbs b/judo-ui-react/src/main/resources/actor/src/components/widgets/CardsContainer.tsx.hbs new file mode 100644 index 00000000..07f1431a --- /dev/null +++ b/judo-ui-react/src/main/resources/actor/src/components/widgets/CardsContainer.tsx.hbs @@ -0,0 +1,257 @@ +{{> fragment.header.hbs }} + +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import TablePagination from '@mui/material/TablePagination'; +import type { + GridColDef, + GridEventListener, + GridFilterModel, + GridRowModel, + GridSortModel, + GridValidRowModel, +} from '@mui/x-data-grid{{ getMUIDataGridPlanSuffix }}'; +import { useEffect, useMemo, useState, type FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { Filter, FilterOption } from '~/components-api'; +import type { CardProps, ToolbarElementProps } from '~/components-api/components/CardsContainer'; +import { basePageSizeOptions } from '~/config'; +import { useL10N } from '~/l10n/l10n-context'; +import type { QueryCustomizer } from '~/services/data-api/common/QueryCustomizer'; +import type { FiltersSerializer } from '~/utilities'; +import { mapAllFiltersToQueryCustomizerProperties, processQueryCustomizer, useErrorHandler } from '~/utilities'; +import { type CardsFilterDefinition } from './CardsFilter'; +import { DefaultCard } from './DefaultCard'; + +export interface CardsContainerProps { + uniqueId: string; + defaultSortParams: GridSortModel; + pageLimit: number; + columns: GridColDef[]; + filterOptions: FilterOption[]; + isOwnerLoading?: boolean; + actions: any; + onRowClick?: GridEventListener<'rowClick'>; + maskAction: () => string; + fetch: any; + refreshCounter: number; + relationName: string; + filtersSerializer: FiltersSerializer; + layout?: 'horizontal' | 'vertical'; + ToolbarElement?: FC>; + CardElement?: FC>; +} + +export const CardsContainer = (props: CardsContainerProps) => { + const { + uniqueId, + defaultSortParams, + pageLimit, + columns, + filterOptions, + isOwnerLoading, + actions, + onRowClick, + maskAction, + fetch, + refreshCounter, + relationName, + filtersSerializer, + layout = 'horizontal', + ToolbarElement, + CardElement = DefaultCard, + } = props; + + const { locale: l10nLocale } = useL10N(); + const { t } = useTranslation(); + const handleError = useErrorHandler(); + + const [isInternalLoading, setIsInternalLoading] = useState(false); + const [data, setData] = useState[]>([]); + const [sortModel, setSortModel] = useState(defaultSortParams); + const [filterModel, setFilterModel] = useState({ items: [] }); + const [filters, setFilters] = useState([]); + const [rowsPerPage, setRowsPerPage] = useState(pageLimit); + const [paginationModel, setPaginationModel] = useState({ + pageSize: rowsPerPage, + page: 0, + }); + const [queryCustomizer, setQueryCustomizer] = useState>({ + _mask: maskAction(), + _seek: { + limit: rowsPerPage + 1, + }, + _orderBy: sortModel.length + ? sortModel.map((s) => ({ + attribute: s.field, + descending: s.sort === 'desc', + })) + : [], + ...mapAllFiltersToQueryCustomizerProperties(filters), + }); + + const [page, setPage] = useState(0); + const [rowCount, setRowCount] = useState(0); + const [lastItem, setLastItem] = useState(); + const [firstItem, setFirstItem] = useState(); + const [isNextButtonEnabled, setIsNextButtonEnabled] = useState(true); + + const isLoading = useMemo(() => isInternalLoading || !!isOwnerLoading, [isInternalLoading, isOwnerLoading]); + + const pageSizeOptions = useMemo(() => { + const opts: Set = new Set([rowsPerPage, ...basePageSizeOptions]); + return Array.from(opts.values()).sort((a, b) => a - b); + }, [rowsPerPage]); + + const handleFiltersChange = (newFilters: Filter[]) => { + setPage(0); + setFilters(newFilters); + + setQueryCustomizer((prevQueryCustomizer: QueryCustomizer) => { + // remove previous filter values, so that we can always start with a clean slate + for (const name of columns.map((c) => c.field)) { + delete (prevQueryCustomizer as any)[name]; + } + return { + ...prevQueryCustomizer, + _seek: { + limit: rowsPerPage + 1, + }, + ...mapAllFiltersToQueryCustomizerProperties(newFilters), + }; + }); + }; + + async function fetching() { + setIsInternalLoading(true); + + try { + const processedQueryCustomizer = { + ...processQueryCustomizer(queryCustomizer), + _mask: maskAction ? maskAction() : queryCustomizer._mask, + }; + const { data: res, headers } = await fetch!(processedQueryCustomizer); + + + if (res.length > rowsPerPage) { + setIsNextButtonEnabled(true); + res.pop(); + } else if (queryCustomizer._seek?.limit === rowsPerPage + 1) { + setIsNextButtonEnabled(false); + } + + setData(res); + setFirstItem(res[0]); + setLastItem(res[res.length - 1]); + setRowCount(res.length || 0); + } catch (error) { + handleError(error); + } finally { + setIsInternalLoading(false); + } + } + + async function fetchData() { + await fetching(); + } + + useEffect(() => { + fetchData(); + }, [queryCustomizer, refreshCounter]); + + function handleSortModelChange(newModel: GridSortModel) { + setPage(0); + setSortModel(newModel); + + const _orderBy = newModel.filter((m: any) => m.sort).map((m: any) => ({ + attribute: m.field, + descending: m.sort === 'desc', + })); + + setQueryCustomizer((prevQueryCustomizer) => { + const strippedQueryCustomizer: QueryCustomizer = { + ...prevQueryCustomizer + }; + if (!!strippedQueryCustomizer._seek) { + delete strippedQueryCustomizer._seek.lastItem; + } + // we need to reset _seek so that previous configuration is erased + return { + ...strippedQueryCustomizer, + _orderBy, + _seek: { + limit: rowsPerPage + 1, + }, + }; + }); + } + + async function handlePageChange(isNext: boolean) { + setQueryCustomizer((prevQueryCustomizer) => { + return { + ...prevQueryCustomizer, + _seek: { + limit: isNext ? rowsPerPage + 1 : rowsPerPage, + reverse: !isNext, + lastItem: isNext ? lastItem : firstItem, + }, + }; + }); + + setIsNextButtonEnabled(!isNext); + } + + const CardItem = useMemo>>(() => CardElement || DefaultCard, [CardElement]); + + return ( + + + {ToolbarElement ? + + : null} + + + {data.map((d) => ( + + ))} + + + + { + let isNext = true; + if (newPage < page) { + isNext = false; + } + setPage(newPage); + handlePageChange(isNext); + } } + rowsPerPage={rowsPerPage} + rowsPerPageOptions={pageSizeOptions} + labelDisplayedRows={({ from, to }) => + `${from}–${to}` + } + nextIconButtonProps={ { + disabled: !isNextButtonEnabled, + } } + backIconButtonProps={ { + disabled: page === 0, + } } + /> + + + + ); +}; diff --git a/judo-ui-react/src/main/resources/actor/src/components/widgets/CardsFilter.tsx.hbs b/judo-ui-react/src/main/resources/actor/src/components/widgets/CardsFilter.tsx.hbs new file mode 100644 index 00000000..0311de27 --- /dev/null +++ b/judo-ui-react/src/main/resources/actor/src/components/widgets/CardsFilter.tsx.hbs @@ -0,0 +1,71 @@ +{{> fragment.header.hbs }} + +import { type FC, useState, useCallback } from 'react'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import { FilterType } from '~/components-api'; +import { useL10N } from '~/l10n/l10n-context'; +import { useTranslation } from 'react-i18next'; + +export interface CardsFilterDefinition { + type: FilterType; + field: keyof T; + label: string; + values: { value: any, label: string }[]; +} + +export const CardsFilter: FC<{ filterDefinitions: CardsFilterDefinition[], onFiltersChanged?: (values: Record) => void }> = ({ filterDefinitions, onFiltersChanged }) => { + const { locale: l10nLocale } = useL10N(); + const { t } = useTranslation(); + + const [values, setValues] = useState>({}); + + const updateValue = useCallback((field: string, value: any) => { + const newValues = { + ...values, + [field]: values[field] === value ? null : value, + }; + setValues(newValues); + onFiltersChanged?.(newValues); + }, [values]); + + const clearFilters = useCallback(() => { + setValues({}); + onFiltersChanged?.({}); + }, [values]); + + return ( + + + + Filters + + + + {filterDefinitions.map(d => ( + + + {d.label}: + + + {d.values.map(v => ( + updateValue(d.field as string, v.value)} + />} + label={v.label} + /> + ))} + + + ))} + + ); +}; diff --git a/judo-ui-react/src/main/resources/actor/src/components/widgets/DefaultCard.tsx.hbs b/judo-ui-react/src/main/resources/actor/src/components/widgets/DefaultCard.tsx.hbs new file mode 100644 index 00000000..a3fab84b --- /dev/null +++ b/judo-ui-react/src/main/resources/actor/src/components/widgets/DefaultCard.tsx.hbs @@ -0,0 +1,59 @@ +{{> fragment.header.hbs }} + +import { type FC, useCallback } from 'react'; +import Grid from '@mui/material/Grid'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; +import CardActions from '@mui/material/CardActions'; +import Button from '@mui/material/Button'; +import type { + GridColDef, + GridEventListener, + GridValidRowModel, +} from '@mui/x-data-grid{{ getMUIDataGridPlanSuffix }}'; +import { useL10N } from '~/l10n/l10n-context'; +import { useTranslation } from 'react-i18next'; +import type { CardProps } from '~/components-api/components/CardsContainer'; + +export const DefaultCard: FC> = ({ onRowClick, row, columns }) => { + const { locale: l10nLocale } = useL10N(); + const { t } = useTranslation(); + + const formatValue = useCallback((value: any) => { + if (value instanceof Date) { + return new Intl.DateTimeFormat(l10nLocale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }).format(value); + } + if (value === undefined || value === null) { + return ''; + } + return value.toString(); + }, []); + + return ( + + + + {columns.map((k, idx) => ( + + {formatValue(row[k.field])} + + ))} + + + + + + + ); +}; diff --git a/judo-ui-react/src/main/resources/actor/src/components/widgets/DefaultToolbar.tsx.hbs b/judo-ui-react/src/main/resources/actor/src/components/widgets/DefaultToolbar.tsx.hbs new file mode 100644 index 00000000..ad255452 --- /dev/null +++ b/judo-ui-react/src/main/resources/actor/src/components/widgets/DefaultToolbar.tsx.hbs @@ -0,0 +1,21 @@ +import { type FC } from 'react'; +import type { ToolbarElementProps } from '~/components-api/components/CardsContainer'; +import { useL10N } from '~/l10n/l10n-context'; +import { useTranslation } from 'react-i18next'; + +export const DefaultToolbar: FC> = ({ + columns, + filters, + handleFiltersChange, + sortModel, + handleSortModelChange, + refresh, + isLoading, +}) => { + const { locale: l10nLocale } = useL10N(); + const { t } = useTranslation(); + + return ( +
{/* Default Toolbar */}
+ ); +}; diff --git a/judo-ui-react/src/main/resources/actor/src/components/widgets/index.tsx.hbs b/judo-ui-react/src/main/resources/actor/src/components/widgets/index.tsx.hbs index 7e831e15..5cb9cc6a 100644 --- a/judo-ui-react/src/main/resources/actor/src/components/widgets/index.tsx.hbs +++ b/judo-ui-react/src/main/resources/actor/src/components/widgets/index.tsx.hbs @@ -4,6 +4,9 @@ export * from './SingleRelationInput'; export * from './Tags'; export * from './AssociationButton'; export * from './BinaryInput'; +export * from './DefaultCard'; +export * from './CardsContainer'; +export * from './CardsFilter'; export * from './NumericInput'; export * from './TextWithTypeAhead'; export * from './TrinaryLogicCombobox'; diff --git a/judo-ui-react/src/main/resources/actor/src/containers/components/cards/customization.ts.hbs b/judo-ui-react/src/main/resources/actor/src/containers/components/cards/customization.ts.hbs new file mode 100644 index 00000000..e2766417 --- /dev/null +++ b/judo-ui-react/src/main/resources/actor/src/containers/components/cards/customization.ts.hbs @@ -0,0 +1,23 @@ +import type { FC } from 'react'; +import type { + GridSortModel, + GridColDef, +} from '@mui/x-data-grid{{ getMUIDataGridPlanSuffix }}'; +import type { Filter } from '~/components-api'; +{{# each (getTableAPIImports table container) as |imp| }} + import type { + {{ imp }}, + {{ imp }}Stored, + } from '~/services/data-api/model/{{ imp }}'; +{{/ each }} +import type { CardProps, ToolbarElementProps } from '~/components-api/components/CardsContainer'; +import type { {{ componentName table }}ActionDefinitions } from './types'; + +export interface {{ componentName table }}CardsContainerConfig { + layout?: 'horizontal' | 'vertical'; + ToolbarElement?: FC>, + CardElement?: FC> +} + +export const {{ camelCaseNameToInterfaceKey (componentName table) }}_CARDS_CONTAINER_CONFIG_HOOK_INTERFACE_KEY = '{{ camelCaseNameToInterfaceKey (componentName table) }}_CARDS_CONTAINER_CONFIG_HOOK'; +export type {{ componentName table }}CardsContainerConfigHook = () => {{ componentName table }}CardsContainerConfig; diff --git a/judo-ui-react/src/main/resources/actor/src/containers/components/cards/index.tsx.hbs b/judo-ui-react/src/main/resources/actor/src/containers/components/cards/index.tsx.hbs new file mode 100644 index 00000000..18637da1 --- /dev/null +++ b/judo-ui-react/src/main/resources/actor/src/containers/components/cards/index.tsx.hbs @@ -0,0 +1,203 @@ +{{> fragment.header.hbs }} + +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import type { ElementType, MouseEvent, Dispatch, SetStateAction, FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { JudoIdentifiable } from '~/services/data-api/common/JudoIdentifiable'; +import type { JudoRestResponse } from '~/services/data-api/rest/requestResponse'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import Typography from '@mui/material/Typography'; +import { GridLogicOperator, useGridApiRef, GridRowModes } from '@mui/x-data-grid{{ getMUIDataGridPlanSuffix }}'; +{{# if isMUILicensePlanPro }} +import { gridColumnDefinitionsSelector } from '@mui/x-data-grid{{ getMUIDataGridPlanSuffix }}'; +{{/ if }} +import type { + GridColDef, + GridFilterModel, + GridRowModel, + GridRowId, + GridRenderCellParams, + GridRowModesModel, + GridRowSelectionModel, + GridSortItem, + GridSortModel, + GridRowClassNameParams, + GridRowParams, + GridValidRowModel, + GridRenderEditCellParams, +} from '@mui/x-data-grid{{ getMUIDataGridPlanSuffix }}'; +import { baseColumnConfig, baseTableConfig, basePageSizeOptions{{# unless table.isEager }}, filterDebounceMs{{/ unless }} } from '~/config'; +import { MdiIcon{{# unless table.isEager }}, CustomTablePagination{{/ unless }} } from '~/components'; +import { + {{# if (tableHasBooleanColumn table) }}booleanColumnOperators,{{/ if }} + {{# if (tableHasDateColumn table) }}dateColumnOperators,{{/ if }} + {{# if (tableHasDateTimeColumn table) }}dateTimeColumnOperators,{{/ if }} + {{# if (tableHasNumericColumn table) }}numericColumnOperators,{{/ if }} + {{# if (tableHasEnumerationColumn table) }}singleSelectColumnOperators,{{/ if }} + {{# if isUseInlineColumnFilters }}stringColumnOperators,{{/ if }} + columnsActionCalculator, +} from '~/components/table'; +import { useConfirmDialog } from '~/components/dialog'; +import type { ContextMenuApi } from '~/components/table/ContextMenu'; +import type { Filter, FilterOption } from '~/components-api'; +import { FilterType } from '~/components-api'; +import { CUSTOM_VISUAL_ELEMENT_INTERFACE_KEY } from '~/custom'; +{{# each (getTableAPIImports table container) as |imp| }} + import type { + {{ imp }}, + {{ imp }}Stored, + } from '~/services/data-api/model/{{ imp }}'; + import type { + {{ imp }}QueryCustomizer, + } from '~/services/data-api/rest/{{ imp }}QueryCustomizer'; +{{/ each }} +{{# or (tableHasNumericColumn table) (tableHasDateColumn table) (tableHasDateTimeColumn table) }} +import { useL10N } from '~/l10n/l10n-context'; +{{/ or }} +import { + TABLE_COLUMN_CUSTOMIZER_HOOK_INTERFACE_KEY, + randomUtils, + {{# if container.isSelector }} + isRowSelectable, + {{ else }} + getUpdatedRowsSelected, + {{/ if }} + {{# if table.isEager }} + applyInMemoryFilters, + {{/ if }} + {{# if (tableHasBinaryColumn table) }} + fileHandling, + {{/ if }} + {{# if isUseInlineColumnFilters }} + mapFilterModelToFilters, + mapFilterToFilterModel, + {{/ if }} + mapAllFiltersToQueryCustomizerProperties, + processQueryCustomizer, + serializeFilters, + deserializeFilters, + {{# unless table.isEager }} + useErrorHandler, + {{/ unless }} + type ServerError, + validateWithNestedErrors, + isErrorNestedValidationError, + extractRelationErrorList, +} from '~/utilities'; +import type { SidekickComponentProps, DialogResult, TableRowAction, ToolBarActionProps, ColumnCustomizerHook, FiltersSerializer{{# if isMUILicensePlanPro }}, PersistedColumnInfo{{/ if }} } from '~/utilities'; +import { OBJECTCLASS } from '@pandino/pandino-api'; +import { useTrackService } from '@pandino/react-hooks'; +import { CardsContainer } from '~/components/widgets'; +import { CellEditInput } from '~/components/table'; +import type { {{ componentName table }}ActionDefinitions, {{ componentName table }}Props } from './types'; +import { {{ classDataName (getReferenceClassType table) 'StoredSerializer' }} } from '~/services/data-api/rest/{{ classDataName (getReferenceClassType table) 'Serializer' }}'; +import { + {{ camelCaseNameToInterfaceKey (componentName table) }}_CARDS_CONTAINER_CONFIG_HOOK_INTERFACE_KEY, + type {{ componentName table }}CardsContainerConfigHook, + type {{ componentName table }}CardsContainerConfig, +} from './customization'; + +export const {{ camelCaseNameToInterfaceKey (componentName table) }}_SIDEKICK_COMPONENT_INTERFACE_KEY = '{{ componentName table }}SidekickComponent'; + +export const filtersSerializer: FiltersSerializer = { + serialize: (filters: Filter[]) => serializeFilters<{{ classDataName (getReferenceClassType table) 'Stored' }}>(filters, {{ classDataName (getReferenceClassType table) 'StoredSerializer' }}.getInstance()), + deserialize: (filters: Filter[]) => deserializeFilters<{{ classDataName (getReferenceClassType table) 'Stored' }}>(filters, {{ classDataName (getReferenceClassType table) 'StoredSerializer' }}.getInstance()), +}; + +// XMIID: {{ getXMIID table }} +// Name: {{ table.name }} +export function {{ componentName table }}(props: {{ componentName table }}Props) { + const { + uniqueId, + actions, + dataPath, + refreshCounter, + isOwnerLoading, + isDraft, + validationError, + {{# unless container.table }} + ownerData, + editMode, + isFormUpdateable, + {{/ unless }} + } = props; + + const { openConfirmDialog } = useConfirmDialog(); + const { t } = useTranslation(); + {{# unless table.isEager }} + const handleError = useErrorHandler(); + {{/ unless }} + {{# or (tableHasNumericColumn table) (tableHasDateColumn table) (tableHasDateTimeColumn table) }} + const { locale: l10nLocale } = useL10N(); + {{/ or }} + {{# if (tableHasBinaryColumn table) }} + const { downloadFile, extractFileNameFromToken } = fileHandling(); + {{/ if }} + const rowValidation = useRef>>(new Map>()); + + // Pandino Cards Container Config overrides + const { service: cardsContainerConfigHook } = useTrackService<{{ componentName table }}CardsContainerConfigHook>( + `(${OBJECTCLASS}=${ {{ camelCaseNameToInterfaceKey (componentName table) }}_CARDS_CONTAINER_CONFIG_HOOK_INTERFACE_KEY })`, + ); + const containerConfig: {{ componentName table }}CardsContainerConfig = cardsContainerConfigHook?.() || {}; + + {{# each table.columns as |column| }} + const {{ column.name }}Column: GridColDef<{{ classDataName (getReferenceClassType table) 'Stored' }}> = { + ...baseColumnConfig, + field: '{{ column.attributeType.name }}', + headerName: t('{{ getTranslationKeyForVisualElement column }}', { defaultValue: '{{ column.label }}' }) as string, + headerClassName: 'data-grid-column-header', + {{# if isDebugPrint }}// include: actor/src/fragments/relation/column.fragment.hbs{{/ if }} + {{> actor/src/fragments/relation/column.fragment.hbs column=column ref=table name=table.name }} + }; + {{/ each }} + + const columns = useMemo[]>(() => [ + {{# each table.columns as |column| }} + {{# if column.customImplementation }} + {{ column.name }}ColumnCustomizer ? {{ column.name }}ColumnCustomizer({{ column.name }}Column) : {{ column.name }}Column, + {{ else }} + {{ column.name }}Column, + {{/ if }} + {{/ each }} + ], [{{# or (tableHasNumericColumn table) (tableHasDateColumn table) (tableHasDateTimeColumn table) }}l10nLocale{{/ or }}]); + + const filterOptions: FilterOption[] = [ + {{# each table.filters as |filter| }} + {{# if isDebugPrint }}// include: actor/src/fragments/table/filter-option.fragment.hbs{{/ if }} + {{> actor/src/fragments/table/filter-option.fragment.hbs filter=filter application=application }} + {{/ each }} + ]; + + return ( +
+ + uniqueId={`{{ createId table }}-${uniqueId}`} + layout={containerConfig.layout} + defaultSortParams={ {{{ getDefaultSortParamsForTable table }}} } + pageLimit={ {{ calculateTablePageLimit table }} } + columns={columns} + filterOptions={filterOptions} + isOwnerLoading={isOwnerLoading} + actions={actions} + {{# each table.rowActionDefinitions as |actionDefinition| }} + {{# if actionDefinition.isOpenPageAction }} + onRowClick={ actions.{{ simpleActionDefinitionName actionDefinition }} ? async (params: GridRowParams<{{ classDataName (getReferenceClassType table) 'Stored' }}>) => await actions.{{ simpleActionDefinitionName actionDefinition }}!(params.row, {{# if container.form }}true{{ else }}false{{/ if }}) : undefined } + {{/ if }} + {{/ each }} + maskAction={actions.get{{ firstToUpper table.relationName }}Mask!} + {{# with (getRefreshActionDefinitionForTable table) as |actionDefinition| }} + fetch={ actions.{{ simpleActionDefinitionName actionDefinition }} } + {{/ with }} + refreshCounter={refreshCounter} + relationName='{{ table.relationName }}' + filtersSerializer={filtersSerializer} + ToolbarElement={containerConfig.ToolbarElement} + CardElement={containerConfig.CardElement} + /> +
+ ); +} diff --git a/judo-ui-react/src/main/resources/actor/src/containers/components/cards/types.ts.hbs b/judo-ui-react/src/main/resources/actor/src/containers/components/cards/types.ts.hbs new file mode 100644 index 00000000..c07aa749 --- /dev/null +++ b/judo-ui-react/src/main/resources/actor/src/containers/components/cards/types.ts.hbs @@ -0,0 +1,97 @@ +import type { ElementType, Dispatch, SetStateAction, FC } from 'react'; +import type { + GridFilterModel, + GridEventListener, + GridRowModesModel, + GridRowModel, +} from '@mui/x-data-grid{{ getMUIDataGridPlanSuffix }}'; +{{# each (getTableAPIImports table container) as |imp| }} + import type { + {{ imp }}, + {{ imp }}Stored, + } from '~/services/data-api/model/{{ imp }}'; + import type { + {{ imp }}QueryCustomizer, + } from '~/services/data-api/rest/{{ imp }}QueryCustomizer'; +{{/ each }} +import type { JudoRestResponse } from '~/services/data-api/rest/requestResponse'; +import type { Filter, FilterOption } from '~/components-api'; +import type { CardProps, ToolbarElementProps } from '~/components-api/components/CardsContainer'; +import type { DialogResult } from '~/utilities'; + +export interface {{ componentName table }}ActionDefinitions { + get{{ firstToUpper table.relationName }}Mask?: () => string; + get{{ firstToUpper table.relationName }}RowRepresentation?: (row: {{ classDataName (getReferenceClassType table) 'Stored' }}) => string; + apply{{ firstToUpper table.relationName }}RowEdit?: (rowData: {{ classDataName (getReferenceClassType table) 'Stored' }}) => Promise; +{{# each table.tableActionDefinitions as |actionDefinition| }} + {{# if actionDefinition.isFilterAction }} + {{ simpleActionDefinitionName actionDefinition }}?: (id: string, filterOptions: FilterOption[], model?: GridFilterModel, filters?: Filter[]) => Promise<{ model?: GridFilterModel; filters?: Filter[] }>; + {{ else }} + {{# if actionDefinition.isRefreshAction }} + {{ simpleActionDefinitionName actionDefinition }}?: (queryCustomizer: {{ classDataName (getReferenceClassType table) 'QueryCustomizer' }}) => Promise>; + {{ else }} + {{# if actionDefinition.isExportAction }} + {{ simpleActionDefinitionName actionDefinition }}?: (queryCustomizer: {{ classDataName (getReferenceClassType table) 'QueryCustomizer' }}) => Promise; + {{ else }} + {{# if actionDefinition.isSelectorRangeAction }} + {{ simpleActionDefinitionName actionDefinition }}?: (queryCustomizer: {{ classDataName (getReferenceClassType table) 'QueryCustomizer' }}) => Promise>; + {{ else }} + {{# if actionDefinition.isInlineCreateRowAction }} + {{ simpleActionDefinitionName actionDefinition }}?: () => Promise<{{ classDataName (getReferenceClassType table) '' }} | undefined>; + {{ else }} + {{# if actionDefinition.isBulk }} + {{ simpleActionDefinitionName actionDefinition }}?: (selectedRows: {{ classDataName (getReferenceClassType table) 'Stored' }}[]) => Promise>; + {{ else }} + {{ simpleActionDefinitionName actionDefinition }}?: ({{# if actionDefinition.targetType }}target: {{ classDataName actionDefinition.targetType 'Stored' }}{{/ if }}) => Promise; + {{/ if }} + {{/ if }} + {{/ if }} + {{/ if }} + {{/ if }} + {{/ if }} +{{/ each }} +{{# each table.rowActionDefinitions as |actionDefinition| }} + {{ simpleActionDefinitionName actionDefinition }}?: (row: {{ classDataName (getReferenceClassType table) 'Stored' }}{{# if actionDefinition.isOpenPageAction }}, isDraft?: boolean{{/ if }}) => Promise; +{{/ each }} + {{ table.relationType.name }}AdditionalToolbarButtons?: ( + {{# with (getReferenceClassType table) as |classType| }} + data: {{# if classType.isMapped }}{{ classDataName classType 'Stored' }}{{ else }}{{ classDataName classType '' }}{{/ if }}[], + {{/ with }} + isLoading: boolean, + selectedRows: {{ classDataName (getReferenceClassType table) 'Stored' }}[], + clearSelections: () => void + {{# unless container.table }} + , ownerData: {{ classDataName container.dataElement 'Stored' }} + , editMode: boolean + , isFormUpdateable: () => boolean + {{/ unless }} + ) => Record; +} + +export interface {{ componentName table }}Props { + uniqueId: string; + actions: {{ componentName table }}ActionDefinitions; + dataPath?: string; + refreshCounter: number; + isOwnerLoading?: boolean; + isDraft?: boolean; + validationError?: string; + {{# if container.isSelector }} + selectionDiff: {{ classDataName (getReferenceClassType table) 'Stored' }}[]; + setSelectionDiff: Dispatch>; + {{/ if }} + {{# if container.isRelationSelector }} + alreadySelected: {{ classDataName (getReferenceClassType table) 'Stored' }}[]; + {{/ if }} + {{# unless container.table }} + ownerData: {{ classDataName container.dataElement 'Stored' }}; + editMode: boolean; + isFormUpdateable: () => boolean; + {{/ unless }} + rowModesModel?: GridRowModesModel; + handleRowModesModelChange?: (newRowModesModel: GridRowModesModel) => void; + handleRowEditStop?: GridEventListener<'rowEditStop'>; + processRowUpdate?: (newRow: GridRowModel) => void; + ToolbarElement?: FC>; + CardElement?: FC>; +} diff --git a/judo-ui-react/src/main/resources/actor/src/utilities/filter-helper.ts.hbs b/judo-ui-react/src/main/resources/actor/src/utilities/filter-helper.ts.hbs index d14ff267..6df73100 100644 --- a/judo-ui-react/src/main/resources/actor/src/utilities/filter-helper.ts.hbs +++ b/judo-ui-react/src/main/resources/actor/src/utilities/filter-helper.ts.hbs @@ -5,6 +5,7 @@ import { GridLogicOperator } from '@mui/x-data-grid{{ getMUIDataGridPlanSuffix } import { isEqual, compareAsc } from 'date-fns'; import type { Filter, FilterOption, Operation } from '../components-api'; import { FilterType } from '../components-api'; +import { CardsFilterDefinition } from '../components/widgets/CardsFilter'; import type { Serializer } from '~/services/data-api/common/Serializer'; import { simpleCloneDeep, ucFirst } from './helper'; @@ -459,6 +460,34 @@ export function mapFilterModelToFilters(filterModel: GridFilterModel, filterOpti return filters; } +export function mapCardsFiltersToFilters(cardsFilters: CardsFilterDefinition[], values: Record): Filter[] { + const filters: Filter[] = []; + + for (const def of cardsFilters) { + if (values.hasOwnProperty(def.field)) { + if (def.type === FilterType.boolean || def.type === FilterType.string) { + const key = def.field as string; + filters.push({ + id: `id-${key}`, + operationId: `eq-${key}`, + valueId: `v-${key}`, + filterOption: { + id: `o-${key}`, + attributeName: key, + filterType: def.type, + }, + filterBy: { + value: values[key], + operator: def.type === FilterType.boolean ? _BooleanOperation.equals : _StringOperation.equal, + }, + }); + } + } + } + + return filters; +} + export function operatorToMUIOperator(operator: string, equals: string, notEquals: string): string { if (operator === 'isEmpty' || operator === 'isNotEmpty') { return operator; diff --git a/judo-ui-react/src/main/resources/ui-react.yaml b/judo-ui-react/src/main/resources/ui-react.yaml index 349f771c..db8873ce 100644 --- a/judo-ui-react/src/main/resources/ui-react.yaml +++ b/judo-ui-react/src/main/resources/ui-react.yaml @@ -493,6 +493,22 @@ templates: pathExpression: "'src/components/widgets/TextWithTypeAhead.tsx'" templateName: actor/src/components/widgets/TextWithTypeAhead.tsx.hbs + - name: actor/src/components/widgets/CardsContainer.tsx + pathExpression: "'src/components/widgets/CardsContainer.tsx'" + templateName: actor/src/components/widgets/CardsContainer.tsx.hbs + + - name: actor/src/components/widgets/CardsFilter.tsx + pathExpression: "'src/components/widgets/CardsFilter.tsx'" + templateName: actor/src/components/widgets/CardsFilter.tsx.hbs + + - name: actor/src/components/widgets/DefaultCard.tsx + pathExpression: "'src/components/widgets/DefaultCard.tsx'" + templateName: actor/src/components/widgets/DefaultCard.tsx.hbs + + - name: actor/src/components/widgets/DefaultToolbar.tsx + pathExpression: "'src/components/widgets/DefaultToolbar.tsx'" + templateName: actor/src/components/widgets/DefaultToolbar.tsx.hbs + # Actor - src - components-api - name: actor/src/components-api/components/Action.ts @@ -503,6 +519,10 @@ templates: pathExpression: "'src/components-api/components/ActionGroup.ts'" templateName: actor/src/components-api/components/ActionGroup.ts.hbs + - name: actor/src/components-api/components/CardsContainer.ts + pathExpression: "'src/components-api/components/CardsContainer.ts'" + templateName: actor/src/components-api/components/CardsContainer.ts.hbs + - name: actor/src/components-api/components/Button.ts pathExpression: "'src/components-api/components/Button.ts'" templateName: actor/src/components-api/components/Button.ts.hbs @@ -790,6 +810,36 @@ templates: - name: table expression: "#self" + - name: actor/src/containers/components/cards/index.tsx + factoryExpression: "#getCardsForPageContainers(#application)" + pathExpression: "'src/containers/' + #containerPath(#self.pageContainer) + '/components/' + #cardsComponentName(#self) + '/index.tsx'" + templateName: actor/src/containers/components/cards/index.tsx.hbs + templateContext: + - name: container + expression: "#self.pageContainer" + - name: table + expression: "#self" + + - name: actor/src/containers/components/cards/types.ts + factoryExpression: "#getCardsForPageContainers(#application)" + pathExpression: "'src/containers/' + #containerPath(#self.pageContainer) + '/components/' + #cardsComponentName(#self) + '/types.ts'" + templateName: actor/src/containers/components/cards/types.ts.hbs + templateContext: + - name: container + expression: "#self.pageContainer" + - name: table + expression: "#self" + + - name: actor/src/containers/components/cards/customization.ts + factoryExpression: "#getCardsForPageContainers(#application)" + pathExpression: "'src/containers/' + #containerPath(#self.pageContainer) + '/components/' + #cardsComponentName(#self) + '/customization.ts'" + templateName: actor/src/containers/components/cards/customization.ts.hbs + templateContext: + - name: container + expression: "#self.pageContainer" + - name: table + expression: "#self" + # Actor - src - pages - name: actor/src/pages/Redirect.tsx