@rchemist/listgrid 0.2.10 → 0.2.12

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.
Files changed (28) hide show
  1. package/dist/listgrid/components/fields/CustomOptionField.d.ts +8 -0
  2. package/dist/listgrid/components/fields/CustomOptionField.js +19 -5
  3. package/dist/listgrid/components/fields/ImageField.js +4 -3
  4. package/dist/listgrid/components/fields/MultipleAssetField.js +2 -1
  5. package/dist/listgrid/components/fields/PhoneNumberField.d.ts +8 -0
  6. package/dist/listgrid/components/fields/PhoneNumberField.js +20 -3
  7. package/dist/listgrid/components/fields/view/MultipleAssetUpload.js +1 -4
  8. package/dist/listgrid/components/fields/view/PhoneNumberFieldView.d.ts +3 -1
  9. package/dist/listgrid/components/fields/view/PhoneNumberFieldView.js +5 -6
  10. package/dist/listgrid/components/fields/view/PhoneNumberListView.d.ts +3 -1
  11. package/dist/listgrid/components/fields/view/PhoneNumberListView.js +4 -6
  12. package/dist/listgrid/components/fields/view/SmsModal.js +3 -2
  13. package/dist/listgrid/components/fields/view/XrefAvailableDateMappingView.js +5 -1
  14. package/dist/listgrid/components/fields/view/XrefPreferMappingView.js +5 -1
  15. package/dist/listgrid/components/list/ViewListGrid.js +9 -7
  16. package/dist/listgrid/components/revision/RevisionField.d.ts +4 -0
  17. package/dist/listgrid/components/revision/RevisionField.js +12 -4
  18. package/dist/listgrid/config/EntityForm.js +12 -11
  19. package/dist/listgrid/config/RuntimeConfig.d.ts +58 -1
  20. package/dist/listgrid/config/RuntimeConfig.js +42 -2
  21. package/dist/listgrid/extensions/FieldExtensions.d.ts +25 -0
  22. package/dist/listgrid/extensions/FieldExtensions.js +26 -6
  23. package/dist/listgrid/index.d.ts +4 -3
  24. package/dist/listgrid/index.js +6 -4
  25. package/dist/listgrid/transfer/Provider/ExcelProvider.js +2 -1
  26. package/dist/listgrid/transfer/Type.js +3 -2
  27. package/dist/listgrid/utils/i18n.js +1 -1
  28. package/package.json +1 -1
@@ -9,7 +9,15 @@ interface CustomOptionFieldProps extends OptionalFieldProps {
9
9
  export declare class CustomOptionField extends OptionalField<CustomOptionField> {
10
10
  alias: string;
11
11
  multiple?: boolean | undefined;
12
+ private fetchUrlOverride?;
13
+ private bulkFetchUrlOverride?;
12
14
  constructor(name: string, order: number, alias: string, multiple?: boolean);
15
+ /** Override the single-alias fetch URL for this field instance. */
16
+ withFetchUrl(url: string): this;
17
+ /** Override the bulk-alias fetch URL for this field instance. */
18
+ withBulkFetchUrl(url: string): this;
19
+ getFetchUrl(): string;
20
+ getBulkFetchUrl(): string;
13
21
  /**
14
22
  * CustomOptionField 핵심 렌더링 로직
15
23
  */
@@ -8,18 +8,32 @@ import { getExternalApiDataWithError, isEmpty, isEquals } from '../../misc';
8
8
  import { CheckBox } from '../../ui';
9
9
  import { isTrue } from '../../utils/BooleanUtil';
10
10
  import { MultiSelectBox } from '../../ui';
11
- const customOptionFetchUrl = '/option/by-alias';
12
- const customOptionBulkFetchUrl = '/option/by-aliases';
11
+ import { getEndpoint } from '../../config/RuntimeConfig';
13
12
  // alias별 options 캐시 (동일 페이지 내에서 공유)
14
13
  const customOptionCache = new Map();
15
14
  export class CustomOptionField extends OptionalField {
16
15
  constructor(name, order, alias, multiple) {
17
16
  super(name, order, 'custom');
18
17
  this.alias = alias;
19
- // this.tooltip = <div>이 선택 옵션은 <a href='/academic/system/option'>시스템 옵션</a> 에서 시스템 ID <span style={{ fontWeight: `bold`, marginRight: `4px` }}>{alias}</span> 로 등록된 옵션값 입니다.</div>;
20
18
  this.multiple = multiple ?? false;
21
19
  this.layout = 'half';
22
20
  }
21
+ /** Override the single-alias fetch URL for this field instance. */
22
+ withFetchUrl(url) {
23
+ this.fetchUrlOverride = url;
24
+ return this;
25
+ }
26
+ /** Override the bulk-alias fetch URL for this field instance. */
27
+ withBulkFetchUrl(url) {
28
+ this.bulkFetchUrlOverride = url;
29
+ return this;
30
+ }
31
+ getFetchUrl() {
32
+ return this.fetchUrlOverride ?? getEndpoint('customOptionByAlias');
33
+ }
34
+ getBulkFetchUrl() {
35
+ return this.bulkFetchUrlOverride ?? getEndpoint('customOptionByAliases');
36
+ }
23
37
  /**
24
38
  * CustomOptionField 핵심 렌더링 로직
25
39
  */
@@ -127,7 +141,7 @@ export async function getCustomOptionValues(alias) {
127
141
  return customOptionCache.get(alias);
128
142
  }
129
143
  const response = await getExternalApiDataWithError({
130
- url: `${customOptionFetchUrl}/${alias}`,
144
+ url: `${getEndpoint('customOptionByAlias')}/${alias}`,
131
145
  method: 'GET',
132
146
  });
133
147
  // 데이터가 정상적으로 들어왔다면 옵션 데이터를 생성해 반환한다. 오류가 발생했다면(alias 가 없거나 하는 경우) 빈 배열을 반환한다.
@@ -153,7 +167,7 @@ export async function prefetchCustomOptions(aliases) {
153
167
  const params = new URLSearchParams();
154
168
  uncachedAliases.forEach((alias) => params.append('aliases', alias));
155
169
  const response = await getExternalApiDataWithError({
156
- url: `${customOptionBulkFetchUrl}?${params.toString()}`,
170
+ url: `${getEndpoint('customOptionByAliases')}?${params.toString()}`,
157
171
  method: 'GET',
158
172
  });
159
173
  if (response.data && Array.isArray(response.data)) {
@@ -5,6 +5,7 @@ import { getInputRendererParameters } from '../helper/FieldRendererHelper';
5
5
  import { isEmpty } from '../../utils';
6
6
  import { getAccessableAssetUrl } from '../../misc';
7
7
  import { TextInput } from '../../ui';
8
+ import { getEndpoint } from '../../config/RuntimeConfig';
8
9
  export class ImageField extends ListableFormField {
9
10
  constructor(name, order, config) {
10
11
  super(name, order, 'file');
@@ -107,15 +108,15 @@ export class ImageField extends ListableFormField {
107
108
  const imgUrl = getAccessableAssetUrl(file.existFiles[0].url);
108
109
  return {
109
110
  result: (_jsx("div", { className: "rcm-image-field-cell", children: _jsxs("div", { className: "rcm-image-field-hover-group", children: [_jsx("img", { className: "rcm-image-field-thumb", src: `${imgUrl}`, onError: (event) => {
110
- event.currentTarget.src = '/assets/images/no-image.png';
111
+ event.currentTarget.src = getEndpoint('noImageFallback');
111
112
  }, alt: "primary image" }), _jsx("div", { className: "rcm-image-field-preview-wrap", children: _jsx("img", { className: "rcm-image-field-preview", src: `${imgUrl}`, onError: (event) => {
112
- event.currentTarget.src = '/assets/images/no-image.png';
113
+ event.currentTarget.src = getEndpoint('noImageFallback');
113
114
  }, alt: "enlarged image" }) })] }) })),
114
115
  };
115
116
  }
116
117
  }
117
118
  return {
118
- result: (_jsx("div", { className: "rcm-image-field-cell", children: _jsx("img", { className: "rcm-image-field-thumb rcm-image-field-thumb-placeholder", src: `/assets/images/no-image.png`, alt: "no image" }) })),
119
+ result: (_jsx("div", { className: "rcm-image-field-cell", children: _jsx("img", { className: "rcm-image-field-thumb rcm-image-field-thumb-placeholder", src: getEndpoint('noImageFallback'), alt: "no image" }) })),
119
120
  };
120
121
  })();
121
122
  }
@@ -12,6 +12,7 @@ import { IconPhotoPlus, IconTrash } from '@tabler/icons-react';
12
12
  import { MultipleAssetUpload } from './view/MultipleAssetUpload';
13
13
  import { getAccessableAssetUrl } from '../../misc';
14
14
  import { RegexLowerEnglishNumber } from '../../misc';
15
+ import { getEndpoint } from '../../config/RuntimeConfig';
15
16
  import { isTrue } from '../../utils/BooleanUtil';
16
17
  import { ViewHelpText } from '../form/ui/ViewHelpText';
17
18
  import { Tooltip } from '../../ui';
@@ -135,7 +136,7 @@ const MultipleAssetFieldView = (props) => {
135
136
  else {
136
137
  const imgUrl = getAccessableAssetUrl(value.assets[index].url);
137
138
  return (_jsx("img", { className: "rcm-asset-img", alt: `${value?.assets?.[index]?.description ?? ''}`, onError: (event) => {
138
- event.currentTarget.src = '/assets/images/no-image.png';
139
+ event.currentTarget.src = getEndpoint('noImageFallback');
139
140
  }, src: `${imgUrl}` }));
140
141
  }
141
142
  })() }) }) }, `td-${index}`) }) })] }) }, `asset${index}`));
@@ -4,16 +4,24 @@ import { Validation } from '../../validations/Validation';
4
4
  import { FieldRenderParameters } from '../../config/EntityField';
5
5
  import { RenderType } from '../../config/Config';
6
6
  import { EntityForm } from '../../config/EntityForm';
7
+ import type { Session } from '../../auth/types';
7
8
  interface PhoneNumberFieldProps extends ListableFormFieldProps {
8
9
  enableSms?: boolean;
9
10
  }
10
11
  export declare class PhoneNumberField extends ListableFormField<PhoneNumberField> {
11
12
  enableSms?: boolean;
13
+ private smsPermissionOverride?;
12
14
  constructor(name: string, order: number, validations?: Validation[], enableSms?: boolean);
13
15
  /**
14
16
  * Enable SMS functionality
15
17
  */
16
18
  withSms(enabled?: boolean): this;
19
+ /**
20
+ * Override the SMS send permission predicate for this field instance.
21
+ * If not set, RuntimeConfig.permissions.canSendSms is used (library default: always true).
22
+ */
23
+ withSmsPermission(predicate: (session?: Session) => boolean): this;
24
+ private resolveCanSendSms;
17
25
  /**
18
26
  * PhoneNumberField 핵심 렌더링 로직
19
27
  */
@@ -7,6 +7,7 @@ import { getInputRendererParameters } from '../helper/FieldRendererHelper';
7
7
  import { formatPhoneNumber, removePhoneNumberHyphens } from '../../utils/PhoneUtil';
8
8
  import { PhoneNumberFieldView } from './view/PhoneNumberFieldView';
9
9
  import { PhoneNumberListView } from './view/PhoneNumberListView';
10
+ import { getPermission } from '../../config/RuntimeConfig';
10
11
  export class PhoneNumberField extends ListableFormField {
11
12
  constructor(name, order, validations, enableSms) {
12
13
  super(name, order, 'phone');
@@ -21,6 +22,18 @@ export class PhoneNumberField extends ListableFormField {
21
22
  this.enableSms = enabled;
22
23
  return this;
23
24
  }
25
+ /**
26
+ * Override the SMS send permission predicate for this field instance.
27
+ * If not set, RuntimeConfig.permissions.canSendSms is used (library default: always true).
28
+ */
29
+ withSmsPermission(predicate) {
30
+ this.smsPermissionOverride = predicate;
31
+ return this;
32
+ }
33
+ resolveCanSendSms(session) {
34
+ const fn = this.smsPermissionOverride ?? getPermission('canSendSms');
35
+ return fn(session);
36
+ }
24
37
  /**
25
38
  * PhoneNumberField 핵심 렌더링 로직
26
39
  */
@@ -38,7 +51,7 @@ export class PhoneNumberField extends ListableFormField {
38
51
  };
39
52
  }
40
53
  }
41
- return (_jsx(PhoneNumberFieldView, { name: inputParams.name, value: inputParams.value, onChange: inputParams.onChange, onError: inputParams.onError, readonly: inputParams.readonly, placeHolder: inputParams.placeHolder, regex: regex, enableSms: this.enableSms, session: params.entityForm.session, renderType: params.entityForm.getRenderType() }));
54
+ return (_jsx(PhoneNumberFieldView, { name: inputParams.name, value: inputParams.value, onChange: inputParams.onChange, onError: inputParams.onError, readonly: inputParams.readonly, placeHolder: inputParams.placeHolder, regex: regex, enableSms: this.enableSms, session: params.entityForm.session, renderType: params.entityForm.getRenderType(), canSendSmsByPermission: this.resolveCanSendSms(params.entityForm.session) }));
42
55
  })();
43
56
  }
44
57
  /**
@@ -72,7 +85,7 @@ export class PhoneNumberField extends ListableFormField {
72
85
  // If SMS is enabled and we have a value, use PhoneNumberListView
73
86
  if (this.enableSms && value) {
74
87
  return Promise.resolve({
75
- result: (_jsx(PhoneNumberListView, { phoneNumber: value, formattedValue: formatted, enableSms: this.enableSms, session: props.entityForm.session })),
88
+ result: (_jsx(PhoneNumberListView, { phoneNumber: value, formattedValue: formatted, enableSms: this.enableSms, session: props.entityForm.session, canSendSmsByPermission: this.resolveCanSendSms(props.entityForm.session) })),
76
89
  linkOnCell: true, // Prevent row click from triggering when clicking the dropdown
77
90
  });
78
91
  }
@@ -82,7 +95,11 @@ export class PhoneNumberField extends ListableFormField {
82
95
  * PhoneNumberField 인스턴스 생성
83
96
  */
84
97
  createInstance(name, order) {
85
- return new PhoneNumberField(name, order, this.validations, this.enableSms);
98
+ const cloned = new PhoneNumberField(name, order, this.validations, this.enableSms);
99
+ if (this.smsPermissionOverride) {
100
+ cloned.smsPermissionOverride = this.smsPermissionOverride;
101
+ }
102
+ return cloned;
86
103
  }
87
104
  static create(props) {
88
105
  const field = new PhoneNumberField(props.name, props.order, props.validations, props.enableSms);
@@ -1,13 +1,10 @@
1
1
  'use client';
2
2
  'use client';
3
3
  import { jsx as _jsx } from "react/jsx-runtime";
4
- import { ASSET_SERVER_URL, removeAssetServerPrefix } from '../../../misc';
4
+ import { removeAssetServerPrefix } from '../../../misc';
5
5
  import { getTranslation } from '../../../utils/i18n';
6
6
  import { FileFieldValue, FileUploadInput } from '../../../ui';
7
7
  import { useEffect, useState } from 'react';
8
- const assetProcessUrl = '/asset/upload-file';
9
- const assetServerUrl = ASSET_SERVER_URL;
10
- const ASSET_PREFIX = '/static-resource/';
11
8
  export const MultipleAssetUpload = (props) => {
12
9
  const maxFiles = 1;
13
10
  const [fileValue, setFileValue] = useState(FileFieldValue.create());
@@ -14,6 +14,8 @@ interface PhoneNumberFieldViewProps {
14
14
  enableSms?: boolean | undefined;
15
15
  session?: Session | undefined;
16
16
  renderType?: RenderType | undefined;
17
+ /** Permission to send SMS, resolved by PhoneNumberField at render time. */
18
+ canSendSmsByPermission?: boolean | undefined;
17
19
  }
18
- export declare const PhoneNumberFieldView: ({ name, value, onChange, onError, readonly, placeHolder, regex, enableSms, session, renderType, }: PhoneNumberFieldViewProps) => import("react/jsx-runtime").JSX.Element;
20
+ export declare const PhoneNumberFieldView: ({ name, value, onChange, onError, readonly, placeHolder, regex, enableSms, session, renderType, canSendSmsByPermission, }: PhoneNumberFieldViewProps) => import("react/jsx-runtime").JSX.Element;
19
21
  export {};
@@ -8,7 +8,7 @@ import { showToast } from '../../../message';
8
8
  import { readonlyClass } from '../../../ui';
9
9
  import { formatPhoneNumber, removePhoneNumberHyphens } from '../../../utils/PhoneUtil';
10
10
  import { SmsModal } from './SmsModal';
11
- export const PhoneNumberFieldView = ({ name, value, onChange, onError, readonly = false, placeHolder, regex, enableSms, session, renderType, }) => {
11
+ export const PhoneNumberFieldView = ({ name, value, onChange, onError, readonly = false, placeHolder, regex, enableSms, session, renderType, canSendSmsByPermission, }) => {
12
12
  const { openModal, closeModal } = useModalManagerStore();
13
13
  const [displayValue, setDisplayValue] = useState('');
14
14
  // Sync displayValue when external value changes
@@ -21,11 +21,10 @@ export const PhoneNumberFieldView = ({ name, value, onChange, onError, readonly
21
21
  setDisplayValue('');
22
22
  }
23
23
  }, [value]);
24
- // Check if user has admin role
25
- const roles = session?.authentication?.roles ?? session?.roles ?? [];
26
- const isAdmin = roles.includes('ROLE_ADMIN');
27
- // SMS can be sent if: admin + enableSms + phoneNumber + update mode
28
- const canSendSms = isAdmin && enableSms && displayValue && renderType === 'update';
24
+ // SMS can be sent if: permitted + enableSms + phoneNumber + update mode
25
+ // `canSendSmsByPermission` is evaluated and injected by PhoneNumberField
26
+ // (either field-level withSmsPermission override or RuntimeConfig.permissions.canSendSms).
27
+ const canSendSms = canSendSmsByPermission && enableSms && displayValue && renderType === 'update';
29
28
  // Copy is always available when there's a phone number
30
29
  const canCopy = !!displayValue;
31
30
  const handleChange = (e) => {
@@ -4,6 +4,8 @@ interface PhoneNumberListViewProps {
4
4
  formattedValue: string;
5
5
  enableSms?: boolean | undefined;
6
6
  session?: Session | undefined;
7
+ /** Permission to send SMS, resolved by PhoneNumberField at render time. */
8
+ canSendSmsByPermission?: boolean | undefined;
7
9
  }
8
- export declare const PhoneNumberListView: ({ phoneNumber, formattedValue, enableSms, session, }: PhoneNumberListViewProps) => import("react/jsx-runtime").JSX.Element;
10
+ export declare const PhoneNumberListView: ({ phoneNumber, formattedValue, enableSms, canSendSmsByPermission, }: PhoneNumberListViewProps) => import("react/jsx-runtime").JSX.Element;
9
11
  export {};
@@ -7,13 +7,11 @@ import { getOverlayZIndex, POPOVER_Z_INDEX, useModalManagerStore } from '../../.
7
7
  import { showToast } from '../../../message';
8
8
  import { SmsModal } from './SmsModal';
9
9
  import { formatPhoneNumber } from '../../../utils/PhoneUtil';
10
- export const PhoneNumberListView = ({ phoneNumber, formattedValue, enableSms, session, }) => {
10
+ export const PhoneNumberListView = ({ phoneNumber, formattedValue, enableSms, canSendSmsByPermission, }) => {
11
11
  const { openModal, closeModal } = useModalManagerStore();
12
- // Check if user has admin role
13
- const roles = session?.authentication?.roles ?? session?.roles ?? [];
14
- const isAdmin = roles.includes('ROLE_ADMIN');
15
- // SMS can be sent if: admin + enableSms + phoneNumber
16
- const canSendSms = isAdmin && enableSms && phoneNumber;
12
+ // SMS can be sent if: permitted + enableSms + phoneNumber
13
+ // `canSendSmsByPermission` is evaluated and injected by PhoneNumberField.
14
+ const canSendSms = canSendSmsByPermission && enableSms && phoneNumber;
17
15
  const handleCopy = async (e) => {
18
16
  e.stopPropagation();
19
17
  try {
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useState } from 'react';
4
4
  import { RequestUtil } from '../../../misc';
5
5
  import { showAlert, showSuccess } from '../../../message';
6
+ import { getEndpoint } from '../../../config/RuntimeConfig';
6
7
  export const SmsModal = ({ phoneNumber, onClose }) => {
7
8
  const [senderAddress, setSenderAddress] = useState('');
8
9
  const [content, setContent] = useState('');
@@ -34,7 +35,7 @@ export const SmsModal = ({ phoneNumber, onClose }) => {
34
35
  setLoadingSenderList(true);
35
36
  try {
36
37
  const response = await RequestUtil.getExternalApiDataWithError({
37
- url: '/api/v1/sms-sender/list',
38
+ url: getEndpoint('smsSenderList'),
38
39
  method: 'GET',
39
40
  });
40
41
  const senderCache = response.data;
@@ -86,7 +87,7 @@ export const SmsModal = ({ phoneNumber, onClose }) => {
86
87
  toList: [{ address: phoneNumber }],
87
88
  };
88
89
  const response = await RequestUtil.getExternalApiDataWithError({
89
- url: '/notification/send',
90
+ url: getEndpoint('smsNotificationSend'),
90
91
  method: 'POST',
91
92
  formData: notificationQueue,
92
93
  });
@@ -141,7 +141,11 @@ export const XrefAvailableDateMappingView = ({ entityForm, ...props }) => {
141
141
  return { entityForm: form.withErrors(fieldErrors), errors: ['입력 값이 올바르지 않습니다.'] };
142
142
  }
143
143
  const formData = await form.getSubmitFormData();
144
- const target = formData.data['mapping'];
144
+ // processManyToOneField stores the ManyToOneField value as `${name}Id` on formData.data,
145
+ // so the inner 'mapping' field is read as 'mappingId'. Reading 'mapping' here would yield
146
+ // undefined and cause the outer view to query the backend with an empty `IN []` filter,
147
+ // which the backend treats as "no filter" and returns the entire list.
148
+ const target = formData.data['mappingId'];
145
149
  if (!isEmpty(mappingValue.mapped)) {
146
150
  let duplicated = false;
147
151
  for (const item of mappingValue.mapped) {
@@ -119,7 +119,11 @@ export const XrefPreferMappingView = ({ entityForm, ...props }) => {
119
119
  return { entityForm: form.withErrors(fieldErrors), errors: ['입력 값이 올바르지 않습니다.'] };
120
120
  }
121
121
  const formData = await form.getSubmitFormData();
122
- const target = formData.data['mapping'];
122
+ // processManyToOneField stores the ManyToOneField value as `${name}Id` on formData.data,
123
+ // so the inner 'mapping' field is read as 'mappingId'. Reading 'mapping' here would yield
124
+ // undefined and cause the outer view to query the backend with an empty `IN []` filter,
125
+ // which the backend treats as "no filter" and returns the entire list.
126
+ const target = formData.data['mappingId'];
123
127
  if (!isEmpty(mappingValue.mapped)) {
124
128
  let duplicated = false;
125
129
  for (const item of mappingValue.mapped) {
@@ -17,7 +17,8 @@ import { useListGridLogic } from './hooks/useListGridLogic';
17
17
  import { ShowNotifications } from '../helper/ShowNotifications';
18
18
  import { Stack } from '../../ui';
19
19
  import { SubCollectionViewModal } from './ui/SubCollectionViewModal';
20
- import { hasAnyRole, useSession } from '../../auth';
20
+ import { useSession } from '../../auth';
21
+ import { getPermission } from '../../config/RuntimeConfig';
21
22
  import { perfLog } from './utils/performanceLogger';
22
23
  import { getListGridThemeByVariant, ListGridThemeProvider, useListGridTheme, } from './context/ListGridThemeContext';
23
24
  import { EntityFormScopeProvider, useEntityFormScope } from './context/EntityFormScopeContext';
@@ -96,13 +97,14 @@ export const ViewListGrid = (props) => {
96
97
  // Hook은 조건문 전에 호출되어야 함
97
98
  const sessionFromHook = useSession();
98
99
  const session = props.session ?? entityForm.getSession() ?? sessionFromHook;
99
- // 관리자 권한 체크 (새창 열기 버튼 표시용)
100
- // 기존 session 변수를 사용하여 불필요한 API 호출 방지
100
+ // "새 열기" 버튼 표시 권한.
101
+ // 라이브러리 자체는 role 모르며, RuntimeConfig.permissions.canOpenInNewWindow
102
+ // 주입된 predicate 가 판정한다. 호스트가 설정하지 않으면 항상 true.
103
+ //
104
+ // TODO: ListGrid 단위 override (withOpenInNewWindowPermission) 를 추가할 수도 있지만,
105
+ // 현재는 전역 predicate 만 사용한다 — 필요 시 listGrid prop 으로 확장.
101
106
  const isAdmin = React.useMemo(() => {
102
- if (session) {
103
- return hasAnyRole(session, 'ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_STAFF');
104
- }
105
- return false;
107
+ return getPermission('canOpenInNewWindow')(session ?? undefined);
106
108
  }, [session]);
107
109
  // QuickSearch 필드명 Set 생성 (헤더 필터 비활성화용)
108
110
  // Note: 이 hook은 early return 이전에 있어야 hooks 순서가 일정함
@@ -4,8 +4,12 @@ import { FieldRenderParameters, FilterRenderParameters } from '../../config/Enti
4
4
  interface RevisionFieldProps extends FormFieldProps {
5
5
  }
6
6
  export declare class RevisionField extends FormField<RevisionField> {
7
+ private apiUrlOverride?;
7
8
  constructor(name: string, order: number);
8
9
  protected createInstance(name: string, order: number): RevisionField;
10
+ /** Override the revision API URL for this field instance. */
11
+ withApiUrl(url: string): this;
12
+ getApiUrl(): string;
9
13
  protected renderInstance(params: FieldRenderParameters): Promise<React.ReactNode | null>;
10
14
  protected renderListFilterInstance(params: FilterRenderParameters): Promise<React.ReactNode | null>;
11
15
  static create(props: RevisionFieldProps): RevisionField;
@@ -11,7 +11,7 @@ import { IconHistory } from '@tabler/icons-react';
11
11
  import { fDateTime } from '../../misc';
12
12
  import { Pagination } from '../../ui';
13
13
  import { getTranslation } from '../../utils/i18n';
14
- const revisionApiUrl = '/revision';
14
+ import { getEndpoint } from '../../config/RuntimeConfig';
15
15
  // Audit/timestamp fields excluded from diff (always change on every update)
16
16
  const AUDIT_FIELD_NAMES = new Set([
17
17
  'updatedAt',
@@ -66,6 +66,14 @@ export class RevisionField extends FormField {
66
66
  createInstance(name, order) {
67
67
  return new RevisionField(name, order);
68
68
  }
69
+ /** Override the revision API URL for this field instance. */
70
+ withApiUrl(url) {
71
+ this.apiUrlOverride = url;
72
+ return this;
73
+ }
74
+ getApiUrl() {
75
+ return this.apiUrlOverride ?? getEndpoint('revisionApi');
76
+ }
69
77
  async renderInstance(params) {
70
78
  const { entityForm, subCollectionEntity } = params;
71
79
  // subCollectionEntity인 경우 렌더링하지 않음
@@ -76,7 +84,7 @@ export class RevisionField extends FormField {
76
84
  if (entityForm.getRenderType() === 'create') {
77
85
  return null;
78
86
  }
79
- return _jsx(RevisionFieldRenderer, { entityForm: entityForm });
87
+ return _jsx(RevisionFieldRenderer, { entityForm: entityForm, apiUrl: this.getApiUrl() });
80
88
  }
81
89
  renderListFilterInstance(params) {
82
90
  // 리스트 필터에서는 지원하지 않음
@@ -86,7 +94,7 @@ export class RevisionField extends FormField {
86
94
  return new RevisionField(props.name, props.order).copyFields(props, true);
87
95
  }
88
96
  }
89
- const RevisionFieldRenderer = ({ entityForm }) => {
97
+ const RevisionFieldRenderer = ({ entityForm, apiUrl }) => {
90
98
  const [revisions, setRevisions] = useState([]);
91
99
  const [loading, setLoading] = useState(false);
92
100
  const [totalPage, setTotalPage] = useState(0);
@@ -108,7 +116,7 @@ const RevisionFieldRenderer = ({ entityForm }) => {
108
116
  .withSort('createdAt', 'DESC')
109
117
  .withPage(page)
110
118
  .withPageSize(10);
111
- const searchResult = await PageResult.fetchListData(revisionApiUrl, searchForm);
119
+ const searchResult = await PageResult.fetchListData(apiUrl, searchForm);
112
120
  if (searchResult && searchResult.list) {
113
121
  setRevisions(searchResult.list);
114
122
  setTotalPage(searchResult.totalPage);
@@ -8,8 +8,7 @@ import { delay, entityErrorToString } from './EntityFormMethod';
8
8
  import { EntityFormExtensions } from './form/EntityFormExtensions';
9
9
  import { ExtensionPoint } from '../extensions/EntityFormExtension.types';
10
10
  import { PhoneNumberField } from '../components/fields/PhoneNumberField';
11
- import { createSmsHistoryField } from '../extensions/FieldExtensions';
12
- import { hasAnyRole } from '../auth';
11
+ import { createSmsHistoryField, getPhoneNumberSmsHistoryInjectConfig, } from '../extensions/FieldExtensions';
13
12
  export class EntityForm extends EntityFormExtensions {
14
13
  constructor(name, url) {
15
14
  super(name, url);
@@ -169,21 +168,23 @@ export class EntityForm extends EntityFormExtensions {
169
168
  const newField = field.withOptions(options).clone(true);
170
169
  entityForm.fields.set(field.getName(), newField);
171
170
  }
172
- // PhoneNumberField 있다면 sourceType, enableSms 설정
171
+ // PhoneNumberField 자동 주입: 호스트 앱이 registerPhoneNumberSmsHistoryInject 로
172
+ // 활성화한 경우에만 SMS 발송 이력 탭을 추가한다. 권한 판정은 호스트가 주입한
173
+ // predicate 를 사용하며, 라이브러리 자체는 role 문자열을 알지 않는다.
173
174
  if (field instanceof PhoneNumberField) {
174
- // 만약 PhoneNumberField 의 enableSMS 가 true 라면, view 페이지에서 SMS 발송 이력 필드를 자동으로 추가한다.
175
- if (isTrue(field.enableSms) &&
176
- hasAnyRole(entityForm.session, 'ROLE_SUPER_ADMIN', 'ROLE_ADMIN', 'ROLE_STAFF')) {
177
- // SMS 발송 이력 탭 정의 (STATUS_TAB_INFO.order - 10 = 999990)
175
+ const injectConfig = getPhoneNumberSmsHistoryInjectConfig();
176
+ if (injectConfig.enabled &&
177
+ isTrue(field.enableSms) &&
178
+ injectConfig.permission(entityForm.session)) {
178
179
  const SMS_HISTORY_TAB = {
179
- id: 'smsHistory',
180
- label: 'SMS 발송 이력',
181
- order: STATUS_TAB_INFO.order - 10,
180
+ id: injectConfig.tabId,
181
+ label: injectConfig.tabLabel,
182
+ order: STATUS_TAB_INFO.order + injectConfig.tabOrderOffset,
182
183
  hidden: false,
183
184
  };
184
185
  const smsHistoryField = createSmsHistoryField(field.getName() + 'SmsHistory', field.getOrder() + 1, field.getName());
185
186
  if (smsHistoryField) {
186
- smsHistoryField.withLabel('SMS 발송 이력').withModifyOnly().withHideLabel(true);
187
+ smsHistoryField.withLabel(injectConfig.tabLabel).withModifyOnly().withHideLabel(true);
187
188
  entityForm.addFields({
188
189
  tab: SMS_HISTORY_TAB,
189
190
  items: [smsHistoryField],
@@ -1,3 +1,42 @@
1
+ import type { Session } from '../auth/types';
2
+ /**
3
+ * Named API endpoints used internally by built-in fields and features.
4
+ * Hosts override at bootstrap via `configureRuntime({ endpoints: { ... } })`.
5
+ * Individual fields can still override via their own chainable setters.
6
+ */
7
+ export interface ListGridEndpoints {
8
+ /** Excel import suffix appended to an entity's base URL. */
9
+ excelUpload: string;
10
+ /** Excel download history logging endpoint. */
11
+ excelDownloadHistory: string;
12
+ /** Custom option single-alias fetch. */
13
+ customOptionByAlias: string;
14
+ /** Custom option bulk-alias fetch. */
15
+ customOptionByAliases: string;
16
+ /** Asset upload endpoint (MultipleAssetField, etc.). */
17
+ assetUpload: string;
18
+ /** Static resource prefix for asset URLs. */
19
+ staticResourcePrefix: string;
20
+ /** SMS sender list endpoint (SMS modal). */
21
+ smsSenderList: string;
22
+ /** SMS send endpoint. */
23
+ smsNotificationSend: string;
24
+ /** Revision history endpoint. */
25
+ revisionApi: string;
26
+ /** Fallback image URL when an asset cannot be loaded. */
27
+ noImageFallback: string;
28
+ }
29
+ /**
30
+ * Permission predicate hooks. Each returns `true` when the UI affordance
31
+ * is allowed for the given session. Library defaults are permissive
32
+ * (`() => true`); hosts install their own role checks at bootstrap.
33
+ */
34
+ export interface ListGridPermissions {
35
+ /** PhoneNumberField "Send SMS" button visibility. */
36
+ canSendSms: (session?: Session) => boolean;
37
+ /** ListGrid "Open in new window" button visibility. */
38
+ canOpenInNewWindow: (session?: Session) => boolean;
39
+ }
1
40
  export interface RuntimeConfig {
2
41
  /** Enables server-side-rendered list caching hints. */
3
42
  cacheControl?: boolean;
@@ -13,6 +52,24 @@ export interface RuntimeConfig {
13
52
  kakaoMapAppKey?: string;
14
53
  /** simpleCrypt passphrase / secret (replaces NEXT_PUBLIC_CRYPT_KEY). */
15
54
  cryptKey?: string;
55
+ /** Named API endpoints; hosts override any subset. */
56
+ endpoints?: Partial<ListGridEndpoints>;
57
+ /** Permission predicates; hosts override any subset. */
58
+ permissions?: Partial<ListGridPermissions>;
59
+ }
60
+ interface ResolvedRuntimeConfig {
61
+ cacheControl: boolean;
62
+ useServerSideCache: boolean;
63
+ searchFormHashKey: string;
64
+ debugListGridPerformance: boolean;
65
+ isDevelopment: boolean;
66
+ kakaoMapAppKey: string;
67
+ cryptKey: string;
68
+ endpoints: ListGridEndpoints;
69
+ permissions: ListGridPermissions;
16
70
  }
17
71
  export declare function configureRuntime(config: RuntimeConfig): void;
18
- export declare function getRuntimeConfig(): Required<RuntimeConfig>;
72
+ export declare function getRuntimeConfig(): ResolvedRuntimeConfig;
73
+ export declare function getEndpoint(name: keyof ListGridEndpoints): string;
74
+ export declare function getPermission(name: keyof ListGridPermissions): (session?: Session) => boolean;
75
+ export {};
@@ -4,6 +4,28 @@
4
4
  // framework-agnostic registry. Host apps call `configureRuntime({...})`
5
5
  // at bootstrap with their platform-specific values (from Next env vars,
6
6
  // Vite import.meta.env, custom config, etc.).
7
+ //
8
+ // Stage 9 (0.3.0) — host-coupling detox.
9
+ // Two registries were added to replace hardcoded project literals:
10
+ // - `endpoints`: named API paths used by built-in features
11
+ // - `permissions`: predicate hooks for UI permission gates
12
+ // See docs/REFACTOR_HOST_COUPLING.md for rationale.
13
+ const DEFAULT_ENDPOINTS = {
14
+ excelUpload: '/excel-upload',
15
+ excelDownloadHistory: '/excel-download-history/add',
16
+ customOptionByAlias: '/option/by-alias',
17
+ customOptionByAliases: '/option/by-aliases',
18
+ assetUpload: '/asset/upload-file',
19
+ staticResourcePrefix: '/static-resource/',
20
+ smsSenderList: '/api/v1/sms-sender/list',
21
+ smsNotificationSend: '/notification/send',
22
+ revisionApi: '/revision',
23
+ noImageFallback: '/assets/images/no-image.png',
24
+ };
25
+ const DEFAULT_PERMISSIONS = {
26
+ canSendSms: () => true,
27
+ canOpenInNewWindow: () => true,
28
+ };
7
29
  const DEFAULT = {
8
30
  cacheControl: false,
9
31
  useServerSideCache: false,
@@ -12,11 +34,29 @@ const DEFAULT = {
12
34
  isDevelopment: false,
13
35
  kakaoMapAppKey: '',
14
36
  cryptKey: '',
37
+ endpoints: { ...DEFAULT_ENDPOINTS },
38
+ permissions: { ...DEFAULT_PERMISSIONS },
39
+ };
40
+ let _config = {
41
+ ...DEFAULT,
42
+ endpoints: { ...DEFAULT_ENDPOINTS },
43
+ permissions: { ...DEFAULT_PERMISSIONS },
15
44
  };
16
- let _config = { ...DEFAULT };
17
45
  export function configureRuntime(config) {
18
- _config = { ...DEFAULT, ..._config, ...config };
46
+ const { endpoints: epOverride, permissions: pmOverride, ...scalarOverrides } = config;
47
+ _config = {
48
+ ..._config,
49
+ ...scalarOverrides,
50
+ endpoints: { ..._config.endpoints, ...(epOverride ?? {}) },
51
+ permissions: { ..._config.permissions, ...(pmOverride ?? {}) },
52
+ };
19
53
  }
20
54
  export function getRuntimeConfig() {
21
55
  return _config;
22
56
  }
57
+ export function getEndpoint(name) {
58
+ return _config.endpoints[name];
59
+ }
60
+ export function getPermission(name) {
61
+ return _config.permissions[name];
62
+ }
@@ -1,5 +1,30 @@
1
+ import type { Session } from '../auth/types';
1
2
  export interface SmsHistoryFieldConstructor {
2
3
  new (fieldName: string, order: number, targetFieldName: string): any;
3
4
  }
4
5
  export declare function registerSmsHistoryField(ctor: SmsHistoryFieldConstructor): void;
5
6
  export declare function createSmsHistoryField(fieldName: string, order: number, targetFieldName: string): any | null;
7
+ /**
8
+ * Auto-injection configuration for the SMS history tab.
9
+ *
10
+ * When a `PhoneNumberField` with `enableSms=true` is processed during EntityForm
11
+ * initialization and this injector is registered with `enabled: true`, the
12
+ * library adds a "SMS 발송 이력" tab containing an `SmsHistoryField` (obtained
13
+ * via `createSmsHistoryField`) next to the phone number field.
14
+ *
15
+ * The `permission` predicate gates the injection per user session. If unset,
16
+ * the tab is injected for every session (library default: permissive).
17
+ *
18
+ * Default: disabled (opt-in). Hosts must explicitly call
19
+ * `registerPhoneNumberSmsHistoryInject({ enabled: true, ... })` at bootstrap.
20
+ */
21
+ export interface PhoneNumberSmsHistoryInjectConfig {
22
+ enabled: boolean;
23
+ permission?: (session?: Session) => boolean;
24
+ tabLabel?: string;
25
+ tabId?: string;
26
+ /** Offset applied to STATUS_TAB_INFO.order to position the tab. Default: -10. */
27
+ tabOrderOffset?: number;
28
+ }
29
+ export declare function registerPhoneNumberSmsHistoryInject(config: PhoneNumberSmsHistoryInjectConfig): void;
30
+ export declare function getPhoneNumberSmsHistoryInjectConfig(): Required<PhoneNumberSmsHistoryInjectConfig>;
@@ -1,12 +1,13 @@
1
- // Stage 3 — Host-supplied domain field extension registry.
1
+ // Host-supplied domain field extension registry.
2
2
  //
3
- // The original library hard-wired `SmsHistoryField` (an academic-system
4
- // domain artifact from a separate entities module) directly in EntityForm.tsx. A truly
5
- // reusable library cannot assume SMS history exists; instead host apps
6
- // register their own SMS-history-like field implementation here.
3
+ // `@rchemist/listgrid` does not ship a built-in `SmsHistoryField` implementation
4
+ // because the field depends on a host application's SMS history schema.
5
+ // Host apps register their own implementation via `registerSmsHistoryField`
6
+ // (and optionally opt into the auto-injection behaviour via
7
+ // `registerPhoneNumberSmsHistoryInject`).
7
8
  //
8
9
  // If no host implementation is registered, the SMS history behaviour is
9
- // silently skipped — callers should check `createSmsHistoryField` for null.
10
+ // silently skipped — callers check `createSmsHistoryField` for null.
10
11
  let _smsHistoryFieldCtor;
11
12
  export function registerSmsHistoryField(ctor) {
12
13
  _smsHistoryFieldCtor = ctor;
@@ -19,3 +20,22 @@ export function createSmsHistoryField(fieldName, order, targetFieldName) {
19
20
  }
20
21
  return new _smsHistoryFieldCtor(fieldName, order, targetFieldName);
21
22
  }
23
+ let _smsHistoryInjectConfig = {
24
+ enabled: false,
25
+ permission: () => true,
26
+ tabLabel: 'SMS 발송 이력',
27
+ tabId: 'smsHistory',
28
+ tabOrderOffset: -10,
29
+ };
30
+ export function registerPhoneNumberSmsHistoryInject(config) {
31
+ _smsHistoryInjectConfig = {
32
+ enabled: config.enabled,
33
+ permission: config.permission ?? (() => true),
34
+ tabLabel: config.tabLabel ?? 'SMS 발송 이력',
35
+ tabId: config.tabId ?? 'smsHistory',
36
+ tabOrderOffset: config.tabOrderOffset ?? -10,
37
+ };
38
+ }
39
+ export function getPhoneNumberSmsHistoryInjectConfig() {
40
+ return _smsHistoryInjectConfig;
41
+ }
@@ -11,9 +11,10 @@ export { configureLoading, useLoadingStore } from './loading';
11
11
  export type { LoadingStore } from './loading';
12
12
  export { useModalManagerStore, configureOverlayZIndex, getOverlayZIndex, POPOVER_Z_INDEX, } from './store';
13
13
  export type { ModalOptions } from './store';
14
- export { registerSmsHistoryField, createSmsHistoryField } from './extensions/FieldExtensions';
15
- export { configureRuntime, getRuntimeConfig } from './config/RuntimeConfig';
16
- export type { RuntimeConfig } from './config/RuntimeConfig';
14
+ export { registerSmsHistoryField, createSmsHistoryField, registerPhoneNumberSmsHistoryInject, getPhoneNumberSmsHistoryInjectConfig, } from './extensions/FieldExtensions';
15
+ export type { SmsHistoryFieldConstructor, PhoneNumberSmsHistoryInjectConfig, } from './extensions/FieldExtensions';
16
+ export { configureRuntime, getRuntimeConfig, getEndpoint, getPermission, } from './config/RuntimeConfig';
17
+ export type { RuntimeConfig, ListGridEndpoints, ListGridPermissions, } from './config/RuntimeConfig';
17
18
  export { configureTranslator, getTranslation } from './utils/i18n';
18
19
  export type { Translator, TranslatorI18n, TranslatorFactory } from './utils/i18n';
19
20
  export { registerMenuPermissionChecker, checkAdminMenuPermission, DEFAULT_MENU_ALIAS, } from './menu';
@@ -12,10 +12,12 @@ export { configureMessages, showAlert, showConfirm, showError, showSuccess, show
12
12
  export { configureLoading, useLoadingStore } from './loading';
13
13
  // Modal manager store (zustand-based).
14
14
  export { useModalManagerStore, configureOverlayZIndex, getOverlayZIndex, POPOVER_Z_INDEX, } from './store';
15
- // Field extension registry — host apps register domain-specific field classes.
16
- export { registerSmsHistoryField, createSmsHistoryField } from './extensions/FieldExtensions';
17
- // Runtime configuration replaces hard-coded process.env.NEXT_PUBLIC_* access.
18
- export { configureRuntime, getRuntimeConfig } from './config/RuntimeConfig';
15
+ // Field extension registry — host apps register domain-specific field classes
16
+ // and opt into domain-specific auto-injection behaviours.
17
+ export { registerSmsHistoryField, createSmsHistoryField, registerPhoneNumberSmsHistoryInject, getPhoneNumberSmsHistoryInjectConfig, } from './extensions/FieldExtensions';
18
+ // Runtime configuration replaces hard-coded process.env.NEXT_PUBLIC_* access
19
+ // and hosts the endpoint/permission registries for framework-agnostic wiring.
20
+ export { configureRuntime, getRuntimeConfig, getEndpoint, getPermission, } from './config/RuntimeConfig';
19
21
  // i18n extension point — host injects a translator factory at bootstrap.
20
22
  export { configureTranslator, getTranslation } from './utils/i18n';
21
23
  // Menu permission checker — host apps register a real checker that decides
@@ -2,6 +2,7 @@ import { isTrue } from '../../utils/BooleanUtil';
2
2
  import * as FileSaver from 'file-saver';
3
3
  import * as XLSX from 'xlsx-js-style';
4
4
  import { isDataRowSet } from '../Type';
5
+ import { getEndpoint } from '../../config/RuntimeConfig';
5
6
  export async function logExcelDownload(usePassword, condition) {
6
7
  try {
7
8
  const url = typeof window !== 'undefined' ? window.location.pathname : '';
@@ -9,7 +10,7 @@ export async function logExcelDownload(usePassword, condition) {
9
10
  const conditionStr = raw.substring(0, 2000);
10
11
  const { callExternalHttpRequest } = await import('../../utils/RequestUtil');
11
12
  await callExternalHttpRequest({
12
- url: '/excel-download-history/add',
13
+ url: getEndpoint('excelDownloadHistory'),
13
14
  method: 'POST',
14
15
  formData: { url, condition: conditionStr, usePassword },
15
16
  });
@@ -1,3 +1,4 @@
1
+ import { getEndpoint } from '../config/RuntimeConfig';
1
2
  import { fDate, fDateTime } from '../misc';
2
3
  import { isTrue } from '../utils/BooleanUtil';
3
4
  import { getPlainText } from '../ui';
@@ -91,8 +92,8 @@ export class DataTransferConfig {
91
92
  this.export.url = url;
92
93
  }
93
94
  if (this.import.url === undefined) {
94
- // 엑셀 업로드 기본 경로
95
- this.import.url = url + '/excel-upload';
95
+ // 엑셀 업로드 기본 경로 (RuntimeConfig.endpoints.excelUpload 로 오버라이드 가능)
96
+ this.import.url = url + getEndpoint('excelUpload');
96
97
  }
97
98
  }
98
99
  isSupportExport() {
@@ -1,5 +1,5 @@
1
1
  // i18n contract: library emits translation keys (e.g. "common.save",
2
- // "menu.academic.admission.notice"), host maps them to strings.
2
+ // "menu.users"), host maps them to strings.
3
3
  //
4
4
  // Usage:
5
5
  // import { configureTranslator } from '@rchemist/listgrid';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rchemist/listgrid",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "private": false,
5
5
  "description": "Framework-free React CRUD UI engine — primitive-based design system, data-attr theming, and a full list/form renderer for RCM-framework-style entity backends.",
6
6
  "keywords": [