Skip to content

Commit 898efce

Browse files
committed
feat(AppShell): add Services package to the App Shell suite [PPUC-187]
1 parent 05d92c6 commit 898efce

File tree

16 files changed

+1168
-0
lines changed

16 files changed

+1168
-0
lines changed

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/app-shell-services/README.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# @hitachivantara/app-shell-services
2+
3+
Hitachi Vantara App Shell Services. Support package to manage services at the App Shell ecosystem.
4+
5+
## Overview
6+
7+
This package provides service management capabilities including:
8+
9+
- Service registration and resolution
10+
- React hooks for service consumption
11+
- Type-safe service interfaces
12+
13+
## Usage
14+
15+
Below is an example of how one could get all registered services (provided by multiple other packages/plugins in the AppShell ecosystem) for, for example, a header action dropdown menu.
16+
17+
First, and although not mandatory it would be ideal that the types of the services are defined in a separate package, so that both the service providers and consumers can depend on it:
18+
19+
```typescript
20+
// @some-package/my-app-services
21+
// index.ts
22+
export type MyAppCreateHeaderAction = {
23+
id: string;
24+
ordinal?: number;
25+
label: string;
26+
icon?: ReactNode;
27+
onAction: () => void;
28+
};
29+
30+
export type UseMyAppCreateHeaderAction = () =>
31+
| MyAppCreateHeaderAction
32+
| undefined;
33+
34+
/**
35+
* Service definitions for my app.
36+
*/
37+
export const MyAppServiceDefinitions = {
38+
UseMyAppCreateHeaderAction: {
39+
id: "my-app-header-action-id",
40+
},
41+
};
42+
```
43+
44+
```typescript
45+
import { FC, useCallback, useMemo } from "react";
46+
import { useServices, SERVICES_ERROR_HANDLING } from "@hitachivantara/app-shell-services";
47+
import { PlusCircleIcon } from "@phosphor-icons/react";
48+
import {
49+
HvDropDownMenu,
50+
HvDropDownMenuProps,
51+
HvIconContainer,
52+
HvListValue
53+
} from "@hitachivantara/uikit-react-core";
54+
import { MyAppServiceDefinitions, MyAppUseCreateHeaderAction, MyAppCreateHeaderAction } from "@some-package/my-app-services";
55+
56+
type OnDropDownMenuClickCallback = NonNullable<HvDropDownMenuProps["onClick"]>;
57+
58+
function isNonNull<T>(value: T): value is NonNullable<T> {
59+
return value != null;
60+
}
61+
62+
function createListValue(action: MyAppCreateHeaderAction): HvListValue {
63+
return {
64+
id: action.id,
65+
label: action.label,
66+
icon: action.icon
67+
} as HvListValue;
68+
}
69+
70+
const CreateHeaderActionComponentInner: FC<{
71+
actionHooks: UseMyAppCreateHeaderAction[];
72+
}> = ({ actionHooks }) => {
73+
// Call all hooks directly at the top level as each hook call happens consistently on every render
74+
const actionResults = actionHooks.map(actionHook => actionHook()).filter(isNonNull);
75+
76+
// Create menu items
77+
const dataList = useMemo(() => actionResults.map(createListValue), [actionResults]);
78+
79+
const onClick = useCallback<OnDropDownMenuClickCallback>(
80+
(_event, dataListItem) => {
81+
const action = actionResults.find(action => action.id === dataListItem.id);
82+
if (action) {
83+
action.onAction();
84+
}
85+
},
86+
[actionResults]
87+
);
88+
89+
// If no valid actions, do not render anything.
90+
if (dataList.length === 0) {
91+
return null;
92+
}
93+
94+
return (
95+
<HvDropDownMenu
96+
icon={
97+
<HvIconContainer size="sm">
98+
<PlusCircleIcon />
99+
</HvIconContainer>
100+
}
101+
dataList={dataList}
102+
keepOpened={false}
103+
onClick={onClick}
104+
/>
105+
);
106+
};
107+
108+
function CreateHeaderActionComponent() {
109+
// For getting multiple services with error handling set to reject on any failure (error on import, type mismatch, etc.)
110+
const {
111+
services: actionHooks,
112+
isPending,
113+
error
114+
} = useServices<UseMyAppCreateHeaderAction>(MyAppServiceDefinitions.UseMyAppCreateHeaderAction.id, { errorHandling: SERVICES_ERROR_HANDLING.REJECT_ON_ANY_FAILURE });
115+
116+
if (isPending) return <div>Loading services...</div>;
117+
if (error) return <div>Error loading services: {error.message}</div>;
118+
119+
// Services are now available to use
120+
return <CreateHeaderActionComponentInner actionHooks={actionHooks} />;
121+
}
122+
```
123+
124+
## Installation
125+
126+
The App Shell Services is available as an NPM package, and can be installed with:
127+
128+
```bash
129+
npm install @hitachivantara/app-shell-services
130+
```
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"name": "@hitachivantara/app-shell-services",
3+
"version": "0.0.1",
4+
"type": "module",
5+
"private": false,
6+
"author": "Hitachi Vantara UI Kit Team",
7+
"description": "AppShell Services",
8+
"homepage": "https://github.com/lumada-design/hv-uikit-react",
9+
"main": "./src/index.ts",
10+
"sideEffects": false,
11+
"license": "Apache-2.0",
12+
"repository": {
13+
"type": "git",
14+
"url": "git+https://github.com/lumada-design/hv-uikit-react.git",
15+
"directory": "packages/app-shell-services"
16+
},
17+
"bugs": "https://github.com/lumada-design/hv-uikit-react/issues",
18+
"scripts": {
19+
"build": "npm run clean && vite build",
20+
"clean": "npx rimraf dist package",
21+
"prepublishOnly": "npm run build && npx clean-publish"
22+
},
23+
"peerDependencies": {
24+
"react": "^18.2.0"
25+
},
26+
"files": [
27+
"dist"
28+
],
29+
"publishConfig": {
30+
"access": "public",
31+
"directory": "package",
32+
"module": "dist/esm/index.js",
33+
"types": "./dist/types/index.d.ts",
34+
"exports": {
35+
".": {
36+
"types": "./dist/types/index.d.ts",
37+
"import": "./dist/esm/index.js",
38+
"default": "./dist/esm/index.js"
39+
},
40+
"./package.json": "./package.json"
41+
}
42+
},
43+
"clean-publish": {
44+
"withoutPublish": true,
45+
"tempDir": "package",
46+
"fields": [
47+
"main"
48+
],
49+
"files": [
50+
"tsconfig.json"
51+
]
52+
}
53+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useCallback } from "react";
2+
3+
import {
4+
ServiceId,
5+
ServiceReference,
6+
UseGetServiceReferenceOptions,
7+
UseGetServiceReferencesOptions,
8+
UseServiceOptions,
9+
UseServiceReferenceResult,
10+
UseServiceResult,
11+
UseServicesOptions,
12+
UseServicesResult,
13+
} from "../types/service";
14+
import { useAsyncGeneric } from "./useAsyncGeneric";
15+
import useServiceManager from "./useServiceManager";
16+
17+
export function useService<TService>(
18+
serviceId: ServiceId,
19+
options: UseServiceOptions = {},
20+
): UseServiceResult<TService> {
21+
const serviceManager = useServiceManager();
22+
23+
const promiseFactory = useCallback(
24+
() => serviceManager.getService<TService>(serviceId, options),
25+
[serviceManager, serviceId, options],
26+
);
27+
28+
return useAsyncGeneric(promiseFactory, "service", undefined);
29+
}
30+
31+
export function useServices<TService>(
32+
serviceId: ServiceId,
33+
options: UseServicesOptions = {},
34+
): UseServicesResult<TService> {
35+
const serviceManager = useServiceManager();
36+
37+
return useAsyncGeneric(
38+
() => serviceManager.getServices<TService>(serviceId, options),
39+
"services",
40+
[],
41+
);
42+
}
43+
44+
export function useGetServiceReference<TService>(
45+
serviceId: ServiceId,
46+
options: UseGetServiceReferenceOptions = {},
47+
): ServiceReference<TService> {
48+
const serviceManager = useServiceManager();
49+
return serviceManager.getServiceReference<TService>(serviceId, options);
50+
}
51+
52+
export function useGetServiceReferences<TService>(
53+
serviceId: ServiceId,
54+
options: UseGetServiceReferencesOptions = {},
55+
): ServiceReference<TService>[] {
56+
const serviceManager = useServiceManager();
57+
return serviceManager.getServiceReferences<TService>(serviceId, options);
58+
}
59+
60+
// Gets a reference's service as an async result.
61+
export function useServiceReference<TService>(
62+
serviceReference: ServiceReference<TService>,
63+
): UseServiceReferenceResult<TService> {
64+
return useAsyncGeneric(
65+
() => serviceReference.getService(),
66+
"service",
67+
undefined,
68+
);
69+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useEffect, useRef, useState } from "react";
2+
3+
import {
4+
ErrorBase,
5+
UseAsyncErrorResult,
6+
UseAsyncPendingResult,
7+
UseAsyncResult,
8+
UseAsyncSuccessResult,
9+
} from "../types/async";
10+
11+
/**
12+
* Generic async hook with full control over data property name and pending data.
13+
* TODO ideally called useAsync, but making optional arguments and defaults work with generics is tricky...
14+
*
15+
* @param promiseFactory Function that returns a Promise
16+
* @param dataProp Custom property name for the data
17+
* @param pendingData Initial data while pending
18+
* @returns UseAsyncResult with custom data property name
19+
*/
20+
export function useAsyncGeneric<
21+
TData,
22+
TError extends ErrorBase = Error,
23+
TDataProp extends string = "data",
24+
TDataPending extends TData | undefined = undefined,
25+
>(
26+
promiseFactory: () => Promise<TData>,
27+
dataProp: TDataProp,
28+
pendingData: TDataPending,
29+
): UseAsyncResult<TData, TError, TDataProp, TDataPending> {
30+
const [data, setData] = useState<TData | undefined>(pendingData);
31+
const [error, setError] = useState<TError | null>(null);
32+
const [isPending, setIsPending] = useState(true);
33+
34+
const promiseFactoryRef = useRef(promiseFactory);
35+
const pendingDataRef = useRef(pendingData);
36+
37+
useEffect(() => {
38+
let isMounted = true;
39+
40+
// Reset state when starting a new async operation
41+
setError(null);
42+
setIsPending(true);
43+
setData(pendingDataRef.current);
44+
45+
promiseFactoryRef
46+
.current()
47+
.then((result) => {
48+
if (isMounted) {
49+
setData(result);
50+
setError(null);
51+
setIsPending(false);
52+
}
53+
})
54+
.catch((err) => {
55+
if (isMounted) {
56+
setError(err as TError);
57+
setData(undefined);
58+
setIsPending(false);
59+
}
60+
});
61+
62+
return () => {
63+
isMounted = false;
64+
};
65+
}, []);
66+
67+
if (error) {
68+
return {
69+
isPending: false,
70+
error,
71+
[dataProp]: undefined,
72+
} as UseAsyncErrorResult<TData, TError, TDataProp, TDataPending>;
73+
}
74+
75+
if (isPending) {
76+
return {
77+
isPending: true,
78+
error: null,
79+
[dataProp]: pendingData,
80+
} as UseAsyncPendingResult<TData, TError, TDataProp, TDataPending>;
81+
}
82+
83+
return {
84+
isPending: false,
85+
error: null,
86+
[dataProp]: data,
87+
} as UseAsyncSuccessResult<TData, TError, TDataProp, TDataPending>;
88+
}
89+
90+
/**
91+
* Convenience hook for async data fetching with "data" property.
92+
*
93+
* @param promiseFactory Function that returns a Promise
94+
* @param initialData Initial data while pending
95+
* @returns UseAsyncResult with "data" property
96+
*/
97+
export function useAsyncData<TData, TError extends ErrorBase = Error>(
98+
promiseFactory: () => Promise<TData>,
99+
initialData?: TData,
100+
): UseAsyncResult<TData, TError, "data", TData | undefined> {
101+
return useAsyncGeneric(promiseFactory, "data", initialData);
102+
}

0 commit comments

Comments
 (0)