@xfilecom/xframe 0.1.34 → 0.1.35
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/defaults.json +2 -2
- package/package.json +1 -1
- package/template/README.md +1 -1
- 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 -0
- package/template/web/admin/vite.config.ts +2 -0
- 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 -0
- package/template/web/client/vite.config.ts +4 -0
- 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
|
@@ -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();
|