entity-info.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. import { useState } from 'react'
  2. import { View, Row, Pressable } from 'app/design/view'
  3. import { Text } from 'app/design/typography'
  4. import Time from 'app/ui/atoms/time'
  5. import Html from 'app/ui/atoms/html'
  6. import { Icon } from 'app/ui/atoms/icon'
  7. import { appSetting, cn, isUrl } from 'app/lib/util'
  8. import { useEditableRequest } from 'app/lib/form/form-helpers'
  9. import Profile from 'app/ui/molecules/profile/profile'
  10. import { BlockWrapper } from 'app/components/block-wrapper'
  11. import DropdownPopup from 'app/ui/atoms/dropdown-popup'
  12. import DatePicker from 'app/ui/atoms/date-picker'
  13. import { useTranslation } from 'react-i18next';
  14. import { formatDate } from 'app/lib/util'
  15. import { Modal, NeoButton, NeoButtonLink } from 'app/design/controls'
  16. import { SelectUsers, User } from 'app/components/form-fields/initial-members'
  17. import { buildTasksHomeUrl } from 'app/components/elements/tasks/helpers'
  18. const EDITABLE_VALUE_BUTTON = {
  19. style: 'borderless',
  20. controlSize: 'small',
  21. borderShape: 'capsule',
  22. align: 'start',
  23. }
  24. /** UNA price fields arrive as `{ value, currency }` (see form-fields/price.js). */
  25. function isPriceObject(value) {
  26. return (
  27. value != null &&
  28. typeof value === 'object' &&
  29. !Array.isArray(value) &&
  30. 'value' in value &&
  31. 'currency' in value
  32. )
  33. }
  34. function formatPriceLabel(value, valueCurrency) {
  35. if (isPriceObject(value)) {
  36. const amount = value.value
  37. const currency = value.currency || valueCurrency || ''
  38. if (amount == null || amount === '') return ''
  39. return currency ? `${amount} ${currency}` : String(amount)
  40. }
  41. if (value == null || value === '') return ''
  42. return valueCurrency ? `${value} ${valueCurrency}` : String(value)
  43. }
  44. function TasksListOpenLink({ href, label }) {
  45. if (!href) return null
  46. return (
  47. <NeoButtonLink
  48. href={href}
  49. style="borderless"
  50. controlSize="mini"
  51. borderShape="circle"
  52. image="ArrowRight"
  53. accessibilityLabel={label}
  54. />
  55. )
  56. }
  57. function withTasksListLink(value, href, label) {
  58. if (!href) return value
  59. return (
  60. <Row className="items-center gap-0.5 min-w-0">
  61. {value}
  62. <TasksListOpenLink href={href} label={label} />
  63. </Row>
  64. )
  65. }
  66. function ReadonlyValue({ href, label, children }) {
  67. const props = {
  68. ...EDITABLE_VALUE_BUTTON,
  69. ...(label != null ? { label } : {}),
  70. }
  71. if (href) {
  72. return (
  73. <NeoButtonLink {...props} href={href} target="_blank">
  74. {children}
  75. </NeoButtonLink>
  76. )
  77. }
  78. if (label != null) {
  79. return <NeoButton {...props} disabled />
  80. }
  81. return (
  82. <NeoButton {...props} disabled>
  83. {children}
  84. </NeoButton>
  85. )
  86. }
  87. const INFO_ICON_SIZE = 16
  88. function getHandleForDisplay(input) {
  89. if (typeof input !== 'string') return input;
  90. const original = input;
  91. let value = input.trim();
  92. if (!value) return original;
  93. if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value)) {
  94. value = 'https://' + value;
  95. }
  96. try {
  97. const url = new URL(value);
  98. let hostname = url.hostname.toLowerCase();
  99. hostname = hostname.replace(/^(www|m)\./, '');
  100. const pathSegments = url.pathname.split('/').filter(Boolean);
  101. if (pathSegments.length === 0) return original;
  102. const firstSegment = decodeURIComponent(pathSegments[0]);
  103. const getSlug = (segment) => {
  104. if (!segment) return null;
  105. const cleaned = segment.replace(/^@/, '').trim();
  106. return cleaned || null;
  107. };
  108. const isTwitter =
  109. hostname === 'twitter.com' || hostname.endsWith('.twitter.com');
  110. const isX = hostname === 'x.com' || hostname.endsWith('.x.com');
  111. const isInstagram =
  112. hostname === 'instagram.com' || hostname.endsWith('.instagram.com');
  113. const isGithub =
  114. hostname === 'github.com' || hostname.endsWith('.github.com');
  115. const isLinkedIn =
  116. hostname === 'linkedin.com' || hostname.endsWith('.linkedin.com');
  117. const isTikTok =
  118. hostname === 'tiktok.com' || hostname.endsWith('.tiktok.com');
  119. const isFacebook =
  120. hostname === 'facebook.com' || hostname.endsWith('.facebook.com');
  121. const isYouTube =
  122. hostname === 'youtube.com' ||
  123. hostname.endsWith('.youtube.com') ||
  124. hostname === 'youtu.be';
  125. if (isTwitter || isX || isInstagram) {
  126. const slug = getSlug(firstSegment);
  127. return slug ? `@${slug}` : original;
  128. }
  129. if (isGithub) {
  130. const slug = getSlug(firstSegment);
  131. return slug ?? original;
  132. }
  133. if (isLinkedIn) {
  134. const [first, second] = pathSegments;
  135. let slug = null;
  136. if (first === 'in' && second) slug = getSlug(second);
  137. else if (first === 'company' && second) slug = getSlug(second);
  138. else if (first === 'school' && second) slug = getSlug(second);
  139. return slug ?? original;
  140. }
  141. if (isTikTok) {
  142. const slug = getSlug(firstSegment);
  143. return slug ? `@${slug}` : original;
  144. }
  145. if (isFacebook) {
  146. const slug = getSlug(firstSegment);
  147. return slug ?? original;
  148. }
  149. if (isYouTube) {
  150. if (hostname === 'youtu.be') {
  151. return original;
  152. }
  153. const [first, second] = pathSegments;
  154. if (first && first.startsWith('@')) {
  155. const slug = getSlug(first);
  156. return slug ? `@${slug}` : original;
  157. }
  158. if (
  159. (first === 'channel' || first === 'user' || first === 'c') &&
  160. second
  161. ) {
  162. const slug = getSlug(second);
  163. return slug ?? original;
  164. }
  165. return original;
  166. }
  167. return original;
  168. } catch {
  169. return original;
  170. }
  171. }
  172. function normalizeSelectValues(values) {
  173. if (!values) return []
  174. if (Array.isArray(values)) return values
  175. return Object.entries(values).map(([key, value]) => ({ key, value }))
  176. }
  177. function fieldValueToUnix(value) {
  178. if (value == null || value === '') return null
  179. if (
  180. typeof value === 'number' ||
  181. (typeof value === 'string' && value !== '' && !Number.isNaN(Number(value)) && !value.includes('-'))
  182. ) {
  183. return Number(value)
  184. }
  185. const str = String(value).replace('Z', '')
  186. const [datePart, timePart] = str.split(/[T ]/)
  187. const [year, month, day] = datePart.split('-').map(Number)
  188. const [hours = 0, minutes = 0, seconds = 0] = timePart
  189. ? timePart.split(':').map(Number)
  190. : []
  191. return Math.floor(new Date(year, month - 1, day, hours, minutes, seconds).getTime() / 1000)
  192. }
  193. function calendarDayKey(date) {
  194. if (!(date instanceof Date) || Number.isNaN(date.getTime())) return ''
  195. return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
  196. }
  197. function dateToUnixParam(date, type) {
  198. if (!(date instanceof Date) || Number.isNaN(date.getTime())) return ''
  199. if (type === 'datetime') {
  200. return Math.floor(date.getTime() / 1000)
  201. }
  202. // Date-only fields must not use local midnight: in UTC+N that instant is
  203. // the previous calendar day on a UTC server, so each save walked the due
  204. // date back by one day.
  205. return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 1000)
  206. }
  207. function EditableDate({ field, requestUrl }) {
  208. const initialDate =
  209. field.value == null || field.value === ''
  210. ? null
  211. : new Date(fieldValueToUnix(field.value) * 1000)
  212. const {
  213. value: pickerValue,
  214. commit: commitPickerValue,
  215. } = useEditableRequest({
  216. initialValue: initialDate,
  217. requestUrl,
  218. fieldName: field.name,
  219. toParam: (date) => dateToUnixParam(date, field.type),
  220. })
  221. const toPickerString = (date, type) => {
  222. if (!(date instanceof Date) || Number.isNaN(date.getTime())) return '';
  223. const datePart = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
  224. if (type === 'datetime') {
  225. return `${datePart} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:00Z`;
  226. }
  227. return datePart;
  228. }
  229. const handleChange = async (unixTs) => {
  230. const nextPickerValue = new Date(unixTs * 1000)
  231. if (field.type !== 'datetime' && calendarDayKey(nextPickerValue) === calendarDayKey(pickerValue)) {
  232. return
  233. }
  234. await commitPickerValue(nextPickerValue)
  235. }
  236. const { t } = useTranslation();
  237. return (
  238. <DatePicker
  239. value={toPickerString(pickerValue, field.type)}
  240. type={field.type}
  241. name={field.name}
  242. onChange={handleChange}
  243. buttonProps={EDITABLE_VALUE_BUTTON}
  244. >
  245. {field.type=='datetime' ? <Time stylesName=" text-sm text-secondary-foreground " ts={field.value}/> :
  246. <Text className=" text-sm text-secondary-foreground ">{pickerValue ? formatDate(pickerValue, t, {showTime: false}) : 'Select...'}</Text>}
  247. </DatePicker>
  248. )
  249. }
  250. function EditableSelect({ field, requestUrl, openHref }) {
  251. const [isOpen, setIsOpen] = useState(false)
  252. const {
  253. value: selectedKey,
  254. commit: commitSelectedKey,
  255. } = useEditableRequest({
  256. initialValue: field.value,
  257. requestUrl,
  258. fieldName: field.name,
  259. })
  260. const options = normalizeSelectValues(field.values)
  261. const selected =
  262. options.find(
  263. (item) => item.key?.toString() === selectedKey?.toString()
  264. ) ?? options.find((item) => item.key?.toString() === '')
  265. const handleSelect = async (key) => {
  266. setIsOpen(false)
  267. await commitSelectedKey(key)
  268. }
  269. const select = (
  270. <DropdownPopup
  271. buttonProps={{
  272. ...EDITABLE_VALUE_BUTTON,
  273. label: selected?.value ?? '',
  274. accessibilityLabel: field.caption,
  275. }}
  276. minPopupWidth={360}
  277. open={isOpen}
  278. onOpenChange={setIsOpen}
  279. >
  280. <View className="py-1">
  281. {options.map((item) => {
  282. const isSelected =
  283. item.key?.toString() === selected?.key?.toString()
  284. return (
  285. <Pressable
  286. key={item.key}
  287. onPress={() => handleSelect(item.key)}
  288. className="px-3 py-2.5 web:hover:bg-muted/50"
  289. >
  290. <Text
  291. className={cn(
  292. 'text-sm',
  293. isSelected
  294. ? 'text-foreground font-medium'
  295. : 'text-secondary-foreground'
  296. )}
  297. >
  298. {item.value}
  299. </Text>
  300. </Pressable>
  301. )
  302. })}
  303. </View>
  304. </DropdownPopup>
  305. )
  306. const href = typeof openHref === 'function' ? openHref(selectedKey) : openHref
  307. return withTasksListLink(select, href, field.caption)
  308. }
  309. function dedupeProfiles(list) {
  310. if (!Array.isArray(list)) return []
  311. return list.filter((item, index, arr) => arr.findIndex((x) => x.id === item.id) === index)
  312. }
  313. function profileDisplayName(profile) {
  314. return profile?.display_name || profile?.title || profile?.name || ''
  315. }
  316. function AssigneeChip({ profile, onPress, disabled }) {
  317. const data = profile?.author_data || profile
  318. const name = profileDisplayName(data)
  319. return (
  320. <NeoButton
  321. {...EDITABLE_VALUE_BUTTON}
  322. controlSize="mini"
  323. image={
  324. <Profile
  325. {...data}
  326. displayType="unit_wo_info"
  327. displaySize="3xs"
  328. showLinks={false}
  329. />
  330. }
  331. label={name}
  332. accessibilityLabel={name}
  333. onPress={onPress}
  334. disabled={disabled || !onPress}
  335. />
  336. )
  337. }
  338. function AssigneeChips({ profiles, onPress, disabled }) {
  339. const list = dedupeProfiles(profiles)
  340. if (!list.length) return null
  341. return (
  342. <Row className="items-center flex-wrap gap-0.5 min-w-0 self-start">
  343. {list.map((item) => (
  344. <AssigneeChip
  345. key={item.id}
  346. profile={item}
  347. onPress={onPress}
  348. disabled={disabled}
  349. />
  350. ))}
  351. </Row>
  352. )
  353. }
  354. function suggestionsRequestUrl(ajaxGetSuggestions) {
  355. if (!ajaxGetSuggestions) return null
  356. // Same URL build as FormFieldInitialMembers
  357. return '/api.php?r=' + ajaxGetSuggestions + (ajaxGetSuggestions.includes('params[]') ? '' : '&params=')
  358. }
  359. function EditableInitialMembers({ field, requestUrl }) {
  360. const [isEditorOpen, setIsEditorOpen] = useState(false)
  361. const [isSelectOpen, setIsSelectOpen] = useState(false)
  362. const [profiles, setProfiles] = useState(() => dedupeProfiles(field.value_data || []))
  363. const {
  364. commit: commitIds,
  365. } = useEditableRequest({
  366. initialValue: field.value || (field.value_data || []).map((item) => item.id),
  367. requestUrl,
  368. fieldName: field.name,
  369. toParam: (ids) => encodeURIComponent((Array.isArray(ids) ? ids : []).join(',')),
  370. })
  371. const saveProfiles = async (nextProfiles) => {
  372. const deduped = dedupeProfiles(nextProfiles)
  373. const prevProfiles = profiles
  374. setProfiles(deduped)
  375. setIsSelectOpen(false)
  376. const ok = await commitIds(deduped.map((item) => item.id))
  377. if (!ok) setProfiles(prevProfiles)
  378. }
  379. // Same as FormFieldInitialMembers.onSave
  380. const onSave = (data, isAdd = false) => {
  381. if (isAdd) {
  382. data = [...profiles, ...data]
  383. }
  384. saveProfiles(data)
  385. }
  386. const onRemove = (profile) => {
  387. saveProfiles(profiles.filter((item) => item.id !== profile.id))
  388. }
  389. const suggestUrl = suggestionsRequestUrl(field.ajax_get_suggestions)
  390. const openEditor = () => setIsEditorOpen(true)
  391. // Screen 1: named profile chips. Click → modal with form control (screen 2).
  392. return (
  393. <>
  394. <Modal
  395. title={field.caption || 'Assign to'}
  396. onVisible={!!isEditorOpen}
  397. onClose={() => {
  398. setIsEditorOpen(false)
  399. setIsSelectOpen(false)
  400. }}
  401. >
  402. {suggestUrl && isSelectOpen ? (
  403. <Modal
  404. title={field.caption || 'Choose users'}
  405. onVisible={!!isSelectOpen}
  406. onClose={() => setIsSelectOpen(false)}
  407. >
  408. <SelectUsers
  409. onSave={onSave}
  410. requestUrl={suggestUrl}
  411. initedData={[]}
  412. />
  413. </Modal>
  414. ) : null}
  415. <Row className="w-full px-1.5 py-1 items-center justify-between flex-wrap shadow-input-outline dark:shadow-input-outline-deep rounded-lg bg-input/60">
  416. <Row className="gap-2 items-center flex-wrap my-auto flex-1">
  417. {profiles.map((item, index) => (
  418. <User
  419. type="multi"
  420. key={item.id ?? item.value ?? `assigned-${index}`}
  421. data={item}
  422. onSelect={onRemove}
  423. />
  424. ))}
  425. </Row>
  426. <NeoButton
  427. image="Plus"
  428. style="borderless"
  429. controlSize="small"
  430. accessibilityLabel={field.caption || 'Add'}
  431. onPress={() => setIsSelectOpen(true)}
  432. disabled={!suggestUrl}
  433. />
  434. </Row>
  435. </Modal>
  436. {profiles.length > 0 ? (
  437. <AssigneeChips profiles={profiles} onPress={openEditor} />
  438. ) : (
  439. <NeoButton
  440. image="Plus"
  441. style="borderless"
  442. controlSize="small"
  443. accessibilityLabel={field.caption || 'Add'}
  444. onPress={openEditor}
  445. disabled={!suggestUrl}
  446. />
  447. )}
  448. </>
  449. )
  450. }
  451. export default function ElementEntityInfo({ data, blockWrapperProps }) {
  452. const defaultIcon = appSetting('entry', 'default_info_icon');
  453. const contextPid = data.params?._context_id ?? data.params?.context_id
  454. function getValue(a) {
  455. switch (a.type) {
  456. case 'datepicker':
  457. case 'datetime':
  458. if (a.editable) {
  459. return (
  460. <EditableDate
  461. field={a}
  462. requestUrl={data.params.request_url}
  463. />
  464. )
  465. }
  466. if (isNaN(a.value)) {
  467. a.value = (new Date(a.value) / 1000);
  468. }
  469. return (
  470. <ReadonlyValue>
  471. <Time stylesName=" text-sm text-secondary-foreground " ts={a.type=='datetime' ? a.value : (new Date(a.value) / 1000)} />
  472. </ReadonlyValue>
  473. )
  474. case 'select': {
  475. const isTasksList = a.name === 'tasks_list'
  476. const listHref = isTasksList
  477. ? (listId) => buildTasksHomeUrl(contextPid, listId)
  478. : undefined
  479. if (a.editable) {
  480. return (
  481. <EditableSelect
  482. field={a}
  483. requestUrl={data.params.request_url}
  484. openHref={listHref}
  485. />
  486. )
  487. }
  488. const hasValue = (a.value != 0 && a.value != '') || isTasksList
  489. if (!hasValue) return false
  490. const sel = a?.values?.find(item => item.key?.toString() === a.value?.toString())
  491. const value = (
  492. <ReadonlyValue
  493. label={a.values ? (sel ? sel.value : a.values[a.value]) : a.value}
  494. />
  495. )
  496. return withTasksListLink(
  497. value,
  498. listHref?.(a.value),
  499. a.caption
  500. )
  501. }
  502. case 'textarea':
  503. return <Html data={a.values ? a.values[a.value] : a.value} customClassName="u-vanilla-html-small" />
  504. case 'location':
  505. return <ReadonlyValue label={a.value.location_string} />
  506. case 'initial_members':
  507. if (a.editable) {
  508. return (
  509. <EditableInitialMembers
  510. field={a}
  511. requestUrl={data.params.request_url}
  512. />
  513. )
  514. }
  515. return <AssigneeChips profiles={a.value_data} />
  516. case 'switcher':
  517. return <ReadonlyValue label={a.value == 1 ? 'Yes' : 'No'} />
  518. case 'price': {
  519. const label = formatPriceLabel(a.value, a.value_currency)
  520. return label ? <ReadonlyValue label={label} /> : false
  521. }
  522. default:
  523. if (a.name == "profile_last_active") {
  524. if (isNaN(a.value)) {
  525. a.value = (new Date(a.value) / 1000);
  526. }
  527. return (
  528. <ReadonlyValue>
  529. <Time stylesName=" text-base text-secondary-foreground " ts={a.value} />
  530. </ReadonlyValue>
  531. )
  532. }
  533. if (isUrl(a.value)) {
  534. return (
  535. <ReadonlyValue href={a.value}>
  536. <Text className=" text-secondary-foreground text-sm whitespace-normal wrap-break-words">
  537. {getHandleForDisplay(a.value)}
  538. </Text>
  539. </ReadonlyValue>
  540. )
  541. }
  542. if (isPriceObject(a.value)) {
  543. const label = formatPriceLabel(a.value, a.value_currency)
  544. return label ? <ReadonlyValue label={label} /> : false
  545. }
  546. return <ReadonlyValue label={a.value} />
  547. }
  548. }
  549. function getIcon(a) {
  550. const infoIcon = (icon) => <Icon icon={icon} size={INFO_ICON_SIZE} />
  551. if (a.icon) {
  552. return infoIcon(a.icon.charAt(0).toUpperCase() + a.icon.slice(1))
  553. }
  554. switch (a.name) {
  555. case 'gender':
  556. return infoIcon('VenusAndMars')
  557. case 'birthday':
  558. return infoIcon('Cake')
  559. case 'fullname':
  560. case 'first_name':
  561. case 'last_name':
  562. case 'name':
  563. return infoIcon('Signature')
  564. case 'description':
  565. return infoIcon('Shapes')
  566. case 'friends_count':
  567. case 'initial_members':
  568. return infoIcon('Users')
  569. case 'followers_count':
  570. return infoIcon('UserPlus')
  571. case 'profile_last_active':
  572. return infoIcon('Clock')
  573. case 'location':
  574. case 'city':
  575. return infoIcon('MapPin')
  576. case 'email':
  577. return infoIcon('Mail')
  578. case 'phone':
  579. return infoIcon('Phone')
  580. case 'website':
  581. case 'country':
  582. return infoIcon('Globe')
  583. case 'added':
  584. return infoIcon('CirclePlus')
  585. case 'changed':
  586. case 'updated':
  587. return infoIcon('CircleCheck')
  588. case 'cat':
  589. return infoIcon('Folder')
  590. case 'labels':
  591. return infoIcon('Tags')
  592. case 'stickers':
  593. return infoIcon('Tag')
  594. case 'type':
  595. return infoIcon('Shapes')
  596. case 'priority':
  597. return infoIcon('Flag')
  598. case 'estimate':
  599. return infoIcon('Hash')
  600. case 'state':
  601. return infoIcon('CircleDot')
  602. case 'tasks_list':
  603. return infoIcon('ListTodo')
  604. case 'gh_issue_url':
  605. return infoIcon('GitBranch')
  606. case 'views':
  607. return infoIcon('Eye')
  608. case 'space_name':
  609. return infoIcon('Boxes')
  610. case 'start':
  611. case 'start_date':
  612. case 'period_start':
  613. return infoIcon('Calendar')
  614. case 'end':
  615. case 'end_date':
  616. case 'period_end':
  617. return infoIcon('CalendarCheck')
  618. case 'price':
  619. return infoIcon('DollarSign')
  620. default:
  621. return defaultIcon ? infoIcon(defaultIcon) : null
  622. }
  623. }
  624. const inputs = Object.keys(data.inputs).map(function (key) {
  625. const a = data.inputs[key]
  626. const v = a.values ? a.values[a.value] : a.value
  627. if (v || a.editable) {
  628. if (a.type) {
  629. let value = getValue(a);
  630. if (value || a.editable) {
  631. const icon = getIcon(a)
  632. return (
  633. <View className={(a.type != 'textarea' ? 'flex-row items-center flex-wrap ' : '') + " gap-2"} key={a.name}>
  634. <Row className="items-center gap-2 flex-none w-1/3">
  635. {icon ? (
  636. <View className="text-secondary-foreground overflow-hidden items-center justify-center w-5 h-5">{icon}</View>
  637. ) : null}
  638. <View className="flex-auto">
  639. <Text className=" text-sm leading-tight text-secondary-foreground ellipsis">
  640. {a.caption}
  641. </Text>
  642. </View>
  643. </Row>
  644. <View className="flex-auto">
  645. {value}
  646. </View>
  647. </View>
  648. )
  649. }
  650. } else {
  651. return <Text key={a.name}>Unsupporded field type: {a.type}</Text>
  652. }
  653. }
  654. })
  655. return (
  656. <BlockWrapper {...blockWrapperProps}>
  657. <View className='gap-1'>
  658. {inputs}
  659. </View>
  660. </BlockWrapper>
  661. )
  662. }