@xfilecom/xframe 0.1.34 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/xframe.js +12 -10
- package/defaults.json +2 -2
- package/package.json +1 -1
- package/template/README.md +1 -1
- package/template/apps/api/src/main.ts +53 -13
- package/template/shared/endpoint/README.md +30 -1
- package/template/shared/endpoint/endpoint.ts +55 -0
- package/template/shared/endpoint/index.ts +2 -0
- package/template/web/admin/src/App.tsx +2 -7
- package/template/web/admin/src/main.tsx +12 -1
- package/template/web/admin/tsconfig.json +1 -1
- package/template/web/admin/vite.config.ts +2 -7
- package/template/web/client/src/App.tsx +2 -7
- package/template/web/client/src/main.tsx +12 -1
- package/template/web/client/tsconfig.json +1 -1
- package/template/web/client/vite.config.ts +4 -7
- package/template/web/shared/README.md +4 -5
- package/template/web/shared/package.json +3 -1
- package/template/web/shared/src/api/client.ts +132 -0
- package/template/web/shared/src/api/methods/health.method.ts +14 -0
- package/template/web/shared/src/api/methods/index.ts +31 -0
- package/template/web/shared/src/api/request.ts +65 -0
- package/template/web/shared/src/api/types.ts +80 -0
- package/template/web/shared/src/context/StoreProvider.tsx +45 -0
- package/template/web/shared/src/hooks/useAppStore.ts +23 -0
- package/template/web/shared/src/hooks/useHealthStatus.ts +12 -3
- package/template/web/shared/src/index.ts +53 -1
- package/template/web/shared/src/lib/http.ts +9 -6
- package/template/web/shared/src/methods/health.ts +5 -4
- package/template/web/shared/src/params/param-store.ts +178 -0
- package/template/web/shared/src/params/types.ts +21 -0
- package/template/web/shared/src/params/validations.ts +32 -0
- package/template/web/shared/src/store/api-cache-store.ts +35 -0
- package/template/web/shared/src/store/index.ts +4 -0
- package/template/web/shared/src/store/root-store.ts +358 -0
- package/template/web/shared/src/store/session-store.ts +28 -0
- package/template/web/shared/src/types/ui.ts +37 -0
- package/template/web/shared/tsconfig.json +4 -1
- package/template/yarn.lock +237 -13
- package/template/web/shared/src/stores/appStore.ts +0 -11
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EndpointDef — path `:param` 치환, query/body, skipGlobalIndicator
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { EndpointDef } from '@shared/endpoint';
|
|
6
|
+
import { apiClient } from './client';
|
|
7
|
+
|
|
8
|
+
function buildPath(path: string, params?: Record<string, string>): string {
|
|
9
|
+
if (!params) return path;
|
|
10
|
+
let out = path;
|
|
11
|
+
for (const [k, v] of Object.entries(params)) {
|
|
12
|
+
out = out.replace(`:${k}`, encodeURIComponent(String(v)));
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildQuery(query?: Record<string, string | number | boolean | undefined>): string {
|
|
18
|
+
if (!query) return '';
|
|
19
|
+
const search = new URLSearchParams();
|
|
20
|
+
for (const [k, v] of Object.entries(query)) {
|
|
21
|
+
if (v !== undefined && v !== '') search.set(k, String(v));
|
|
22
|
+
}
|
|
23
|
+
const s = search.toString();
|
|
24
|
+
return s ? `?${s}` : '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RequestTransportOptions<T = unknown> {
|
|
28
|
+
params?: Record<string, string>;
|
|
29
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
30
|
+
body?: T;
|
|
31
|
+
skipGlobalIndicator?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function sendRequest<T = unknown, R = unknown>(
|
|
35
|
+
endpoint: EndpointDef,
|
|
36
|
+
options: RequestTransportOptions<T> = {},
|
|
37
|
+
): Promise<R> {
|
|
38
|
+
const { params, query, body, skipGlobalIndicator } = options;
|
|
39
|
+
const url = buildPath(endpoint.path, params) + buildQuery(query);
|
|
40
|
+
const config: {
|
|
41
|
+
method: string;
|
|
42
|
+
url: string;
|
|
43
|
+
data?: T;
|
|
44
|
+
skipGlobalIndicator?: boolean;
|
|
45
|
+
} = {
|
|
46
|
+
method: endpoint.method,
|
|
47
|
+
url,
|
|
48
|
+
skipGlobalIndicator,
|
|
49
|
+
};
|
|
50
|
+
if (
|
|
51
|
+
body !== undefined &&
|
|
52
|
+
(endpoint.method === 'POST' || endpoint.method === 'PUT' || endpoint.method === 'PATCH')
|
|
53
|
+
) {
|
|
54
|
+
config.data = body;
|
|
55
|
+
}
|
|
56
|
+
const axiosConfig = {
|
|
57
|
+
...config,
|
|
58
|
+
...(endpoint.timeoutMs != null ? { timeout: endpoint.timeoutMs } : {}),
|
|
59
|
+
} as Parameters<typeof apiClient.request>[0];
|
|
60
|
+
|
|
61
|
+
const response = await apiClient.request<R>(axiosConfig);
|
|
62
|
+
const raw = response.data;
|
|
63
|
+
const shaped = endpoint.post ? endpoint.post(raw) : raw;
|
|
64
|
+
return shaped as R;
|
|
65
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* methods.ts / RootStore.execute — RootStore 직접 참조 없이 순환 방지
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { EndpointDef } from '@shared/endpoint';
|
|
6
|
+
import type { ParamScreenSchema, ParamsSchema } from '../params/types';
|
|
7
|
+
import type { ParamStore } from '../params/param-store';
|
|
8
|
+
import type { SessionStore } from '../store/session-store';
|
|
9
|
+
|
|
10
|
+
/** methods 콜백에서 받는 store — RootStore 가 이 형태를 만족 */
|
|
11
|
+
export interface ApiMethodStore {
|
|
12
|
+
paramStore: ParamStore;
|
|
13
|
+
sessionStore: SessionStore;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type { ParamsSchema, ParamScreenSchema };
|
|
17
|
+
|
|
18
|
+
export type CoreSdkWrapped<T> = {
|
|
19
|
+
data?: T[] | T;
|
|
20
|
+
code?: number;
|
|
21
|
+
error?: unknown;
|
|
22
|
+
success?: boolean;
|
|
23
|
+
message?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface ExecuteContext {
|
|
27
|
+
redirectTo?: string;
|
|
28
|
+
navigate?: (path: string, options?: { replace?: boolean }) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApiMethodUiConfig {
|
|
32
|
+
indicatorMessage?: string;
|
|
33
|
+
successMessage?: string;
|
|
34
|
+
errorMessage?: string;
|
|
35
|
+
showIndicator?: boolean;
|
|
36
|
+
showSuccessToast?: boolean;
|
|
37
|
+
showErrorToast?: boolean;
|
|
38
|
+
unwrapDataArray?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ScreenAuthType = 'guest' | 'protected';
|
|
42
|
+
|
|
43
|
+
export interface ScreenMeta {
|
|
44
|
+
path: string;
|
|
45
|
+
auth: ScreenAuthType;
|
|
46
|
+
index?: boolean;
|
|
47
|
+
page: string;
|
|
48
|
+
bottomNav?: boolean;
|
|
49
|
+
label?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ApiMethodEndpointCall<TPayload = unknown, TStore = ApiMethodStore> {
|
|
53
|
+
endpoint: EndpointDef;
|
|
54
|
+
body?: (stores: TStore, payload?: TPayload) => unknown;
|
|
55
|
+
query?: (
|
|
56
|
+
stores: TStore,
|
|
57
|
+
payload?: TPayload,
|
|
58
|
+
) => Record<string, string | number | boolean | undefined>;
|
|
59
|
+
params?: (stores: TStore, payload?: TPayload) => Record<string, string>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ApiMethodConfig<TPayload = unknown, TResult = unknown, TStore = ApiMethodStore> {
|
|
63
|
+
endpoint?: EndpointDef;
|
|
64
|
+
endpoints?: ApiMethodEndpointCall<TPayload, TStore>[];
|
|
65
|
+
schema: ParamScreenSchema;
|
|
66
|
+
screen?: ScreenMeta;
|
|
67
|
+
ui?: ApiMethodUiConfig;
|
|
68
|
+
body?: (stores: TStore, payload?: TPayload) => unknown;
|
|
69
|
+
query?: (
|
|
70
|
+
stores: TStore,
|
|
71
|
+
payload?: TPayload,
|
|
72
|
+
) => Record<string, string | number | boolean | undefined>;
|
|
73
|
+
params?: (stores: TStore, payload?: TPayload) => Record<string, string>;
|
|
74
|
+
validate?: (stores: TStore, payload?: TPayload) => boolean;
|
|
75
|
+
post?: (result: TResult, stores: TStore, payload?: TPayload) => void;
|
|
76
|
+
send?: (store: TStore) => Promise<unknown> | unknown;
|
|
77
|
+
redirectPath?:
|
|
78
|
+
| string
|
|
79
|
+
| ((result: TResult, payload?: TPayload, ctx?: ExecuteContext) => string);
|
|
80
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RootStore 컨텍스트 — useStore / useParamStore / useSendMessage 등
|
|
3
|
+
* (싱글톤 rootStore 이므로 Provider 없이도 rootStore 직접 import 가능)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { createContext, useCallback, useContext } from 'react';
|
|
7
|
+
import type { EndpointDef } from '@shared/endpoint';
|
|
8
|
+
import type { SendMessageOptions } from '../types/ui';
|
|
9
|
+
import { rootStore } from '../store/root-store';
|
|
10
|
+
|
|
11
|
+
type RootStoreApi = typeof rootStore;
|
|
12
|
+
|
|
13
|
+
const StoreContext = createContext<RootStoreApi | null>(null);
|
|
14
|
+
|
|
15
|
+
export function useStore(): RootStoreApi {
|
|
16
|
+
const ctx = useContext(StoreContext);
|
|
17
|
+
if (!ctx) throw new Error('StoreProvider is required');
|
|
18
|
+
return ctx;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
22
|
+
return <StoreContext.Provider value={rootStore}>{children}</StoreContext.Provider>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useSendMessage() {
|
|
26
|
+
const store = useStore();
|
|
27
|
+
return useCallback(
|
|
28
|
+
function sendMessage<T = unknown, R = unknown>(endpoint: EndpointDef, options?: SendMessageOptions<T>) {
|
|
29
|
+
return store.sendMessage<T, R>(endpoint, options ?? {});
|
|
30
|
+
},
|
|
31
|
+
[store],
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useParamStore() {
|
|
36
|
+
return useStore().paramStore;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useSessionStore() {
|
|
40
|
+
return useStore().sessionStore;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useRootStore(): RootStoreApi {
|
|
44
|
+
return useStore();
|
|
45
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { reaction } from 'mobx';
|
|
2
|
+
import { useSyncExternalStore } from 'react';
|
|
3
|
+
import { rootStore } from '../store/root-store';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zustand 스타일 셀렉터 — RootStore(MobX) 구독
|
|
7
|
+
* 예: `useAppStore((s) => s.lastHealthAt)`, `useAppStore((s) => s.setLastHealthAt)`
|
|
8
|
+
*/
|
|
9
|
+
export function useAppStore<T>(selector: (store: typeof rootStore) => T): T {
|
|
10
|
+
return useSyncExternalStore(
|
|
11
|
+
(onChange) => {
|
|
12
|
+
const dispose = reaction(
|
|
13
|
+
() => selector(rootStore),
|
|
14
|
+
() => {
|
|
15
|
+
onChange();
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
return () => dispose();
|
|
19
|
+
},
|
|
20
|
+
() => selector(rootStore),
|
|
21
|
+
() => selector(rootStore),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
|
+
import { configureApi } from '../api/client';
|
|
2
3
|
import { fetchHealth } from '../methods/health';
|
|
3
4
|
import { safeJsonStringify } from '../utils/safeJsonStringify';
|
|
4
|
-
import { useAppStore } from '
|
|
5
|
+
import { useAppStore } from './useAppStore';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* @param apiBaseUrl 선택 — 넘기면 `configureApi` 로 baseURL 갱신. 앱 entry 에서 이미 `configureApi` 했다면 생략 가능.
|
|
9
|
+
*/
|
|
10
|
+
export function useHealthStatus(apiBaseUrl?: string) {
|
|
7
11
|
const setLastHealthAt = useAppStore((s) => s.setLastHealthAt);
|
|
8
12
|
const [text, setText] = useState('loading…');
|
|
9
13
|
const [error, setError] = useState<string | null>(null);
|
|
10
14
|
|
|
11
15
|
useEffect(() => {
|
|
16
|
+
if (apiBaseUrl != null && apiBaseUrl !== '') {
|
|
17
|
+
configureApi({ baseURL: apiBaseUrl });
|
|
18
|
+
}
|
|
12
19
|
let cancelled = false;
|
|
13
|
-
|
|
20
|
+
setError(null);
|
|
21
|
+
setText('loading…');
|
|
22
|
+
fetchHealth()
|
|
14
23
|
.then((body) => {
|
|
15
24
|
if (!cancelled) {
|
|
16
25
|
setText(safeJsonStringify(body, 2));
|
|
@@ -1,8 +1,60 @@
|
|
|
1
1
|
export { Shell, type ShellProps } from './components/Shell';
|
|
2
2
|
export { useHealthStatus } from './hooks/useHealthStatus';
|
|
3
|
-
export { useAppStore } from './
|
|
3
|
+
export { useAppStore } from './hooks/useAppStore';
|
|
4
|
+
export {
|
|
5
|
+
StoreProvider,
|
|
6
|
+
useStore,
|
|
7
|
+
useRootStore,
|
|
8
|
+
useSendMessage,
|
|
9
|
+
useParamStore,
|
|
10
|
+
useSessionStore,
|
|
11
|
+
} from './context/StoreProvider';
|
|
12
|
+
|
|
13
|
+
export { configureApi, getApiBaseUrl, setApiHooks, apiClient } from './api/client';
|
|
14
|
+
export { sendRequest, type RequestTransportOptions } from './api/request';
|
|
15
|
+
export type { EndpointDef, HttpMethod } from '@shared/endpoint';
|
|
16
|
+
export { healthEndpoint, appMetaEndpoint, unwrapResponseData } from '@shared/endpoint';
|
|
17
|
+
export type {
|
|
18
|
+
ApiMethodConfig,
|
|
19
|
+
ApiMethodStore,
|
|
20
|
+
ApiMethodEndpointCall,
|
|
21
|
+
CoreSdkWrapped,
|
|
22
|
+
ExecuteContext,
|
|
23
|
+
ApiMethodUiConfig,
|
|
24
|
+
ScreenMeta,
|
|
25
|
+
ScreenAuthType,
|
|
26
|
+
} from './api/types';
|
|
27
|
+
export {
|
|
28
|
+
methods,
|
|
29
|
+
screenParamSchemas,
|
|
30
|
+
type MethodName,
|
|
31
|
+
} from './api/methods';
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
rootStore,
|
|
35
|
+
RootStore,
|
|
36
|
+
sessionStore,
|
|
37
|
+
SessionStore,
|
|
38
|
+
apiCacheStore,
|
|
39
|
+
ApiCacheStore,
|
|
40
|
+
observer,
|
|
41
|
+
} from './store';
|
|
42
|
+
|
|
43
|
+
/** 이전 `appStore` 싱글톤 명칭 호환 */
|
|
44
|
+
export { rootStore as appStore } from './store';
|
|
45
|
+
|
|
46
|
+
export { ParamStore, type ParamStoreOptions, type ParamFieldState } from './params/param-store';
|
|
47
|
+
export type { ParamsSchema, ParamScreenSchema, ParamFieldDef, ValidRule } from './params/types';
|
|
48
|
+
|
|
4
49
|
export { fetchHealth } from './methods/health';
|
|
5
50
|
export { getJson } from './lib/http';
|
|
6
51
|
export { safeJsonStringify } from './utils/safeJsonStringify';
|
|
7
52
|
export { FALLBACK_API_BASE_URL } from './constants/app';
|
|
8
53
|
export type { CommonEnvelope, HealthData } from './types/api';
|
|
54
|
+
export type {
|
|
55
|
+
ToastSeverity,
|
|
56
|
+
ToastItem,
|
|
57
|
+
ApiErrorItem,
|
|
58
|
+
IndicatorState,
|
|
59
|
+
SendMessageOptions,
|
|
60
|
+
} from './types/ui';
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import type { AxiosRequestConfig } from 'axios';
|
|
2
|
+
import { apiClient } from '../api/client';
|
|
3
|
+
|
|
4
|
+
export async function getJson<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
5
|
+
const res = await apiClient.get<T>(url, {
|
|
6
|
+
...config,
|
|
7
|
+
headers: { Accept: 'application/json', ...config?.headers },
|
|
8
|
+
});
|
|
9
|
+
return res.data;
|
|
7
10
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sendRequest } from '../api/request';
|
|
2
|
+
import { healthEndpoint } from '@shared/endpoint';
|
|
2
3
|
import type { CommonEnvelope, HealthData } from '../types/api';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
return
|
|
5
|
+
/** 직접 호출용 (토스트 없음) — UI 훅은 보통 이 함수 사용 */
|
|
6
|
+
export async function fetchHealth(): Promise<CommonEnvelope<HealthData>> {
|
|
7
|
+
return sendRequest<unknown, CommonEnvelope<HealthData>>(healthEndpoint, {});
|
|
7
8
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { makeAutoObservable, runInAction } from 'mobx';
|
|
2
|
+
import type { ParamFieldDef, ParamScreenSchema, ParamsSchema } from './types';
|
|
3
|
+
import { validateField } from './validations';
|
|
4
|
+
|
|
5
|
+
export interface ParamStoreOptions {
|
|
6
|
+
onValidationFail?: (message: string) => void;
|
|
7
|
+
getStore?: () => unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ParamFieldState {
|
|
11
|
+
value: string | number | boolean | null;
|
|
12
|
+
valid: ParamFieldDef['valid'];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toFieldState(def: ParamFieldDef): ParamFieldState {
|
|
16
|
+
return {
|
|
17
|
+
value: def.value ?? '',
|
|
18
|
+
valid: def.valid ?? [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ParamStore {
|
|
23
|
+
params: Record<string, Record<string, ParamFieldState>> = {};
|
|
24
|
+
fieldErrors: Record<string, Record<string, string>> = {};
|
|
25
|
+
screenErrors: Record<string, string> = {};
|
|
26
|
+
private _onValidationFail?: (message: string) => void;
|
|
27
|
+
private _getStore?: () => unknown;
|
|
28
|
+
|
|
29
|
+
constructor(initialSchema?: ParamsSchema, options?: ParamStoreOptions) {
|
|
30
|
+
makeAutoObservable(this, {}, { autoBind: true });
|
|
31
|
+
this._onValidationFail = options?.onValidationFail;
|
|
32
|
+
this._getStore = options?.getStore;
|
|
33
|
+
if (initialSchema) {
|
|
34
|
+
for (const [screen, fields] of Object.entries(initialSchema)) {
|
|
35
|
+
this.registerScreen(screen, fields);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
registerScreen(screen: string, schema: ParamScreenSchema): void {
|
|
41
|
+
runInAction(() => {
|
|
42
|
+
if (!this.params[screen]) this.params[screen] = {};
|
|
43
|
+
for (const [field, def] of Object.entries(schema)) {
|
|
44
|
+
this.params[screen][field] = toFieldState(def);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getParam(screen: string, field: string): string | number | boolean | null {
|
|
50
|
+
return this.params[screen]?.[field]?.value ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getScreenValues(screen: string): Record<string, string | number | boolean | null> {
|
|
54
|
+
const row = this.params[screen];
|
|
55
|
+
if (!row) return {};
|
|
56
|
+
return Object.fromEntries(Object.entries(row).map(([k, v]) => [k, v.value])) as Record<
|
|
57
|
+
string,
|
|
58
|
+
string | number | boolean | null
|
|
59
|
+
>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setParam(payload: { screen: string; field: string; value: string | number | boolean | null }):
|
|
63
|
+
| { ok: true }
|
|
64
|
+
| { ok: false; message: string; field: string } {
|
|
65
|
+
const { screen, field, value } = payload;
|
|
66
|
+
const state = this.params[screen]?.[field];
|
|
67
|
+
if (!state) {
|
|
68
|
+
runInAction(() => {
|
|
69
|
+
if (!this.params[screen]) this.params[screen] = {};
|
|
70
|
+
this.params[screen][field] = { value, valid: [] };
|
|
71
|
+
});
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = validateField(value, state.valid, this._getStore);
|
|
76
|
+
runInAction(() => {
|
|
77
|
+
state.value = value;
|
|
78
|
+
if (this.screenErrors[screen]) delete this.screenErrors[screen];
|
|
79
|
+
if (!result.ok) {
|
|
80
|
+
const msg = result.message ?? 'validation.failed';
|
|
81
|
+
if (!this.fieldErrors[screen]) this.fieldErrors[screen] = {};
|
|
82
|
+
this.fieldErrors[screen][field] = msg;
|
|
83
|
+
this._onValidationFail?.(msg);
|
|
84
|
+
} else if (this.fieldErrors[screen]?.[field]) {
|
|
85
|
+
delete this.fieldErrors[screen][field];
|
|
86
|
+
if (Object.keys(this.fieldErrors[screen]).length === 0) delete this.fieldErrors[screen];
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return result.ok
|
|
90
|
+
? { ok: true as const }
|
|
91
|
+
: { ok: false as const, message: result.message ?? 'validation.failed', field };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getFieldError(screen: string, field: string): string | undefined {
|
|
95
|
+
return this.fieldErrors[screen]?.[field];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setScreenError(screen: string, message: string): void {
|
|
99
|
+
runInAction(() => {
|
|
100
|
+
if (message) this.screenErrors[screen] = message;
|
|
101
|
+
else if (this.screenErrors[screen]) delete this.screenErrors[screen];
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getScreenError(screen: string): string | undefined {
|
|
106
|
+
return this.screenErrors[screen];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
isScreenValid(screen: string): boolean {
|
|
110
|
+
const row = this.params[screen];
|
|
111
|
+
if (!row) return true;
|
|
112
|
+
for (const [, s] of Object.entries(row)) {
|
|
113
|
+
const res = validateField(s.value, s.valid, this._getStore);
|
|
114
|
+
if (!res.ok) return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setParams(
|
|
120
|
+
screen: string,
|
|
121
|
+
updates: Record<string, string | number | boolean | null>,
|
|
122
|
+
): { ok: true } | { ok: false; message: string; field: string } {
|
|
123
|
+
const state = this.params[screen];
|
|
124
|
+
if (!state) {
|
|
125
|
+
runInAction(() => {
|
|
126
|
+
this.params[screen] = {};
|
|
127
|
+
for (const [field, value] of Object.entries(updates)) {
|
|
128
|
+
this.params[screen][field] = { value, valid: [] };
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return { ok: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const [field, value] of Object.entries(updates)) {
|
|
135
|
+
const s = state[field];
|
|
136
|
+
if (s) {
|
|
137
|
+
const res = validateField(value, s.valid, this._getStore);
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
const msg = res.message ?? 'validation.failed';
|
|
140
|
+
runInAction(() => {
|
|
141
|
+
if (!this.fieldErrors[screen]) this.fieldErrors[screen] = {};
|
|
142
|
+
this.fieldErrors[screen][field] = msg;
|
|
143
|
+
});
|
|
144
|
+
this._onValidationFail?.(msg);
|
|
145
|
+
return { ok: false, message: msg, field };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
runInAction(() => {
|
|
151
|
+
for (const [field, value] of Object.entries(updates)) {
|
|
152
|
+
if (state[field]) {
|
|
153
|
+
state[field].value = value;
|
|
154
|
+
if (this.fieldErrors[screen]?.[field]) {
|
|
155
|
+
delete this.fieldErrors[screen][field];
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
this.params[screen][field] = { value, valid: [] };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (this.fieldErrors[screen] && Object.keys(this.fieldErrors[screen]).length === 0) {
|
|
162
|
+
delete this.fieldErrors[screen];
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
return { ok: true };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
resetScreen(screen: string, schema: ParamScreenSchema): void {
|
|
169
|
+
runInAction(() => {
|
|
170
|
+
this.params[screen] = {};
|
|
171
|
+
for (const [field, def] of Object.entries(schema)) {
|
|
172
|
+
this.params[screen][field] = toFieldState(def);
|
|
173
|
+
}
|
|
174
|
+
if (this.fieldErrors[screen]) delete this.fieldErrors[screen];
|
|
175
|
+
if (this.screenErrors[screen]) delete this.screenErrors[screen];
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ValidRule {
|
|
2
|
+
min?: number;
|
|
3
|
+
max?: number;
|
|
4
|
+
expr?: RegExp | ((value: string, stores?: unknown) => boolean);
|
|
5
|
+
message?: string;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ParamFieldDef {
|
|
10
|
+
value?: string | number | boolean | null;
|
|
11
|
+
valid: ValidRule[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ParamScreenSchema = Record<string, ParamFieldDef>;
|
|
15
|
+
export type ParamsSchema = Record<string, ParamScreenSchema>;
|
|
16
|
+
|
|
17
|
+
export interface ValidateResult {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
message?: string;
|
|
20
|
+
field?: string;
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ValidRule, ValidateResult } from './types';
|
|
2
|
+
|
|
3
|
+
export function validateField(value: unknown, rules: ValidRule[], getStore?: () => unknown): ValidateResult {
|
|
4
|
+
const raw = value === undefined || value === null ? '' : String(value).trim();
|
|
5
|
+
|
|
6
|
+
for (const rule of rules) {
|
|
7
|
+
if (rule.required && (raw === '' || value === undefined || value === null)) {
|
|
8
|
+
return { ok: false, message: rule.message ?? 'validation.required', field: undefined };
|
|
9
|
+
}
|
|
10
|
+
if (!rule.required && raw === '') continue;
|
|
11
|
+
|
|
12
|
+
if (rule.min !== undefined && raw.length < rule.min) {
|
|
13
|
+
return { ok: false, message: rule.message ?? 'validation.minLength', field: undefined };
|
|
14
|
+
}
|
|
15
|
+
if (rule.max !== undefined && raw.length > rule.max) {
|
|
16
|
+
return { ok: false, message: rule.message ?? 'validation.maxLength', field: undefined };
|
|
17
|
+
}
|
|
18
|
+
if (rule.expr) {
|
|
19
|
+
if (rule.expr instanceof RegExp) {
|
|
20
|
+
if (!rule.expr.test(raw)) {
|
|
21
|
+
return { ok: false, message: rule.message ?? 'validation.format', field: undefined };
|
|
22
|
+
}
|
|
23
|
+
} else if (typeof rule.expr === 'function') {
|
|
24
|
+
const stores = getStore?.();
|
|
25
|
+
if (!rule.expr(raw, stores)) {
|
|
26
|
+
return { ok: false, message: rule.message ?? 'validation.format', field: undefined };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { ok: true };
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { makeAutoObservable } from 'mobx';
|
|
2
|
+
|
|
3
|
+
interface CacheEntry<T = unknown> {
|
|
4
|
+
value: T;
|
|
5
|
+
staleAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class ApiCacheStore {
|
|
9
|
+
private _entries = new Map<string, CacheEntry>();
|
|
10
|
+
|
|
11
|
+
constructor() {
|
|
12
|
+
makeAutoObservable(this);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get<T = unknown>(key: string): T | undefined {
|
|
16
|
+
const entry = this._entries.get(key);
|
|
17
|
+
if (!entry) return undefined;
|
|
18
|
+
if (entry.staleAt > 0 && Date.now() > entry.staleAt) {
|
|
19
|
+
this._entries.delete(key);
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return entry.value as T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
set<T = unknown>(key: string, value: T, ttlMs?: number): void {
|
|
26
|
+
const staleAt = ttlMs != null ? Date.now() + ttlMs : 0;
|
|
27
|
+
this._entries.set(key, { value, staleAt });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear(): void {
|
|
31
|
+
this._entries.clear();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const apiCacheStore = new ApiCacheStore();
|