@iyulab/enterprise 0.12.0 → 0.14.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.0] - 2026-09-20
4
+
5
+ ### Fixed
6
+
7
+ - **A failed `api*` write told the user nothing.** `notify.error` was wired into the OData writes
8
+ only, so a custom REST write that came back 4xx threw an `ApiError` and left the screen exactly as
9
+ it was — indistinguishable from nothing having happened, even when the server had returned a
10
+ precise reason. The four `api*` writes now notify on failure and rethrow, on the same terms as
11
+ their OData siblings (401 excluded, since `onUnauthorized` already covers it). Reads are unchanged
12
+ and still silent on both sides.
13
+
14
+ ### Added
15
+
16
+ - **`apiPostQuiet`, `apiPutQuiet`, `apiPatchQuiet`, `apiDeleteQuiet`.** The opt-out for the above,
17
+ matching the existing `odata*Quiet` naming: use them when one user action issues several requests,
18
+ or when the calling code already reports failures itself and would otherwise show two messages.
19
+
20
+ ### Documentation
21
+
22
+ - **The README said which hooks exist but not where they apply**, which read as "wire `notify` and
23
+ failures become visible". It now carries a per-method table and states the axis: writes notify,
24
+ reads do not, `*Quiet` opts out, 401 never notifies, and `api*` has no success message because the
25
+ library cannot invent wording for an arbitrary endpoint.
26
+
27
+ ## 0.13.0
28
+
29
+ ### Changed
30
+
31
+ - **`FormRow`'s grid tracks are now `minmax(0, 1fr)` rather than `1fr`, so columns declared
32
+ equal actually render equal.** A grid item's default `min-width: auto` refuses to shrink below
33
+ its content's min-content width, so one long value in a row was enough to blow out its own
34
+ column and squeeze the rest. Measured in a 400px container with one long unbreakable string:
35
+ a two-column row laid out at **425px / 8px** and a three-column row at **425px / 8px / 8px**,
36
+ and the row overflowed its container. With `minmax(0, 1fr)` the same cases lay out at
37
+ **196 / 196** and **128 / 128 / 128**. Nothing about the API changes, and rows whose content
38
+ already fit are unaffected.
39
+ - Long unbreakable content still overflows its own column — that is a separate axis, handled
40
+ on the cell with `overflow-wrap` (this package's peer elements already set it).
41
+
42
+ - **`@iyulab/components` is no longer an optional peer dependency.** The main entry imports it
43
+ unconditionally, so declaring it optional meant a consumer installing this package alone got
44
+ nothing installed and no warning — the failure surfaced only as a module-not-found at
45
+ runtime. Consumers already installing it are unaffected.
46
+
3
47
  ## 0.12.0
4
48
 
5
49
  ### Added
package/README.md CHANGED
@@ -51,6 +51,8 @@ import { FormSection, FormRow } from '@iyulab/enterprise'
51
51
  ```
52
52
 
53
53
  - `FormRow`는 기본 2컬럼 그리드입니다. 한 칸을 차지하려면 `full`을, 다른 열 수가 필요하면 `columns`를 씁니다.
54
+ - **열 폭 — 선언한 열은 실제로 균등합니다.** 트랙은 `1fr`이 아니라 `minmax(0, 1fr)`입니다. 그리드 아이템의 기본 `min-width: auto`는 내용의 min-content 아래로 줄어들기를 거부해서, 순수 `1fr`로 두면 긴 내용이 든 칸만 부풀고 나머지가 찌그러집니다(400px 컨테이너 실측: 2열 **425/8**, 3열 **425/8/8**). `minmax(0, 1fr)`은 트랙 최소를 0으로 만들어 같은 경우를 **196/196**·**128/128/128**로 만듭니다.
55
+ - ⚠**긴 불가분 문자열 자체의 넘침은 별개 축입니다.** 칸이 균등해져도 그 안의 긴 문자열은 자기 칸을 넘습니다 — 셀 쪽에서 `overflow-wrap`으로 다룹니다(`@iyulab/components`의 엘리먼트는 이미 그 값을 갖습니다).
54
56
  - 두 컴포넌트 모두 `className`·`style`을 받아 기본값 **뒤에** 병합합니다 — 블록을 복제하지 않고 조정하는 정규 경로입니다. `FormSection`은 제목 줄만 바꾸는 `titleStyle`도 받습니다.
55
57
 
56
58
  ```tsx
@@ -119,10 +121,29 @@ await svc.apiPost<Order>('orders/7/attachments', form)
119
121
  | `baseUrl` | 모든 요청의 오리진 (필수) |
120
122
  | `odataPrefix` / `apiPrefix` | 엔드포인트 prefix (기본 `$data` / `api`) |
121
123
  | `onUnauthorized(status)` | 401 시 호출 — 리다이렉트/재진입 가드는 앱이 처리 |
122
- | `notify.success/error` | 토스트 훅 (생략 시 토스트 없음 — 순수) |
124
+ | `notify.success/error` | 토스트 훅 (생략 시 토스트 없음 — 순수). **메서드마다 걸리는 방식이 다르다 — 바로 아래 표 참조** |
123
125
  | `messages` | 사용자 대면 문구 (기본 영어, 지정 키만 대체) |
124
126
  | `formatError(info)` | 에러 메시지 포매팅 오버라이드 (앱별 정책) — `info` 는 `status`/`statusText`/`rawMessage`/`details`(검증된 `error.details`)/`body` 를 받는다 |
125
127
 
128
+ #### `notify` 가 걸리는 자리 — 축은 «쓰기 ↔ 읽기» 다
129
+
130
+ | 메서드 | 실패 시 `error` | 성공 시 `success` |
131
+ |---|---|---|
132
+ | `odataPost` · `odataPatch` · `odataDelete` | ✅ (401 제외) | ✅ `saved`·`updated`·`deleted` |
133
+ | `apiPost` · `apiPut` · `apiPatch` · `apiDelete` | ✅ (401 제외) | ❌ |
134
+ | `*Quiet` 전부 (`odataPostQuiet` … `apiDeleteQuiet`) | ❌ | ❌ |
135
+ | `odataGet` · `odataGetById` · `odataCount` · `apiGet` · `fetchRaw` | ❌ | ❌ |
136
+
137
+ - **조회는 통지하지 않는다.** 빈 화면 자체가 신호이고, 목록을 열 때마다 토스트가 뜨면 읽을 수 없다.
138
+ - **쓰기는 통지한다.** 결과가 화면에 안 보일 수 있기 때문이다 — 서버가 409 와 사유를 돌려줘도,
139
+ 통지가 없으면 아무 일도 일어나지 않은 화면과 구별되지 않는다.
140
+ - **401 은 통지하지 않는다** — `onUnauthorized` 가 이미 안내하므로 겹친다.
141
+ - **`api*` 에는 성공 토스트가 없다.** 임의의 RPC(상태 전이·발행·업로드)를 태우는 경로라
142
+ «저장되었습니다» 같은 문구를 라이브러리가 지어낼 수 없다. 실패 메시지는 서버가 주므로 어느
143
+ 엔드포인트에서나 뜻이 통하지만, 성공 문구는 그렇지 않다 — 필요하면 호출한 쪽이 띄운다.
144
+ - **이미 자기 래퍼로 통지하고 있다면 `*Quiet` 로 바꾼다.** 이중 토스트를 막는 탈출구이고,
145
+ `odata*Quiet` 와 같은 관용구다(한 사용자 액션이 여러 요청을 낼 때도 같은 것을 쓴다).
146
+
126
147
  > 도메인 액션(상태 전이 등)·엔티티 목록·권한 코드는 라이브러리에 넣지 말고 앱 adapter 에 둔다.
127
148
 
128
149
  #### 실패 응답 — `ApiError`
package/dist/FormRow.d.ts CHANGED
@@ -5,6 +5,22 @@ import { CSSProperties, ReactNode } from 'react';
5
5
  * 오버라이드 계약(docs/lob-layers.md §2)에 따라 `className`·`style` 을 받아 병합한다.
6
6
  * `columns` 는 2열 고정이 맞지 않는 소비자가 **컴포넌트를 복제하지 않고** 조정하는 경로다
7
7
  * — 복제가 시작되면 이 계층은 쓰기 전보다 나쁜 상태를 만든다.
8
+ *
9
+ * 🔴**트랙은 `minmax(0, 1fr)` 이지 `1fr` 이 아니다 — 순수 `1fr` 은 «균등» 을 약속하고**
10
+ * **지키지 않는다.** 그리드 아이템의 기본 `min-width: auto` 는 내용의 min-content 아래로
11
+ * 줄어들기를 거부하므로, 한 칸에 긴 내용이 들어오면 그 칸만 부풀고 나머지가 찌그러진다.
12
+ * 실측(400px 컨테이너 · 한 칸에 긴 불가분 문자열): 2열이 **425/8**, 3열이 **425/8/8** 이었고
13
+ * 행 자체가 컨테이너를 넘쳤다. `minmax(0, 1fr)` 은 트랙의 최소를 0 으로 만들어 **자식을**
14
+ * **건드리지 않고** 이를 고친다 — 같은 실측에서 **196/196** · **128/128/128**.
15
+ *
16
+ * ⚠**긴 불가분 내용 자체의 넘침은 별개 축이고 이 컴포넌트의 몫이 아니다.** 칸이 균등해져도
17
+ * 그 안의 긴 문자열은 여전히 자기 칸을 넘는다 — 그것은 셀의 `overflow-wrap` 이 답이다
18
+ * (`@iyulab/components` 의 엘리먼트는 이미 그 값을 갖는다). 같은 실측에서 `overflow-wrap` 을
19
+ * 주면 행의 넘침이 0 이 됐다.
20
+ *
21
+ * 계약은 `tests/form-layout-contract.test.ts` 가 고정한다 — 다만 그 파일은 **«선언» 을 재지**
22
+ * **«배치» 를 재지 않는다**(이 패키지엔 레이아웃을 계산하는 테스트 자리가 없다). 위 수치는
23
+ * 형제 패키지의 브라우저 프로젝트를 계측기로 빌린 일회성 탐침으로 쟀다.
8
24
  */
9
25
  export declare function FormRow({ children, full, columns, className, style, }: {
10
26
  children: ReactNode;
@@ -94,16 +94,28 @@ export interface ODataService {
94
94
  odataDelete(entity: string, id: string): Promise<void>;
95
95
  /** custom REST GET — 204 등 빈 바디를 안전 파싱. */
96
96
  apiGet<T>(path: string): Promise<T>;
97
- /** custom REST POST. `body`가 `FormData` 인스턴스면 그대로(직렬화 없이) 멀티파트로
98
- * 전송된다 — `@iyulab/http-client`가 Content-Type을 브라우저 자동 설정에 맡기고
99
- * JSON 직렬화 분기를 타지 않는다. `apiPut`/`apiPatch`도 동일하게 동작한다. */
97
+ /** custom REST POST 실패 시 `error` 토스트 후 rethrow(401 제외). `body`가 `FormData`
98
+ * 인스턴스면 그대로(직렬화 없이) 멀티파트로 전송된다 — `@iyulab/http-client`가
99
+ * Content-Type을 브라우저 자동 설정에 맡기고 JSON 직렬화 분기를 타지 않는다.
100
+ * `apiPut`/`apiPatch`도 동일하게 동작한다. */
100
101
  apiPost<T>(path: string, body?: unknown): Promise<T>;
101
- /** custom REST PUT(리소스 전체 교체/생성). `body`의 `FormData` 처리는 `apiPost` 참조. */
102
+ /** custom REST POST 토스트 없이 결과만(`odataPostQuiet`와 같은 이유: 사용자 액션이
103
+ * 여러 요청을 내거나, 소비자가 자기 래퍼로 이미 통지할 때). */
104
+ apiPostQuiet<T>(path: string, body?: unknown): Promise<T>;
105
+ /** custom REST PUT(리소스 전체 교체/생성) — 실패 시 `error` 토스트 후 rethrow(401 제외).
106
+ * `body`의 `FormData` 처리는 `apiPost` 참조. */
102
107
  apiPut<T>(path: string, body?: unknown): Promise<T>;
103
- /** custom REST PATCH. `body`의 `FormData` 처리는 `apiPost` 참조. */
108
+ /** custom REST PUT 토스트 없이 결과만. */
109
+ apiPutQuiet<T>(path: string, body?: unknown): Promise<T>;
110
+ /** custom REST PATCH — 실패 시 `error` 토스트 후 rethrow(401 제외). `body`의 `FormData`
111
+ * 처리는 `apiPost` 참조. */
104
112
  apiPatch<T>(path: string, body?: unknown): Promise<T>;
105
- /** custom REST DELETE대부분 204 No Content. */
113
+ /** custom REST PATCH토스트 없이 결과만. */
114
+ apiPatchQuiet<T>(path: string, body?: unknown): Promise<T>;
115
+ /** custom REST DELETE — 실패 시 `error` 토스트 후 rethrow(401 제외). 대부분 204 No Content. */
106
116
  apiDelete<T = void>(path: string): Promise<T>;
117
+ /** custom REST DELETE — 토스트 없이 결과만. */
118
+ apiDeleteQuiet<T = void>(path: string): Promise<T>;
107
119
  /** URL 을 직접 조립한 커스텀 조회(csv-export 등)를 위해 raw 응답을 반환. */
108
120
  fetchRaw(url: string): Promise<HttpResponse>;
109
121
  /**
package/dist/index.js CHANGED
@@ -59,6 +59,22 @@ function FormSection({ title, children, className, style, titleStyle }) {
59
59
  * 오버라이드 계약(docs/lob-layers.md §2)에 따라 `className`·`style` 을 받아 병합한다.
60
60
  * `columns` 는 2열 고정이 맞지 않는 소비자가 **컴포넌트를 복제하지 않고** 조정하는 경로다
61
61
  * — 복제가 시작되면 이 계층은 쓰기 전보다 나쁜 상태를 만든다.
62
+ *
63
+ * 🔴**트랙은 `minmax(0, 1fr)` 이지 `1fr` 이 아니다 — 순수 `1fr` 은 «균등» 을 약속하고**
64
+ * **지키지 않는다.** 그리드 아이템의 기본 `min-width: auto` 는 내용의 min-content 아래로
65
+ * 줄어들기를 거부하므로, 한 칸에 긴 내용이 들어오면 그 칸만 부풀고 나머지가 찌그러진다.
66
+ * 실측(400px 컨테이너 · 한 칸에 긴 불가분 문자열): 2열이 **425/8**, 3열이 **425/8/8** 이었고
67
+ * 행 자체가 컨테이너를 넘쳤다. `minmax(0, 1fr)` 은 트랙의 최소를 0 으로 만들어 **자식을**
68
+ * **건드리지 않고** 이를 고친다 — 같은 실측에서 **196/196** · **128/128/128**.
69
+ *
70
+ * ⚠**긴 불가분 내용 자체의 넘침은 별개 축이고 이 컴포넌트의 몫이 아니다.** 칸이 균등해져도
71
+ * 그 안의 긴 문자열은 여전히 자기 칸을 넘는다 — 그것은 셀의 `overflow-wrap` 이 답이다
72
+ * (`@iyulab/components` 의 엘리먼트는 이미 그 값을 갖는다). 같은 실측에서 `overflow-wrap` 을
73
+ * 주면 행의 넘침이 0 이 됐다.
74
+ *
75
+ * 계약은 `tests/form-layout-contract.test.ts` 가 고정한다 — 다만 그 파일은 **«선언» 을 재지**
76
+ * **«배치» 를 재지 않는다**(이 패키지엔 레이아웃을 계산하는 테스트 자리가 없다). 위 수치는
77
+ * 형제 패키지의 브라우저 프로젝트를 계측기로 빌린 일회성 탐침으로 쟀다.
62
78
  */
63
79
  function FormRow({ children, full, columns = 2, className, style }) {
64
80
  if (full) return /* @__PURE__ */ jsx("div", {
@@ -73,7 +89,7 @@ function FormRow({ children, full, columns = 2, className, style }) {
73
89
  className,
74
90
  style: {
75
91
  display: "grid",
76
- gridTemplateColumns: `repeat(${columns}, 1fr)`,
92
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
77
93
  gap: "var(--u-space-sm, 8px)",
78
94
  ...style
79
95
  },
@@ -758,44 +774,58 @@ function createODataService(config) {
758
774
  await throwIfError(res);
759
775
  return (await res.json())["@odata.count"] ?? 0;
760
776
  }
777
+ /**
778
+ * 쓰기 실패를 사용자에게 알린 뒤 그대로 다시 던진다.
779
+ *
780
+ * 🔴**이 서비스의 통지 축은 «쓰기 ↔ 읽기» 이지 «odata ↔ api» 가 아니다.** 조회
781
+ * (`odataGet`·`odataGetById`·`odataCount`·`apiGet`·`fetchRaw`)는 어느 쪽도 통지하지
782
+ * 않는다 — 화면이 비어 있는 것 자체가 신호이고, 목록 조회마다 토스트를 띄우면 읽을 수
783
+ * 없다. 쓰기는 **결과가 화면에 안 보일 수 있으므로** 통지한다: 서버가 409 와 사유를
784
+ * 돌려줘도 아무 일도 일어나지 않은 화면과 구별되지 않는다.
785
+ *
786
+ * ⚠**401 은 제외한다** — `onUnauthorized` 가 이미 안내하므로 토스트가 겹친다.
787
+ *
788
+ * ⚠성공 토스트는 **`odata*` 쓰기에만** 있다. `api*` 는 임의의 RPC(상태 전이·발행·업로드)를
789
+ * 태우는 경로라 «저장되었습니다» 같은 문구를 우리가 지어낼 수 없다 — 실패 메시지는 서버가
790
+ * 주므로 어느 엔드포인트에서나 뜻이 통하지만, 성공 문구는 그렇지 않다.
791
+ */
792
+ async function notifyingWrite(run) {
793
+ try {
794
+ return await run();
795
+ } catch (e) {
796
+ if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
797
+ throw e;
798
+ }
799
+ }
761
800
  async function odataPostQuiet(entity, body) {
762
801
  const res = await client.post(odataUrl(entity), normalizeBody(body));
763
802
  await throwIfError(res);
764
803
  return res.json();
765
804
  }
766
805
  async function odataPost(entity, body) {
767
- try {
806
+ return notifyingWrite(async () => {
768
807
  const result = await odataPostQuiet(entity, body);
769
808
  notifySuccess?.(messages.saved);
770
809
  return result;
771
- } catch (e) {
772
- if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
773
- throw e;
774
- }
810
+ });
775
811
  }
776
812
  async function odataPatchQuiet(entity, id, body) {
777
813
  await throwIfError(await client.patch(`${odataUrl(entity)}(${id})`, normalizeBody(body)));
778
814
  }
779
815
  async function odataPatch(entity, id, body) {
780
- try {
816
+ return notifyingWrite(async () => {
781
817
  await odataPatchQuiet(entity, id, body);
782
818
  notifySuccess?.(messages.updated);
783
- } catch (e) {
784
- if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
785
- throw e;
786
- }
819
+ });
787
820
  }
788
821
  async function odataDeleteQuiet(entity, id) {
789
822
  await throwIfError(await client.delete(`${odataUrl(entity)}(${id})`));
790
823
  }
791
824
  async function odataDelete(entity, id) {
792
- try {
825
+ return notifyingWrite(async () => {
793
826
  await odataDeleteQuiet(entity, id);
794
827
  notifySuccess?.(messages.deleted);
795
- } catch (e) {
796
- if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
797
- throw e;
798
- }
828
+ });
799
829
  }
800
830
  async function apiGet(path) {
801
831
  const [p, ...q] = path.split("?");
@@ -804,26 +834,38 @@ function createODataService(config) {
804
834
  await throwIfError(res);
805
835
  return parseJsonBody(res);
806
836
  }
807
- async function apiPost(path, body) {
837
+ async function apiPostQuiet(path, body) {
808
838
  const res = await client.post(apiUrl(path), body ?? {});
809
839
  await throwIfError(res);
810
840
  return parseJsonBody(res);
811
841
  }
812
- async function apiPut(path, body) {
842
+ async function apiPost(path, body) {
843
+ return notifyingWrite(() => apiPostQuiet(path, body));
844
+ }
845
+ async function apiPutQuiet(path, body) {
813
846
  const res = await client.put(apiUrl(path), body ?? {});
814
847
  await throwIfError(res);
815
848
  return parseJsonBody(res);
816
849
  }
817
- async function apiPatch(path, body) {
850
+ async function apiPut(path, body) {
851
+ return notifyingWrite(() => apiPutQuiet(path, body));
852
+ }
853
+ async function apiPatchQuiet(path, body) {
818
854
  const res = await client.patch(apiUrl(path), body ?? {});
819
855
  await throwIfError(res);
820
856
  return parseJsonBody(res);
821
857
  }
822
- async function apiDelete(path) {
858
+ async function apiPatch(path, body) {
859
+ return notifyingWrite(() => apiPatchQuiet(path, body));
860
+ }
861
+ async function apiDeleteQuiet(path) {
823
862
  const res = await client.delete(apiUrl(path));
824
863
  await throwIfError(res);
825
864
  return parseJsonBody(res);
826
865
  }
866
+ async function apiDelete(path) {
867
+ return notifyingWrite(() => apiDeleteQuiet(path));
868
+ }
827
869
  async function fetchRaw(url) {
828
870
  const res = await client.get(url);
829
871
  await throwIfError(res);
@@ -843,9 +885,13 @@ function createODataService(config) {
843
885
  odataDelete,
844
886
  apiGet,
845
887
  apiPost,
888
+ apiPostQuiet,
846
889
  apiPut,
890
+ apiPutQuiet,
847
891
  apiPatch,
892
+ apiPatchQuiet,
848
893
  apiDelete,
894
+ apiDeleteQuiet,
849
895
  fetchRaw,
850
896
  sourceDefaults: {
851
897
  baseUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/enterprise",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Enterprise utilities and components for iyulab framework",
5
5
  "keywords": [
6
6
  "enterprise",
@@ -58,18 +58,13 @@
58
58
  "@iyulab/components": "^1.27.0",
59
59
  "@types/node": "^26.1.1",
60
60
  "@types/react": "^19.2.14",
61
- "typescript": "^5.9.3",
61
+ "typescript": "^6.0.2",
62
62
  "vite": "^8.1.4",
63
63
  "vite-plugin-dts": "^5.0.3",
64
- "vitest": "^4.1.10"
64
+ "vitest": "^5.0.0"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "react": ">=18",
68
68
  "@iyulab/components": ">=1.27.0"
69
- },
70
- "peerDependenciesMeta": {
71
- "@iyulab/components": {
72
- "optional": true
73
- }
74
69
  }
75
70
  }