initial-members.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import Field from './_field';
  2. import { Text } from 'app/design/typography'
  3. import { View, Row, Pressable, ScrollView } from 'app/design/view'
  4. import { useState, useEffect, useReducer } from 'react';
  5. import { fetcher } from 'app/lib/fetcher';
  6. import Profile from 'app/ui/molecules/profile/profile'
  7. import { NeoButton, Input, Modal } from 'app/design/controls'
  8. import Loading from 'app/ui/atoms/loading'
  9. import { Icon } from 'app/ui/atoms/icon'
  10. import { useTranslation } from 'react-i18next';
  11. import { useFormField } from 'app/lib/form/use-form-field';
  12. function suggestionId(item, index) {
  13. const id = item?.id ?? item?.value ?? item?.profile_id
  14. if (id != null && id !== '') return String(id)
  15. return index != null ? `user-${index}` : ''
  16. }
  17. function normalizeSuggestion(item) {
  18. if (!item || typeof item !== 'object') return item
  19. const id = item.id ?? item.value ?? item.profile_id
  20. return {
  21. ...item,
  22. id,
  23. display_name: item.display_name ?? item.label ?? item.title ?? item.name,
  24. }
  25. }
  26. export const User = ({ data, onSelect, type }) => {
  27. if (type !== 'multi'){
  28. return (
  29. <Pressable className="" onPress={() => onSelect(data)}>
  30. <Row className=" p-1.5 h-9 overflow-hidden truncate bg-muted rounded-full items-center justiy-center">
  31. {<Profile displaySize="xs" displayType="unit_wo_info" {...data} showLinks={false} />}
  32. <Text className="text-card-foreground web:hover:text-primary text-sm px-1.5 font-semibold tracking-tight truncate">
  33. {data.display_name}
  34. </Text>
  35. </Row>
  36. </Pressable>
  37. )
  38. }
  39. return (
  40. <Pressable className="" onPress={() => onSelect(data)}>
  41. <Row className=" p-1.5 h-9 overflow-hidden truncate rounded-full shadow-btn-outline dark:shadow-btn-outline-deep bg-card items-center justiy-center">
  42. {<Profile displaySize="xs" displayType="unit_wo_info" {...data} showLinks={false} />}
  43. <Text className="text-card-foreground px-1.5 web:hover:text-primary text-sm font-semibold tracking-tight truncate">
  44. {data.display_name}
  45. </Text>
  46. <Text className="text-card-foreground rounded-full bg-muted p-0.5 my-auto h-6 w-6 items-center justify-center"><Icon icon="X" size={20} /></Text>
  47. </Row>
  48. </Pressable>
  49. );
  50. };
  51. const EMPTY_SUGGESTIONS = [];
  52. export function SelectUsers({ onSave, initedData = [], requestUrl, isSingle = false, extraSuggestions = EMPTY_SUGGESTIONS, autoFocus = false }) {
  53. const { t } = useTranslation();
  54. const extraList = Array.isArray(extraSuggestions) ? extraSuggestions : EMPTY_SUGGESTIONS;
  55. const initialState = {
  56. suggestedUsers: extraList,
  57. selectedUsers: Array.isArray(initedData) ? initedData : [],
  58. showLoading: false,
  59. searchText: ''
  60. };
  61. const [state, dispatch] = useReducer(reducer, initialState);
  62. function reducer(state, action) {
  63. if (action.suggestedUsers)
  64. action.suggestedUsers = action.suggestedUsers
  65. .map(normalizeSuggestion)
  66. .filter((v, i, a) => a.map(e => e.id).indexOf(v.id) === i);
  67. if (action.selectedUsers)
  68. action.selectedUsers = action.selectedUsers.filter((v, i, a) => a.map(e => e.id).indexOf(v.id) === i);
  69. switch (action.type) {
  70. case 'setSelectedUsers':
  71. return { ...state, selectedUsers: action.selectedUsers };
  72. case 'setLoading':
  73. return { ...state, showLoading: true };
  74. case 'searchFinished':
  75. const selectedUserIds = state.selectedUsers.map(user => user.id);
  76. action.suggestedUsers = action.suggestedUsers.filter(user => !selectedUserIds.includes(user.id));
  77. return { ...state, suggestedUsers: action.suggestedUsers, showLoading: false, searchText: action.searchText };
  78. case 'userSelected':
  79. return { ...state, suggestedUsers: action.suggestedUsers, selectedUsers: action.selectedUsers };
  80. }
  81. }
  82. // Plain handlers: React Compiler memoizes them with the real dependencies
  83. // (the manual useCallback deps here were incomplete, e.g. requestUrl /
  84. // state.selectedUsers were read but not listed).
  85. const matchExtra = (term) => {
  86. const q = String(term || '').trim().toLowerCase();
  87. if (!q) return extraList;
  88. return extraList.filter((item) => String(item?.display_name || item?.title || '').toLowerCase().includes(q));
  89. };
  90. useEffect(() => {
  91. if (state.searchText) return;
  92. dispatch({ type: 'searchFinished', suggestedUsers: extraList, searchText: '' });
  93. }, [extraList]);
  94. const onChangeText = async (sValue) => {
  95. dispatch({ type: 'setLoading' })
  96. let suggestions = []
  97. try {
  98. const sResponse = await fetcher(requestUrl + JSON.stringify({ term: sValue }));
  99. suggestions = Array.isArray(sResponse.data) ? sResponse.data : []
  100. } catch {
  101. suggestions = []
  102. }
  103. dispatch({ type: 'searchFinished', suggestedUsers: [...matchExtra(sValue), ...suggestions], searchText: sValue })
  104. };
  105. const onSelectUser = (oData) => {
  106. dispatch({ type: 'userSelected', selectedUsers: [...state.selectedUsers, oData], suggestedUsers: state.suggestedUsers.filter((user) => user.id !== oData.id) })
  107. };
  108. useEffect(() => {
  109. if (isSingle && state.selectedUsers.length > 0)
  110. onSave(state.selectedUsers);
  111. }, [state.selectedUsers]);
  112. const onRemove = (oData) => {
  113. dispatch({ type: 'userSelected', selectedUsers: state.selectedUsers.filter((user) => user.id !== oData.id), suggestedUsers: [...state.suggestedUsers, oData] })
  114. };
  115. const onSaveInt = async () => {
  116. onSave(state.selectedUsers, !isSingle);
  117. };
  118. return <View className="px-1">
  119. <ScrollView keyboardDismissMode="none" keyboardShouldPersistTaps="handled" className="w-full overflow-visible min-h-64" >
  120. <Row className="text-center w-full flex-wrap gap-1 pb-4">
  121. {state.selectedUsers && state.selectedUsers.map((item, index) => <User key={suggestionId(item, index)} data={item} onSelect={onRemove} />)}
  122. </Row>
  123. <Row className="pb-4 gap-2 px-1">
  124. <Input
  125. placeholder={"Select users..."}
  126. className="px-2 mr-2 w-full"
  127. onChangeText={onChangeText}
  128. role="textbox"
  129. autoFocus={autoFocus}
  130. />
  131. <NeoButton style="borderedProminent" controlSize="large" image="Check" accessibilityLabel={t('Save')} disabled={state.selectedUsers.length == 0} onPress={() => onSaveInt()} />
  132. </Row>
  133. <Row className="text-center w-full flex-wrap gap-1 ">
  134. {!state.searchText && extraList.length > 0 && state.suggestedUsers.length > 0 && !state.showLoading ? (
  135. <Text className="text-sm text-muted-foreground w-full px-1 pb-1">{t('messenger_agents')}</Text>
  136. ) : null}
  137. {state.suggestedUsers && !state.showLoading && state.suggestedUsers.map((item, index) => <User key={suggestionId(item, index)} data={item} onSelect={onSelectUser} />)}
  138. {state.showLoading && <View className=' w-full items-center justify-center py-2'><Loading /></View>}
  139. {state.suggestedUsers.length == 0 && state.searchText != '' && !state.showLoading && <Text className="text-sm py-2">{t('Nothing found')}</Text>}
  140. </Row>
  141. </ScrollView>
  142. </View>
  143. };
  144. export default function FormFieldInitialMembers(props) {
  145. const { field } = useFormField(props);
  146. const [selected, setSelected] = useState(props.value_data ? props.value_data : []);
  147. const [isModal, setIsModal] = useState(false);
  148. const isSingle = props?.custom?.only_once;
  149. const onSave = (data, isAdd = false) => {
  150. if (isAdd) {
  151. data = [...selected, ...data]
  152. }
  153. data = data.filter((v, i, a) => a.map(e => e.id).indexOf(v.id) === i);
  154. const value = data.map(item => item.id);
  155. setSelected(data)
  156. field.onChange(value);
  157. setIsModal(false);
  158. }
  159. const showSelect = () => {
  160. setIsModal(true);
  161. }
  162. const onRemove = (valueToRemove) => {
  163. if (!isSingle) {
  164. onSave(selected.filter(item => item.id !== valueToRemove.id))
  165. }
  166. };
  167. return (
  168. <Field {...props}>
  169. <Modal id='file-preview2' title={props.title || "Choose users"} onVisible={!!isModal} onClose={() => { setIsModal(false) }}>
  170. <SelectUsers isSingle={isSingle} onSave={onSave} requestUrl={'/api.php?r=' + props.ajax_get_suggestions + (props.ajax_get_suggestions.includes("params[]") ? '' : "&params=")} initedData={[]} />
  171. </Modal>
  172. <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'>
  173. <Row className='gap-2 items-center flex-wrap my-auto flex-1'>
  174. {selected && selected.map((oItem) => <User type={isSingle ? '' : "multi"} key={oItem.id} data={oItem} onSelect={isSingle ? showSelect : onRemove} />)}
  175. </Row>
  176. <NeoButton
  177. image={isSingle ? 'RefreshCw' : 'Plus'}
  178. style="borderless"
  179. controlSize="small"
  180. accessibilityLabel={isSingle ? 'Change' : 'Add'}
  181. onPress={() => showSelect()}
  182. />
  183. </Row>
  184. </Field>
  185. );
  186. }