@open-mercato/ui 0.6.7-develop.6661.1.0043ed1d03 → 0.6.7-develop.6669.1.40b669666b
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/fields/phone.js +65 -0
- package/dist/backend/fields/phone.js.map +7 -0
- package/dist/backend/fields/registry.generated.js +1 -0
- package/dist/backend/fields/registry.generated.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +273 -21
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/utils/customFieldDefs.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/__tests__/custom-field-columns.test.ts +28 -0
- package/src/backend/__tests__/custom-field-filters.test.ts +3 -0
- package/src/backend/__tests__/custom-field-forms.test.ts +4 -0
- package/src/backend/fields/__tests__/phone.test.tsx +99 -0
- package/src/backend/fields/phone.tsx +98 -0
- package/src/backend/fields/registry.generated.ts +1 -0
- package/src/backend/inputs/PhoneNumberField.tsx +307 -26
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +71 -1
- package/src/backend/utils/customFieldDefs.ts +2 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/customFieldDefs.ts"],
|
|
4
|
-
"sourcesContent": ["import * as React from 'react'\nimport { useQuery, type UseQueryResult, type QueryClient } from '@tanstack/react-query'\nimport { readApiResultOrThrow } from './apiCall'\nimport type { CustomFieldOptionDto } from '@open-mercato/shared/modules/entities/options'\n\nexport type CustomFieldDefDto = {\n entityId?: string\n key: string\n kind: string\n label?: string\n description?: string\n options?: CustomFieldOptionDto[]\n optionsUrl?: string\n multi?: boolean\n filterable?: boolean\n formEditable?: boolean\n listVisible?: boolean\n editor?: string\n input?: string\n priority?: number\n fieldset?: string\n fieldsets?: string[]\n group?: { code: string; title?: string; hint?: string }\n // attachments-specific config\n maxAttachmentSizeMb?: number\n acceptExtensions?: string[]\n // optional validation rules\n validation?: Array<\n | { rule: 'required'; message: string }\n | { rule: 'date'; message: string }\n | { rule: 'integer'; message: string }\n | { rule: 'float'; message: string }\n | { rule: 'lt' | 'lte' | 'gt' | 'gte'; param: number; message: string }\n | { rule: 'eq' | 'ne'; param: any; message: string }\n | { rule: 'regex'; param: string; message: string }\n >\n dictionaryId?: string\n dictionaryInlineCreate?: boolean\n defaultValue?: string | number | boolean | null\n}\n\nexport type CustomFieldsetGroupDto = {\n code: string\n title?: string\n hint?: string\n}\n\nexport type CustomFieldsetDto = {\n code: string\n label: string\n icon?: string\n description?: string\n groups?: CustomFieldsetGroupDto[]\n}\n\nexport type CustomFieldDefinitionsPayload = {\n items?: CustomFieldDefDto[]\n fieldsetsByEntity?: Record<string, CustomFieldsetDto[]>\n entitySettings?: Record<string, { singleFieldsetPerRecord?: boolean }>\n}\n\nexport function normalizeEntityIds(entityIds: string | string[] | null | undefined): string[] {\n if (entityIds == null) return []\n const list = Array.isArray(entityIds) ? entityIds : [entityIds]\n const dedup = new Set<string>()\n const normalized: string[] = []\n for (const raw of list) {\n const trimmed = String(raw ?? '').trim()\n if (!trimmed || dedup.has(trimmed)) continue\n dedup.add(trimmed)\n normalized.push(trimmed)\n }\n return normalized\n}\n\nexport type CustomFieldDefinitionQueryOptions = {\n fieldset?: string\n}\n\nfunction buildDefinitionsQuery(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): string {\n const params = new URLSearchParams()\n entityIds.forEach((id) => {\n if (id) params.append('entityId', id)\n })\n if (options?.fieldset) params.set('fieldset', options.fieldset)\n return params.toString()\n}\n\ntype CustomFieldDefinitionsResponse = CustomFieldDefinitionsPayload\n\nfunction normalizeRecord<T>(value: unknown): Record<string, T[]> {\n if (!value || typeof value !== 'object') return {}\n const out: Record<string, T[]> = {}\n for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n if (!Array.isArray(raw)) continue\n out[key] = raw as T[]\n }\n return out\n}\n\nfunction preparePayload(data: CustomFieldDefinitionsResponse | null | undefined): CustomFieldDefinitionsPayload {\n const items = Array.isArray(data?.items) ? [...data!.items] : []\n items.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n const fieldsetsByEntity = normalizeRecord<CustomFieldsetDto>(data?.fieldsetsByEntity)\n const entitySettings = (data?.entitySettings && typeof data.entitySettings === 'object'\n ? data.entitySettings\n : {}) as CustomFieldDefinitionsPayload['entitySettings']\n return { items, fieldsetsByEntity, entitySettings }\n}\n\nasync function readDefinitionsViaFetch(\n entityIds: string[],\n fetchImpl: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const res = await fetchImpl(`/api/entities/definitions?${query}`, {\n headers: { 'content-type': 'application/json' },\n })\n const data = await res.json().catch(() => ({ items: [] }))\n return preparePayload(data)\n}\n\nasync function readDefinitionsViaApi(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const payload = await readApiResultOrThrow<CustomFieldDefinitionsResponse>(\n `/api/entities/definitions?${query}`,\n { headers: { 'content-type': 'application/json' } },\n {\n errorMessage: 'Failed to load custom field definitions',\n fallback: { items: [] },\n },\n )\n return preparePayload(payload)\n}\n\nexport async function fetchCustomFieldDefinitionsPayload(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const filtered = normalizeEntityIds(entityIds)\n if (!filtered.length) return { items: [] }\n return fetchImpl\n ? await readDefinitionsViaFetch(filtered, fetchImpl, options)\n : await readDefinitionsViaApi(filtered, options)\n}\n\nexport async function fetchCustomFieldDefs(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefDto[]> {\n const payload = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl, options)\n return payload.items ?? []\n}\n\nexport type UseCustomFieldDefsOptions<TData = CustomFieldDefDto[]> = {\n enabled?: boolean\n staleTime?: number\n gcTime?: number\n /** @deprecated Custom fetch implementations are no longer needed. */\n fetchImpl?: typeof fetch\n keyExtras?: Array<string | number | boolean | null | undefined>\n fieldset?: string\n select?: (data: CustomFieldDefDto[]) => TData\n}\n\nexport function useCustomFieldDefs<TData = CustomFieldDefDto[]>(\n entityIds: string | string[] | null | undefined,\n options: UseCustomFieldDefsOptions<TData> = {}\n): UseQueryResult<TData> {\n const {\n enabled: enabledOption = true,\n staleTime,\n gcTime,\n keyExtras,\n fetchImpl,\n fieldset,\n } = options\n const normalizedIds = React.useMemo(() => normalizeEntityIds(entityIds), [entityIds])\n const idsSignature = React.useMemo(() => JSON.stringify(normalizedIds), [normalizedIds])\n const extrasSignature = React.useMemo(() => JSON.stringify(keyExtras ?? []), [keyExtras])\n const normalizedFieldset = typeof fieldset === 'string' && fieldset.trim().length ? fieldset.trim() : null\n const queryKey = React.useMemo(\n () => ['customFieldDefs', ...(keyExtras ?? []), ...normalizedIds, `fieldset:${normalizedFieldset ?? 'default'}`],\n [idsSignature, extrasSignature, normalizedFieldset]\n )\n const enabled = enabledOption && normalizedIds.length > 0\n\n return useQuery<CustomFieldDefDto[], Error, TData>({\n queryKey,\n queryFn: () =>\n fetchCustomFieldDefs(\n normalizedIds,\n fetchImpl,\n normalizedFieldset ? { fieldset: normalizedFieldset } : undefined\n ),\n enabled,\n staleTime: staleTime ?? 5 * 60 * 1000,\n gcTime: gcTime ?? 30 * 60 * 1000,\n select: options.select,\n })\n}\n\nexport type CustomFieldVisibility = 'list' | 'form' | 'filter'\n\nexport function isDefVisible(def: CustomFieldDefDto, mode: CustomFieldVisibility): boolean {\n switch (mode) {\n case 'list':\n return def.listVisible !== false\n case 'form':\n return def.formEditable !== false\n case 'filter':\n return !!def.filterable\n default:\n return true\n }\n}\n\nexport function filterCustomFieldDefs(defs: CustomFieldDefDto[], mode: CustomFieldVisibility): CustomFieldDefDto[] {\n return defs\n .filter((d) => isDefVisible(d, mode))\n .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n}\n\nexport async function invalidateCustomFieldDefs(\n queryClient: QueryClient,\n entityIds?: string | string[] | null,\n): Promise<void> {\n const normalizedIds = normalizeEntityIds(entityIds)\n const targetPrefixes = new Set(['customFieldDefs', 'customFieldForms', 'dealFormFields'])\n if (!normalizedIds.length) {\n await queryClient.invalidateQueries({\n predicate: (query) => Array.isArray(query.queryKey) && typeof query.queryKey[0] === 'string' && targetPrefixes.has(query.queryKey[0] as string),\n })\n return\n }\n await queryClient.invalidateQueries({\n predicate: (query) => {\n if (!Array.isArray(query.queryKey)) return false\n const [prefix] = query.queryKey\n if (typeof prefix !== 'string' || !targetPrefixes.has(prefix)) return false\n return normalizedIds.every((id) => query.queryKey.includes(id))\n },\n })\n}\n"],
|
|
5
|
-
"mappings": "AAAA,YAAY,WAAW;AACvB,SAAS,gBAAuD;AAChE,SAAS,4BAA4B;
|
|
4
|
+
"sourcesContent": ["import * as React from 'react'\nimport { useQuery, type UseQueryResult, type QueryClient } from '@tanstack/react-query'\nimport { readApiResultOrThrow } from './apiCall'\nimport type { CustomFieldOptionDto } from '@open-mercato/shared/modules/entities/options'\n\nexport type CustomFieldDefDto = {\n entityId?: string\n key: string\n kind: string\n label?: string\n description?: string\n options?: CustomFieldOptionDto[]\n optionsUrl?: string\n multi?: boolean\n filterable?: boolean\n formEditable?: boolean\n listVisible?: boolean\n editor?: string\n input?: string\n priority?: number\n fieldset?: string\n fieldsets?: string[]\n group?: { code: string; title?: string; hint?: string }\n // attachments-specific config\n maxAttachmentSizeMb?: number\n acceptExtensions?: string[]\n // phone-specific config\n defaultCountryIso2?: string\n // optional validation rules\n validation?: Array<\n | { rule: 'required'; message: string }\n | { rule: 'date'; message: string }\n | { rule: 'integer'; message: string }\n | { rule: 'float'; message: string }\n | { rule: 'lt' | 'lte' | 'gt' | 'gte'; param: number; message: string }\n | { rule: 'eq' | 'ne'; param: any; message: string }\n | { rule: 'regex'; param: string; message: string }\n >\n dictionaryId?: string\n dictionaryInlineCreate?: boolean\n defaultValue?: string | number | boolean | null\n}\n\nexport type CustomFieldsetGroupDto = {\n code: string\n title?: string\n hint?: string\n}\n\nexport type CustomFieldsetDto = {\n code: string\n label: string\n icon?: string\n description?: string\n groups?: CustomFieldsetGroupDto[]\n}\n\nexport type CustomFieldDefinitionsPayload = {\n items?: CustomFieldDefDto[]\n fieldsetsByEntity?: Record<string, CustomFieldsetDto[]>\n entitySettings?: Record<string, { singleFieldsetPerRecord?: boolean }>\n}\n\nexport function normalizeEntityIds(entityIds: string | string[] | null | undefined): string[] {\n if (entityIds == null) return []\n const list = Array.isArray(entityIds) ? entityIds : [entityIds]\n const dedup = new Set<string>()\n const normalized: string[] = []\n for (const raw of list) {\n const trimmed = String(raw ?? '').trim()\n if (!trimmed || dedup.has(trimmed)) continue\n dedup.add(trimmed)\n normalized.push(trimmed)\n }\n return normalized\n}\n\nexport type CustomFieldDefinitionQueryOptions = {\n fieldset?: string\n}\n\nfunction buildDefinitionsQuery(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): string {\n const params = new URLSearchParams()\n entityIds.forEach((id) => {\n if (id) params.append('entityId', id)\n })\n if (options?.fieldset) params.set('fieldset', options.fieldset)\n return params.toString()\n}\n\ntype CustomFieldDefinitionsResponse = CustomFieldDefinitionsPayload\n\nfunction normalizeRecord<T>(value: unknown): Record<string, T[]> {\n if (!value || typeof value !== 'object') return {}\n const out: Record<string, T[]> = {}\n for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n if (!Array.isArray(raw)) continue\n out[key] = raw as T[]\n }\n return out\n}\n\nfunction preparePayload(data: CustomFieldDefinitionsResponse | null | undefined): CustomFieldDefinitionsPayload {\n const items = Array.isArray(data?.items) ? [...data!.items] : []\n items.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n const fieldsetsByEntity = normalizeRecord<CustomFieldsetDto>(data?.fieldsetsByEntity)\n const entitySettings = (data?.entitySettings && typeof data.entitySettings === 'object'\n ? data.entitySettings\n : {}) as CustomFieldDefinitionsPayload['entitySettings']\n return { items, fieldsetsByEntity, entitySettings }\n}\n\nasync function readDefinitionsViaFetch(\n entityIds: string[],\n fetchImpl: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const res = await fetchImpl(`/api/entities/definitions?${query}`, {\n headers: { 'content-type': 'application/json' },\n })\n const data = await res.json().catch(() => ({ items: [] }))\n return preparePayload(data)\n}\n\nasync function readDefinitionsViaApi(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const payload = await readApiResultOrThrow<CustomFieldDefinitionsResponse>(\n `/api/entities/definitions?${query}`,\n { headers: { 'content-type': 'application/json' } },\n {\n errorMessage: 'Failed to load custom field definitions',\n fallback: { items: [] },\n },\n )\n return preparePayload(payload)\n}\n\nexport async function fetchCustomFieldDefinitionsPayload(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const filtered = normalizeEntityIds(entityIds)\n if (!filtered.length) return { items: [] }\n return fetchImpl\n ? await readDefinitionsViaFetch(filtered, fetchImpl, options)\n : await readDefinitionsViaApi(filtered, options)\n}\n\nexport async function fetchCustomFieldDefs(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefDto[]> {\n const payload = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl, options)\n return payload.items ?? []\n}\n\nexport type UseCustomFieldDefsOptions<TData = CustomFieldDefDto[]> = {\n enabled?: boolean\n staleTime?: number\n gcTime?: number\n /** @deprecated Custom fetch implementations are no longer needed. */\n fetchImpl?: typeof fetch\n keyExtras?: Array<string | number | boolean | null | undefined>\n fieldset?: string\n select?: (data: CustomFieldDefDto[]) => TData\n}\n\nexport function useCustomFieldDefs<TData = CustomFieldDefDto[]>(\n entityIds: string | string[] | null | undefined,\n options: UseCustomFieldDefsOptions<TData> = {}\n): UseQueryResult<TData> {\n const {\n enabled: enabledOption = true,\n staleTime,\n gcTime,\n keyExtras,\n fetchImpl,\n fieldset,\n } = options\n const normalizedIds = React.useMemo(() => normalizeEntityIds(entityIds), [entityIds])\n const idsSignature = React.useMemo(() => JSON.stringify(normalizedIds), [normalizedIds])\n const extrasSignature = React.useMemo(() => JSON.stringify(keyExtras ?? []), [keyExtras])\n const normalizedFieldset = typeof fieldset === 'string' && fieldset.trim().length ? fieldset.trim() : null\n const queryKey = React.useMemo(\n () => ['customFieldDefs', ...(keyExtras ?? []), ...normalizedIds, `fieldset:${normalizedFieldset ?? 'default'}`],\n [idsSignature, extrasSignature, normalizedFieldset]\n )\n const enabled = enabledOption && normalizedIds.length > 0\n\n return useQuery<CustomFieldDefDto[], Error, TData>({\n queryKey,\n queryFn: () =>\n fetchCustomFieldDefs(\n normalizedIds,\n fetchImpl,\n normalizedFieldset ? { fieldset: normalizedFieldset } : undefined\n ),\n enabled,\n staleTime: staleTime ?? 5 * 60 * 1000,\n gcTime: gcTime ?? 30 * 60 * 1000,\n select: options.select,\n })\n}\n\nexport type CustomFieldVisibility = 'list' | 'form' | 'filter'\n\nexport function isDefVisible(def: CustomFieldDefDto, mode: CustomFieldVisibility): boolean {\n switch (mode) {\n case 'list':\n return def.listVisible !== false\n case 'form':\n return def.formEditable !== false\n case 'filter':\n return !!def.filterable\n default:\n return true\n }\n}\n\nexport function filterCustomFieldDefs(defs: CustomFieldDefDto[], mode: CustomFieldVisibility): CustomFieldDefDto[] {\n return defs\n .filter((d) => isDefVisible(d, mode))\n .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n}\n\nexport async function invalidateCustomFieldDefs(\n queryClient: QueryClient,\n entityIds?: string | string[] | null,\n): Promise<void> {\n const normalizedIds = normalizeEntityIds(entityIds)\n const targetPrefixes = new Set(['customFieldDefs', 'customFieldForms', 'dealFormFields'])\n if (!normalizedIds.length) {\n await queryClient.invalidateQueries({\n predicate: (query) => Array.isArray(query.queryKey) && typeof query.queryKey[0] === 'string' && targetPrefixes.has(query.queryKey[0] as string),\n })\n return\n }\n await queryClient.invalidateQueries({\n predicate: (query) => {\n if (!Array.isArray(query.queryKey)) return false\n const [prefix] = query.queryKey\n if (typeof prefix !== 'string' || !targetPrefixes.has(prefix)) return false\n return normalizedIds.every((id) => query.queryKey.includes(id))\n },\n })\n}\n"],
|
|
5
|
+
"mappings": "AAAA,YAAY,WAAW;AACvB,SAAS,gBAAuD;AAChE,SAAS,4BAA4B;AA6D9B,SAAS,mBAAmB,WAA2D;AAC5F,MAAI,aAAa,KAAM,QAAO,CAAC;AAC/B,QAAM,OAAO,MAAM,QAAQ,SAAS,IAAI,YAAY,CAAC,SAAS;AAC9D,QAAM,QAAQ,oBAAI,IAAY;AAC9B,QAAM,aAAuB,CAAC;AAC9B,aAAW,OAAO,MAAM;AACtB,UAAM,UAAU,OAAO,OAAO,EAAE,EAAE,KAAK;AACvC,QAAI,CAAC,WAAW,MAAM,IAAI,OAAO,EAAG;AACpC,UAAM,IAAI,OAAO;AACjB,eAAW,KAAK,OAAO;AAAA,EACzB;AACA,SAAO;AACT;AAMA,SAAS,sBAAsB,WAAqB,SAAqD;AACvG,QAAM,SAAS,IAAI,gBAAgB;AACnC,YAAU,QAAQ,CAAC,OAAO;AACxB,QAAI,GAAI,QAAO,OAAO,YAAY,EAAE;AAAA,EACtC,CAAC;AACD,MAAI,SAAS,SAAU,QAAO,IAAI,YAAY,QAAQ,QAAQ;AAC9D,SAAO,OAAO,SAAS;AACzB;AAIA,SAAS,gBAAmB,OAAqC;AAC/D,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,CAAC;AACjD,QAAM,MAA2B,CAAC;AAClC,aAAW,CAAC,KAAK,GAAG,KAAK,OAAO,QAAQ,KAAgC,GAAG;AACzE,QAAI,CAAC,MAAM,QAAQ,GAAG,EAAG;AACzB,QAAI,GAAG,IAAI;AAAA,EACb;AACA,SAAO;AACT;AAEA,SAAS,eAAe,MAAwF;AAC9G,QAAM,QAAQ,MAAM,QAAQ,MAAM,KAAK,IAAI,CAAC,GAAG,KAAM,KAAK,IAAI,CAAC;AAC/D,QAAM,KAAK,CAAC,GAAG,OAAO,EAAE,YAAY,MAAM,EAAE,YAAY,EAAE;AAC1D,QAAM,oBAAoB,gBAAmC,MAAM,iBAAiB;AACpF,QAAM,iBAAkB,MAAM,kBAAkB,OAAO,KAAK,mBAAmB,WAC3E,KAAK,iBACL,CAAC;AACL,SAAO,EAAE,OAAO,mBAAmB,eAAe;AACpD;AAEA,eAAe,wBACb,WACA,WACA,SACwC;AACxC,QAAM,QAAQ,sBAAsB,WAAW,OAAO;AACtD,QAAM,MAAM,MAAM,UAAU,6BAA6B,KAAK,IAAI;AAAA,IAChE,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,EAChD,CAAC;AACD,QAAM,OAAO,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,EAAE,OAAO,CAAC,EAAE,EAAE;AACzD,SAAO,eAAe,IAAI;AAC5B;AAEA,eAAe,sBAAsB,WAAqB,SAAqF;AAC7I,QAAM,QAAQ,sBAAsB,WAAW,OAAO;AACtD,QAAM,UAAU,MAAM;AAAA,IACpB,6BAA6B,KAAK;AAAA,IAClC,EAAE,SAAS,EAAE,gBAAgB,mBAAmB,EAAE;AAAA,IAClD;AAAA,MACE,cAAc;AAAA,MACd,UAAU,EAAE,OAAO,CAAC,EAAE;AAAA,IACxB;AAAA,EACF;AACA,SAAO,eAAe,OAAO;AAC/B;AAEA,eAAsB,mCACpB,WACA,WACA,SACwC;AACxC,QAAM,WAAW,mBAAmB,SAAS;AAC7C,MAAI,CAAC,SAAS,OAAQ,QAAO,EAAE,OAAO,CAAC,EAAE;AACzC,SAAO,YACH,MAAM,wBAAwB,UAAU,WAAW,OAAO,IAC1D,MAAM,sBAAsB,UAAU,OAAO;AACnD;AAEA,eAAsB,qBACpB,WACA,WACA,SAC8B;AAC9B,QAAM,UAAU,MAAM,mCAAmC,WAAW,WAAW,OAAO;AACtF,SAAO,QAAQ,SAAS,CAAC;AAC3B;AAaO,SAAS,mBACd,WACA,UAA4C,CAAC,GACtB;AACvB,QAAM;AAAA,IACJ,SAAS,gBAAgB;AAAA,IACzB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI;AACJ,QAAM,gBAAgB,MAAM,QAAQ,MAAM,mBAAmB,SAAS,GAAG,CAAC,SAAS,CAAC;AACpF,QAAM,eAAe,MAAM,QAAQ,MAAM,KAAK,UAAU,aAAa,GAAG,CAAC,aAAa,CAAC;AACvF,QAAM,kBAAkB,MAAM,QAAQ,MAAM,KAAK,UAAU,aAAa,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC;AACxF,QAAM,qBAAqB,OAAO,aAAa,YAAY,SAAS,KAAK,EAAE,SAAS,SAAS,KAAK,IAAI;AACtG,QAAM,WAAW,MAAM;AAAA,IACrB,MAAM,CAAC,mBAAmB,GAAI,aAAa,CAAC,GAAI,GAAG,eAAe,YAAY,sBAAsB,SAAS,EAAE;AAAA,IAC/G,CAAC,cAAc,iBAAiB,kBAAkB;AAAA,EACpD;AACA,QAAM,UAAU,iBAAiB,cAAc,SAAS;AAExD,SAAO,SAA4C;AAAA,IACjD;AAAA,IACA,SAAS,MACP;AAAA,MACE;AAAA,MACA;AAAA,MACA,qBAAqB,EAAE,UAAU,mBAAmB,IAAI;AAAA,IAC1D;AAAA,IACF;AAAA,IACA,WAAW,aAAa,IAAI,KAAK;AAAA,IACjC,QAAQ,UAAU,KAAK,KAAK;AAAA,IAC5B,QAAQ,QAAQ;AAAA,EAClB,CAAC;AACH;AAIO,SAAS,aAAa,KAAwB,MAAsC;AACzF,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO,IAAI,gBAAgB;AAAA,IAC7B,KAAK;AACH,aAAO,IAAI,iBAAiB;AAAA,IAC9B,KAAK;AACH,aAAO,CAAC,CAAC,IAAI;AAAA,IACf;AACE,aAAO;AAAA,EACX;AACF;AAEO,SAAS,sBAAsB,MAA2B,MAAkD;AACjH,SAAO,KACJ,OAAO,CAAC,MAAM,aAAa,GAAG,IAAI,CAAC,EACnC,KAAK,CAAC,GAAG,OAAO,EAAE,YAAY,MAAM,EAAE,YAAY,EAAE;AACzD;AAEA,eAAsB,0BACpB,aACA,WACe;AACf,QAAM,gBAAgB,mBAAmB,SAAS;AAClD,QAAM,iBAAiB,oBAAI,IAAI,CAAC,mBAAmB,oBAAoB,gBAAgB,CAAC;AACxF,MAAI,CAAC,cAAc,QAAQ;AACzB,UAAM,YAAY,kBAAkB;AAAA,MAClC,WAAW,CAAC,UAAU,MAAM,QAAQ,MAAM,QAAQ,KAAK,OAAO,MAAM,SAAS,CAAC,MAAM,YAAY,eAAe,IAAI,MAAM,SAAS,CAAC,CAAW;AAAA,IAChJ,CAAC;AACD;AAAA,EACF;AACA,QAAM,YAAY,kBAAkB;AAAA,IAClC,WAAW,CAAC,UAAU;AACpB,UAAI,CAAC,MAAM,QAAQ,MAAM,QAAQ,EAAG,QAAO;AAC3C,YAAM,CAAC,MAAM,IAAI,MAAM;AACvB,UAAI,OAAO,WAAW,YAAY,CAAC,eAAe,IAAI,MAAM,EAAG,QAAO;AACtE,aAAO,cAAc,MAAM,CAAC,OAAO,MAAM,SAAS,SAAS,EAAE,CAAC;AAAA,IAChE;AAAA,EACF,CAAC;AACH;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6669.1.40b669666b",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,13 +155,13 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.7-develop.6669.1.40b669666b",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
164
|
+
"@open-mercato/shared": "0.6.7-develop.6669.1.40b669666b",
|
|
165
165
|
"@testing-library/dom": "^10.4.1",
|
|
166
166
|
"@testing-library/jest-dom": "^6.9.1",
|
|
167
167
|
"@testing-library/react": "^16.3.1",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mapCustomFieldKindToFilterType, supportsCustomFieldColumn } from '../utils/customFieldColumns'
|
|
2
|
+
import type { CustomFieldDefDto } from '../utils/customFieldDefs'
|
|
3
|
+
|
|
4
|
+
describe('mapCustomFieldKindToFilterType', () => {
|
|
5
|
+
it('maps phone to a free-text filter (#62)', () => {
|
|
6
|
+
expect(mapCustomFieldKindToFilterType('phone')).toBe('text')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('keeps the established mappings for other kinds', () => {
|
|
10
|
+
expect(mapCustomFieldKindToFilterType('boolean')).toBe('boolean')
|
|
11
|
+
expect(mapCustomFieldKindToFilterType('integer')).toBe('number')
|
|
12
|
+
expect(mapCustomFieldKindToFilterType('float')).toBe('number')
|
|
13
|
+
expect(mapCustomFieldKindToFilterType('date')).toBe('date')
|
|
14
|
+
expect(mapCustomFieldKindToFilterType('select')).toBe('select')
|
|
15
|
+
expect(mapCustomFieldKindToFilterType('dictionary')).toBe('select')
|
|
16
|
+
expect(mapCustomFieldKindToFilterType('text')).toBe('text')
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('supportsCustomFieldColumn', () => {
|
|
21
|
+
it('renders a phone value as a normal list column (#62)', () => {
|
|
22
|
+
expect(supportsCustomFieldColumn({ key: 'work_phone', kind: 'phone' } as CustomFieldDefDto)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('still excludes attachment columns', () => {
|
|
26
|
+
expect(supportsCustomFieldColumn({ key: 'files', kind: 'attachment' } as CustomFieldDefDto)).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -32,6 +32,7 @@ describe('buildFilterDefsFromCustomFields', () => {
|
|
|
32
32
|
multi: true,
|
|
33
33
|
},
|
|
34
34
|
{ key: 'notes', kind: 'multiline', filterable: true },
|
|
35
|
+
{ key: 'work_phone', kind: 'phone', filterable: true },
|
|
35
36
|
{ key: 'hidden', kind: 'text', filterable: false },
|
|
36
37
|
]
|
|
37
38
|
|
|
@@ -58,6 +59,8 @@ describe('buildFilterDefsFromCustomFields', () => {
|
|
|
58
59
|
expect(typeof regions.loadOptions).toBe('function')
|
|
59
60
|
// text-like (multiline) => text
|
|
60
61
|
expect(out.find(f => f.id === 'cf_notes')!.type).toBe('text')
|
|
62
|
+
// phone filters as free text
|
|
63
|
+
expect(out.find(f => f.id === 'cf_work_phone')!.type).toBe('text')
|
|
61
64
|
// non-filterable omitted
|
|
62
65
|
expect(out.some(f => f.id === 'cf_hidden')).toBe(false)
|
|
63
66
|
})
|
|
@@ -5,6 +5,7 @@ import { FieldRegistry } from '../fields/registry'
|
|
|
5
5
|
describe('buildFormFieldsFromCustomFields', () => {
|
|
6
6
|
beforeAll(() => {
|
|
7
7
|
FieldRegistry.register('dictionary', { input: () => null })
|
|
8
|
+
FieldRegistry.register('phone', { input: () => null })
|
|
8
9
|
})
|
|
9
10
|
|
|
10
11
|
it('maps kinds to CrudField and filters by formEditable', () => {
|
|
@@ -33,6 +34,7 @@ describe('buildFormFieldsFromCustomFields', () => {
|
|
|
33
34
|
filterable: true,
|
|
34
35
|
formEditable: true,
|
|
35
36
|
},
|
|
37
|
+
{ key: 'work_phone', kind: 'phone', filterable: true, formEditable: true },
|
|
36
38
|
{ key: 'notes', kind: 'multiline', filterable: false, formEditable: true },
|
|
37
39
|
// text with editor hint should render richtext
|
|
38
40
|
{ key: 'desc', kind: 'text', filterable: false, formEditable: true, editor: 'htmlRichText' },
|
|
@@ -65,6 +67,8 @@ describe('buildFormFieldsFromCustomFields', () => {
|
|
|
65
67
|
if (byId['cf_labels']?.type === 'select') {
|
|
66
68
|
expect(byId['cf_labels'].multiple).toBe(true)
|
|
67
69
|
}
|
|
70
|
+
// Phone resolves to a custom input via the field registry
|
|
71
|
+
expect(byId['cf_work_phone']?.type).toBe('custom')
|
|
68
72
|
// Multiline now defaults to richtext (markdown editor)
|
|
69
73
|
expect(byId['cf_notes']?.type).toBe('richtext')
|
|
70
74
|
expect(byId['cf_desc']?.type).toBe('richtext')
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
6
|
+
import { FieldRegistry } from '../registry'
|
|
7
|
+
import '../phone'
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/i18n/context', () => {
|
|
10
|
+
const actual = jest.requireActual('@open-mercato/shared/lib/i18n/context')
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
useT: () => (key: string, fallback?: string) => fallback ?? key,
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
jest.mock('../../inputs/PhoneNumberField', () => ({
|
|
18
|
+
PhoneNumberField: ({ id, value, onValueChange, disabled, externalError, defaultCountryIso2 }: any) => (
|
|
19
|
+
<div>
|
|
20
|
+
<input
|
|
21
|
+
aria-label="phone"
|
|
22
|
+
id={id}
|
|
23
|
+
value={value}
|
|
24
|
+
disabled={disabled}
|
|
25
|
+
data-default-country={defaultCountryIso2 ?? ''}
|
|
26
|
+
onChange={(event) => onValueChange(event.target.value || undefined)}
|
|
27
|
+
/>
|
|
28
|
+
{externalError ? <span role="alert">{externalError}</span> : null}
|
|
29
|
+
</div>
|
|
30
|
+
),
|
|
31
|
+
PHONE_COUNTRIES: [
|
|
32
|
+
{ iso2: 'US', dialCode: '+1', label: 'United States', flag: '🇺🇸' },
|
|
33
|
+
{ iso2: 'PL', dialCode: '+48', label: 'Poland', flag: '🇵🇱' },
|
|
34
|
+
],
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
jest.mock('../../../primitives/select', () => {
|
|
38
|
+
const Passthrough = ({ children }: { children?: React.ReactNode }) => <>{children}</>
|
|
39
|
+
return {
|
|
40
|
+
Select: ({ value, onValueChange, children }: any) => (
|
|
41
|
+
<select data-testid="country-select" value={value} onChange={(event) => onValueChange(event.target.value)}>
|
|
42
|
+
{children}
|
|
43
|
+
</select>
|
|
44
|
+
),
|
|
45
|
+
SelectContent: Passthrough,
|
|
46
|
+
SelectItem: ({ value }: any) => <option value={value} />,
|
|
47
|
+
SelectItemLeading: Passthrough,
|
|
48
|
+
SelectTrigger: () => null,
|
|
49
|
+
SelectValue: () => null,
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('phone custom field', () => {
|
|
54
|
+
it('registers an input and a definition editor under the "phone" kind', () => {
|
|
55
|
+
expect(FieldRegistry.getInput('phone')).toBeDefined()
|
|
56
|
+
expect(FieldRegistry.getDefEditor('phone')).toBeDefined()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('wraps PhoneNumberField and forwards the configured default country', () => {
|
|
60
|
+
const input = FieldRegistry.getInput('phone')
|
|
61
|
+
if (!input) throw new Error('phone input not registered')
|
|
62
|
+
const setValue = jest.fn()
|
|
63
|
+
render(
|
|
64
|
+
<>{input({ id: 'cf_work_phone', value: '+1 212 555 1234', setValue, def: { defaultCountryIso2: 'PL' } } as any)}</>,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const field = screen.getByLabelText('phone') as HTMLInputElement
|
|
68
|
+
expect(field.value).toBe('+1 212 555 1234')
|
|
69
|
+
expect(field).toHaveAttribute('data-default-country', 'PL')
|
|
70
|
+
|
|
71
|
+
fireEvent.change(field, { target: { value: '+48 600 700 800' } })
|
|
72
|
+
expect(setValue).toHaveBeenCalledWith('+48 600 700 800')
|
|
73
|
+
|
|
74
|
+
fireEvent.change(field, { target: { value: '' } })
|
|
75
|
+
expect(setValue).toHaveBeenCalledWith(undefined)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('emits the selected default country from the definition editor', () => {
|
|
79
|
+
const defEditor = FieldRegistry.getDefEditor('phone')
|
|
80
|
+
if (!defEditor) throw new Error('phone defEditor not registered')
|
|
81
|
+
const onChange = jest.fn()
|
|
82
|
+
render(<>{defEditor({ def: { configJson: {} }, onChange })}</>)
|
|
83
|
+
|
|
84
|
+
const select = screen.getByTestId('country-select') as HTMLSelectElement
|
|
85
|
+
fireEvent.change(select, { target: { value: 'PL' } })
|
|
86
|
+
expect(onChange).toHaveBeenCalledWith({ defaultCountryIso2: 'PL' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('clears the default country when auto-detect is chosen', () => {
|
|
90
|
+
const defEditor = FieldRegistry.getDefEditor('phone')
|
|
91
|
+
if (!defEditor) throw new Error('phone defEditor not registered')
|
|
92
|
+
const onChange = jest.fn()
|
|
93
|
+
render(<>{defEditor({ def: { configJson: { defaultCountryIso2: 'PL' } }, onChange })}</>)
|
|
94
|
+
|
|
95
|
+
const select = screen.getByTestId('country-select') as HTMLSelectElement
|
|
96
|
+
fireEvent.change(select, { target: { value: '__om_phone_auto__' } })
|
|
97
|
+
expect(onChange).toHaveBeenCalledWith({ defaultCountryIso2: undefined })
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { CrudCustomFieldRenderProps } from '../CrudForm'
|
|
5
|
+
import { FieldRegistry } from './registry'
|
|
6
|
+
import { PhoneNumberField, PHONE_COUNTRIES } from '../inputs/PhoneNumberField'
|
|
7
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectItemLeading,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from '../../primitives/select'
|
|
16
|
+
|
|
17
|
+
type PhoneFieldConfig = {
|
|
18
|
+
defaultCountryIso2?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type PhoneFieldDef = PhoneFieldConfig & {
|
|
22
|
+
configJson?: PhoneFieldConfig
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type PhoneFieldInputProps = CrudCustomFieldRenderProps & { def?: PhoneFieldDef }
|
|
26
|
+
|
|
27
|
+
// Sentinel used by the definition editor to represent "no fixed default"
|
|
28
|
+
// because Radix Select cannot use an empty string as an item value.
|
|
29
|
+
const AUTO_COUNTRY_VALUE = '__om_phone_auto__'
|
|
30
|
+
|
|
31
|
+
function PhoneFieldInput({ id, value, setValue, disabled, error, def }: PhoneFieldInputProps) {
|
|
32
|
+
const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value)
|
|
33
|
+
return (
|
|
34
|
+
<PhoneNumberField
|
|
35
|
+
id={id}
|
|
36
|
+
value={stringValue}
|
|
37
|
+
onValueChange={(next) => setValue(next ?? undefined)}
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
externalError={error ?? null}
|
|
40
|
+
defaultCountryIso2={def?.defaultCountryIso2}
|
|
41
|
+
/>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function PhoneFieldDefEditor({
|
|
46
|
+
def,
|
|
47
|
+
onChange,
|
|
48
|
+
}: {
|
|
49
|
+
def: { configJson?: PhoneFieldConfig } | undefined
|
|
50
|
+
onChange: (patch: Partial<PhoneFieldConfig>) => void
|
|
51
|
+
}) {
|
|
52
|
+
const t = useT()
|
|
53
|
+
const selected = typeof def?.configJson?.defaultCountryIso2 === 'string' ? def.configJson.defaultCountryIso2 : ''
|
|
54
|
+
return (
|
|
55
|
+
<div className="mt-3 space-y-3 rounded border border-dashed border-muted-foreground/40 bg-muted/30 p-3">
|
|
56
|
+
<div className="space-y-1">
|
|
57
|
+
<label className="text-xs font-medium text-muted-foreground">
|
|
58
|
+
{t('ui.customFields.phone.defaultCountry', 'Default country')}
|
|
59
|
+
</label>
|
|
60
|
+
<Select
|
|
61
|
+
value={selected || AUTO_COUNTRY_VALUE}
|
|
62
|
+
onValueChange={(next) => onChange({ defaultCountryIso2: next === AUTO_COUNTRY_VALUE ? undefined : next })}
|
|
63
|
+
>
|
|
64
|
+
<SelectTrigger size="sm">
|
|
65
|
+
<SelectValue />
|
|
66
|
+
</SelectTrigger>
|
|
67
|
+
<SelectContent align="start">
|
|
68
|
+
<SelectItem value={AUTO_COUNTRY_VALUE}>
|
|
69
|
+
{t('ui.customFields.phone.defaultCountryAuto', 'Auto-detect from value')}
|
|
70
|
+
</SelectItem>
|
|
71
|
+
{PHONE_COUNTRIES.map((country) => (
|
|
72
|
+
<SelectItem key={`${country.iso2}-${country.dialCode}`} value={country.iso2}>
|
|
73
|
+
<SelectItemLeading>
|
|
74
|
+
<span className="text-base leading-none">{country.flag}</span>
|
|
75
|
+
</SelectItemLeading>
|
|
76
|
+
<span className="flex-1 truncate">{country.label}</span>
|
|
77
|
+
<span className="ml-2 text-xs text-muted-foreground tabular-nums">{country.dialCode}</span>
|
|
78
|
+
</SelectItem>
|
|
79
|
+
))}
|
|
80
|
+
</SelectContent>
|
|
81
|
+
</Select>
|
|
82
|
+
<p className="text-xs text-muted-foreground">
|
|
83
|
+
{t(
|
|
84
|
+
'ui.customFields.phone.defaultCountryHint',
|
|
85
|
+
'Pre-selects a country in the phone editor when the field is empty.',
|
|
86
|
+
)}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
FieldRegistry.register('phone', {
|
|
94
|
+
input: (props) => <PhoneFieldInput {...props} />,
|
|
95
|
+
defEditor: (props) => <PhoneFieldDefEditor {...props} />,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
export {}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// This file can be regenerated by module generators. Keep imports side-effectful.
|
|
3
3
|
import '@open-mercato/core/modules/attachments/fields/attachment'
|
|
4
4
|
import '@open-mercato/core/modules/dictionaries/fields/dictionary'
|
|
5
|
+
import './phone'
|
|
5
6
|
|
|
6
7
|
export function loadAll() {
|
|
7
8
|
// No-op: imports above perform registration via side effects
|