Skip to content

Commit

Permalink
JNG-4838 add post refresh hooks, and onBlur support
Browse files Browse the repository at this point in the history
  • Loading branch information
noherczeg committed Nov 28, 2023
1 parent 08f3dfe commit 8db5c05
Show file tree
Hide file tree
Showing 23 changed files with 241 additions and 244 deletions.
234 changes: 32 additions & 202 deletions docs/pages/01_ui_react.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -434,226 +434,56 @@ const OptimisticImplementationForYayy: ComponentActorCreateYayy = ({ data, store
> Of course our custom components can be placed / imported from anywhere in the source code. We just simplified it in
the use-case above.

=== Implementing custom navigation logic for components
=== Overriding and extending page actions

Navigation actions are implemented as hooks. These hooks have names starting with "useRow..." in case of tables and
"useLink..." in case of single relations.
Every page has a set of Actions. These are typically actions triggered by buttons, or page lifecycle actions, and are
generated in a form of optional interface methods.

Given we have a table screen listing galaxies, we can implement a custom navigation logic in the following way:
These methods can be re-implemented one-by-one, and if the framework detects a "custom" version of a method, it will
call that instead of the original (if any).

*Generated hook (original code):*
[source,typescriptjsx]
----
import { OBJECTCLASS } from '@pandino/pandino-api';
import { useTrackService } from '@pandino/react-hooks';
import type { JudoIdentifiable } from '@judo/data-api-common';
import type { ViewGalaxyQueryCustomizer, ViewGalaxy, ViewGalaxyStored } from '../../../../../../generated/data-api';
import { useJudoNavigation } from '../../../../../../components';
export const ROW_VIEW_GALAXIES_ACTION_INTERFACE_KEY = 'RowViewGalaxiesAction';
export type RowViewGalaxiesAction = () => (entry: ViewGalaxyStored) => Promise<void>;
export const useRowViewGalaxiesAction: RowViewGalaxiesAction = () => {
const { navigate } = useJudoNavigation();
const { service: useCustomNavigation } = useTrackService<RowViewGalaxiesAction>(
`(${OBJECTCLASS}=${ROW_VIEW_GALAXIES_ACTION_INTERFACE_KEY})`,
);
if (useCustomNavigation) {
const customNavigation = useCustomNavigation();
return customNavigation;
}
return async function (entry: ViewGalaxyStored) {
navigate(`god/galaxies/view/${entry.__signedIdentifier}`);
};
};
----

Overriding the above logic can ge done by:

- implementing the `RowViewGalaxiesAction` interface
- registering this implementation in the `application-customizer.tsx` file

> For brevity's sake we'll put all our code in a single file, but it's not mandatory

*src/custom/application-customizer.tsx:*
[source,typescriptjsx]
----
import type { BundleContext } from '@pandino/pandino-api';
import { useJudoNavigation } from '../components';
import { ViewGalaxyStored } from '../generated/data-api';
import { RowViewGalaxiesAction, ROW_VIEW_GALAXIES_ACTION_INTERFACE_KEY } from '../pages/god/galaxies/table/actions';
import { ApplicationCustomizer } from './interfaces';
export class DefaultApplicationCustomizer implements ApplicationCustomizer {
async customize(context: BundleContext): Promise<void> {
context.registerService<RowViewGalaxiesAction>(ROW_VIEW_GALAXIES_ACTION_INTERFACE_KEY, customRowViewGalaxiesAction);
}
}
const customRowViewGalaxiesAction: RowViewGalaxiesAction = () => {
const { navigate } = useJudoNavigation();
return async (entry: ViewGalaxyStored) => {
// regardless of what row we select, we will always go to the same page
navigate('god/earth/view');
}
};
----

=== Implementing custom behaviour for operation success handling

Every custom operation has a "success handler" implementation by default. These handlers behave differently depending on
the action type, and return parameter (or lack thereof).

*Default behaviours explained:*

- if there is a *mapped* return type:
* pop a success toast and
* navigate to the created element's view page
- if there is an *unmapped* return type:
* pop a success toast and
* refresh the current page and
* show the result in a read-only modal
- if there is no return type:
* pop a success toast and
* refresh the current page

*Overriding the above logic can be done by:*

- implementing the `PostHandlerHook` interface for an operation
- registering this implementation in the `application-customizer.tsx` file

Depending on what operation we would like to override, we need to locate the action in the `src/pages` folder, and once
we found our action file, we should be able to see an `INTERFACE_KEY` with the corresponding `PostHandlerHook` interface.

> Please be aware that the interfaces for each hook have different signatures based on the operation, e.g.: for operations
which do not have a return type, the corresponding hook interfaces won't contain a "result" parameter!
Custom page actions can be implemented on a per-page basis. Every page as a designated unique `INTERFACE_KEY` string and
a corresponding action hook `type`.

*src/custom/application-customizer.tsx:*
[source,typescriptjsx]
----
import type { BundleContext } from '@pandino/pandino-api';
import { useSnackbar } from 'notistack';
import { ApplicationCustomizer } from './interfaces';
import {
ADMIN_DASHBOARD_CREATE_ISSUE_ACTION_POST_HANDLER_HOOK_INTERFACE_KEY,
AdminDashboardCreateIssueActionPostHandlerHook
} from '../pages/admin/admin/dashboardhome/actions';
import { AdminIssueStored } from '../generated/data-api';
import { toastConfig } from '../config';
*Figuring out how to locate interface keys can be done via:*

export class DefaultApplicationCustomizer implements ApplicationCustomizer {
async customize(context: BundleContext): Promise<void> {
context.registerService(ADMIN_DASHBOARD_CREATE_ISSUE_ACTION_POST_HANDLER_HOOK_INTERFACE_KEY, usePostIssueCreated);
}
}
- observing the page route in the browsers URL bar (for non-dialogs), and looking the up in the `src/routes.tsx` file
- Inspecting the pages / dialogs in dev-tools, and searching for the id of them in the code

const usePostIssueCreated: AdminDashboardCreateIssueActionPostHandlerHook = () => {
const { enqueueSnackbar } = useSnackbar();
// We can add any variables and use any hooks here
return async (ownerCallback: () => void, result?: AdminIssueStored) => {
// The default implementation in this case is to pop a generic toast, and navigate to the created element's page
// but we are overriding this, to only pop a custom toast message
if (result) {
enqueueSnackbar(`${result.title} created!`, {
variant: 'success',
...toastConfig.success,
});
}
// and regardless of the output, refresh the current page
ownerCallback();
};
};
----

Since operations are always started from modal windows the `ownerCallback` can behave differently, but most of the time
it triggers a refresh on the actual page which we resided on.

If we would like to implement a fix navigation to some page, it is recommended to *NOT CALL* the `ownerCallback()` function
to prevent unnecessary REST calls and potential screen flow issues.

=== Implementing a post refresh hook
*Registering implementations*

Whenever pages have a `refresh` action, we have the option to register a hook in which we can "intercept" the
"after/post" lifecycle for them.

These hooks are registered with a specific `INTERFACE_KEY`. We can locate these keys in the `src/pages` folder, and once
we found our page/dialog file, we should be able to see an `INTERFACE_KEY` with the corresponding `PostRefreshHook` interface.
Implementations can be registered in one central location: `src/custom/application-customizer.tsx`.

*src/custom/application-customizer.tsx:*
[source,typescriptjsx]
----
import type { BundleContext } from '@pandino/pandino-api';
import type { Dispatch, SetStateAction } from 'react';
import { ApplicationCustomizer } from './interfaces';
import { GOD_GALAXIES_VIEW_POST_REFRESH_HOOK_INTERFACE_KEY, GodGalaxiesViewPostRefreshHook } from '~/pages/god/galaxies/view';
import { ViewGalaxy, ViewGalaxyStored } from '~/generated/data-api';
export class DefaultApplicationCustomizer implements ApplicationCustomizer {
async customize(context: BundleContext): Promise<void> {
// register your implementations here
context.registerService(GOD_GALAXIES_VIEW_POST_REFRESH_HOOK_INTERFACE_KEY, customGodGalaxiesViewRefreshPostHandlerHook);
}
}
const customGodGalaxiesViewRefreshPostHandlerHook: GodGalaxiesViewPostRefreshHook = () => {
return async (data: ViewGalaxyStored,
storeDiff: (attributeName: keyof ViewGalaxyStored, value: any) => void,
setEditMode: Dispatch<SetStateAction<boolean>>,
setValidation: Dispatch<SetStateAction<Map<keyof ViewGalaxy, string>>>,
) => {
if (data.name === 'Acallaris') {
setValidation((validation) => {
validation.set('constellation', 'Wrong constellation, lmao');
return validation;
});
}
};
};
----

=== Implementing an onBlur hook

OnBlur hooks are available for all form elements which have the flag enabled in the model.

Just like every other hook, these have their corresponding `INTERFACE_KEY` and the interfaces for the keys.

*src/custom/application-customizer.tsx:*
[source,typescriptjsx]
----
import type { Dispatch, SetStateAction } from 'react';
import type { BundleContext } from '@pandino/pandino-api';
import { ApplicationCustomizer } from './interfaces';
import { GOD_CREATE_NAME_ON_BLUR_INTERFACE_KEY, GodCreateNameOnBlurHook } from '~/pages/god/galaxies/table/actions/PageCreateGalaxiesForm';
import type { ViewGalaxy } from '~/generated/data-api';
import type { ApplicationCustomizer } from './interfaces';
import type { ViewGalaxyViewActionsHook } from '~/pages/God/Galaxies/AccessViewPage';
import { GOD_GALAXIES_ACCESS_VIEW_PAGE_ACTIONS_HOOK_INTERFACE_KEY } from '~/pages/God/Galaxies/AccessViewPage';
export class DefaultApplicationCustomizer implements ApplicationCustomizer {
async customize(context: BundleContext): Promise<void> {
// register your implementations here
context.registerService(GOD_CREATE_NAME_ON_BLUR_INTERFACE_KEY, customGodCreateNameOnBlurHook);
context.registerService<ViewGalaxyViewActionsHook>(GOD_GALAXIES_ACCESS_VIEW_PAGE_ACTIONS_HOOK_INTERFACE_KEY, customViewGalaxyViewActionsHook);
}
}
const customGodCreateNameOnBlurHook: GodCreateNameOnBlurHook = () => {
return async (
data: ViewGalaxy,
storeDiff: (attributeName: keyof ViewGalaxy, value: any) => void,
editMode: boolean,
setEditMode: Dispatch<SetStateAction<boolean>>,
setValidation: Dispatch<SetStateAction<Map<keyof ViewGalaxy, string>>>,
submit: () => Promise<void>,
) => {
if (data.name === 'test') {
storeDiff('constellation', data.name);
storeDiff('nakedEye', true);
await submit();
}
const customViewGalaxyViewActionsHook: ViewGalaxyViewActionsHook = () => {
return {
onNakedEyeBlurAction: async (data, storeDiff, editMode, setValidation, submit) => {
// If the are toggling the `nakedEye` property and not in editMode already, then automatically save the change
if (!editMode) {
await submit();
}
},
postRefreshAction: async (data , storeDiff, setValidation) => {
// Check the `nakedEye` property after every refresh, and if it is not filled, then set a validation message.
if (!data.nakedEye) {
setValidation(new Map([
['nakedEye', 'Naked Eye has to be checked!']
]));
}
},
};
};
----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@
import java.util.List;
import java.util.stream.Collectors;

import static hu.blackbelt.judo.ui.generator.react.UiPageHelper.isSingleAccessPage;
import static hu.blackbelt.judo.ui.generator.react.UiPageHelper.pageHasSignedId;
import static hu.blackbelt.judo.ui.generator.react.UiPageHelper.*;
import static hu.blackbelt.judo.ui.generator.react.UiWidgetHelper.collectElementsOfType;
import static hu.blackbelt.judo.ui.generator.react.UiWidgetHelper.getReferenceClassType;
import static hu.blackbelt.judo.ui.generator.typescript.rest.commons.UiCommonsHelper.classDataName;
Expand Down Expand Up @@ -284,4 +283,46 @@ public static String refreshActionDataParameter(Action action) {
}
return "undefined";
}

public static String postCallOperationActionParams(PageDefinition page, ActionDefinition actionDefinition) {
List<String> tokens = new ArrayList<>();
if (actionDefinition.getTargetType() != null) {
tokens.add("data: " + classDataName(actionDefinition.getTargetType(), "Stored"));
}
if (actionDefinition instanceof CallOperationActionDefinition call && call.getOperation().getOutput() != null) {
tokens.add("output: " + classDataName(call.getOperation().getOutput().getTarget(), "Stored"));
}
if (page.getContainer().isForm()) {
String result = (pageHasOutputTarget(page) ? classDataName(getPageOutputTarget(page), "Stored") : dialogDataType(page)) + (page.getContainer().isTable() ? "[]" : "");
tokens.add("onSubmit: (result?: " + result + ") => Promise<void>");
}
if (page.isOpenInDialog()) {
tokens.add("onClose: () => Promise<void>");
}
return String.join(", ", tokens);
}

public static String postRefreshActionParams(PageDefinition page, ActionDefinition actionDefinition) {
String res = "";
res += "data: " + classDataName(getReferenceClassType(page), "Stored") + (page.getContainer().isTable() ? "[]" : "");
if (!page.getContainer().isTable()) {
res += ", ";
res += "storeDiff: (attributeName: keyof " + classDataName(getReferenceClassType(page), "") + ", value: any) => void, ";
res += "setValidation: Dispatch<SetStateAction<Map<keyof " + classDataName(getReferenceClassType(page), "") + ", string>>>";
}
return res;
}

public static String onBlurActionParams(PageContainer container) {
List<String> tokens = new ArrayList<>();
if (!container.isTable()) {
tokens.add("data: " + classDataName((ClassType) container.getDataElement(), "Stored"));
tokens.add("storeDiff: (attributeName: keyof " + classDataName((ClassType) container.getDataElement(), "") + ", value: any) => void");
tokens.add("editMode: boolean");
tokens.add("setValidation: Dispatch<SetStateAction<Map<keyof " + classDataName((ClassType) container.getDataElement(), "") + ", string>>>");
}
tokens.add("submit: () => Promise<void>");

return String.join(", ", tokens);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public class UiImportHelper {
Map.entry("link", Set.of()),
Map.entry("numericinput", Set.of("TextField", "InputAdornment")),
Map.entry("spacer", Set.of()),
Map.entry("switch", Set.of("TextField", "MenuItem", "InputAdornment", "FormGroup", "FormControlLabel", "Checkbox")),
Map.entry("switch", Set.of("TextField", "MenuItem", "InputAdornment", "FormGroup", "FormControlLabel", "Checkbox", "FormControl", "FormHelperText")),
Map.entry("tabcontroller", Set.of()),
Map.entry("table", Set.of("Button")),
Map.entry("text", Set.of("Typography")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,28 @@ public static String dialogDataType(PageDefinition page) {
}
return "void";
}

public static Action getCreateActionForPage(PageDefinition page) {
ActionDefinition def = page.getContainer().getActionButtonGroup().getButtons().stream().map(Button::getActionDefinition).filter(a -> a instanceof CreateActionDefinition).findFirst().orElse(null);
if (def != null) {
return page.getActions().stream().filter(a -> a.getActionDefinition().equals(def)).findFirst().orElse(null);
}
return null;
}

public static Action getUpdateActionForPage(PageDefinition page) {
ActionDefinition def = page.getContainer().getActionButtonGroup().getButtons().stream().map(Button::getActionDefinition).filter(a -> a instanceof UpdateActionDefinition).findFirst().orElse(null);
if (def != null) {
return page.getActions().stream().filter(a -> a.getActionDefinition().equals(def)).findFirst().orElse(null);
}
return null;
}

public static Action getCallOperationActionForPage(PageDefinition page) {
ActionDefinition def = page.getContainer().getActionButtonGroup().getButtons().stream().map(Button::getActionDefinition).filter(a -> a instanceof CallOperationActionDefinition).findFirst().orElse(null);
if (def != null) {
return page.getActions().stream().filter(a -> a.getActionDefinition().equals(def)).findFirst().orElse(null);
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@

import hu.blackbelt.judo.generator.commons.annotations.TemplateHelper;
import hu.blackbelt.judo.meta.ui.*;
import hu.blackbelt.judo.meta.ui.data.AttributeType;
import lombok.extern.java.Log;

import java.util.*;
import java.util.stream.Collectors;

import static hu.blackbelt.judo.ui.generator.react.UiPageContainerHelper.containerComponentName;
import static hu.blackbelt.judo.ui.generator.react.UiPageContainerHelper.simpleActionDefinitionName;
Expand Down Expand Up @@ -86,4 +88,20 @@ public static List<Action> getAllCallOperationActions(PageDefinition pageDefinit
.sorted(Comparator.comparing(NamedElement::getFQName))
.toList();
}

public static List<AttributeType> getOnBlurAttributesForContainer(PageContainer container) {
Set<VisualElement> elements = new LinkedHashSet<>();
collectVisualElementsMatchingCondition(container, e -> e.getOnBlur() != null && e.getOnBlur(), elements);

Set<AttributeType> filtered = new LinkedHashSet<>();

for (VisualElement e: elements) {
AttributeType attributeType = ((Input) e).getAttributeType();
if (filtered.stream().noneMatch(a -> a.getName().equals(attributeType.getName()))) {
filtered.add(attributeType);
}
}

return filtered.stream().sorted(Comparator.comparing(NamedElement::getFQName)).collect(Collectors.toList());
}
}
Loading

0 comments on commit 8db5c05

Please sign in to comment.