App.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. import React, {useState, useEffect, useMemo, useRef} from 'react';
  2. import logo from './logo.svg';
  3. import './App.css';
  4. import Select from 'react-select/async'
  5. import {sortableContainer, sortableElement} from 'react-sortable-hoc';
  6. import { GraphQLClient } from 'graphql-request';
  7. import createModels2 from './front-models'
  8. let gql;
  9. const getGQL = () => new GraphQLClient(localStorage.url + 'graphql', {headers: localStorage.authToken ? {Authorization: 'Bearer '+localStorage.authToken} : {}})
  10. if (localStorage.authToken && localStorage.url){
  11. gql = getGQL()
  12. }
  13. const ShortName = ({record, options}) => {
  14. if (record && record.constructor)
  15. if (options.view.relations[record.constructor.name]){
  16. const Formatter = options.view.relations[record.constructor.name]
  17. return <Formatter options={options}>{record}</Formatter>
  18. }
  19. return (<>{(record && record.name) || record.key || record.login || record.originalFileName || record._id}</>)
  20. }
  21. const EditFormRow = ({field, models={}, value, options=defaultAdminOptions, record, onChange}) => {
  22. return (
  23. <tr>
  24. <th>{field.name}</th>
  25. <td><Cell models={models} model={getModelByField(field, models)} options={options} record={record} field={field} selected onChange={onChange}>{value}</Cell></td>
  26. </tr>
  27. )
  28. }
  29. const EditForm = ({record, models={}, model, options=defaultAdminOptions, Components:{EditFormRow:EFR}={EditFormRow}}) => {
  30. const [edit, setEdit] = useState({...record})
  31. if (!record) return <></>
  32. if (!record.save || !record._id){
  33. Object.assign(record, edit)
  34. }
  35. const fields = model.fields
  36. const onlyInputs = model.inputs.filter(input => !fields.find(field => field.name === input.name))
  37. return (
  38. <>
  39. <table>
  40. <tbody>
  41. {Object.entries(fields).map(([key,field]) => <EFR key={key} field={field}
  42. models={models}
  43. record={record}
  44. value={edit[field.name]}
  45. options={options}
  46. onChange={event => {
  47. setEdit({...edit, [field.name]: event && event.target ? event.target.value : event})
  48. }}
  49. />)}
  50. {Object.entries(onlyInputs).map(([key,field]) => <EFR key={key} field={field}
  51. models={models}
  52. record={record}
  53. value={edit[field.name]}
  54. options={options}
  55. onChange={event => setEdit({...edit, [field.name]: event && event.target ? event.target.value : event})}
  56. />)}
  57. </tbody>
  58. </table>
  59. {record._id && record.save &&
  60. <button onClick={() => {
  61. Object.assign(record, edit);
  62. if ('_id' in record && 'save' in record)
  63. record.save()
  64. }}>
  65. Save
  66. </button>}
  67. </>
  68. )
  69. }
  70. const Row = ({top, selected, children}) => {
  71. return (
  72. <>
  73. <div className={`Row ${selected ? 'selected' : ''}` } >
  74. {children}
  75. </div>
  76. </>
  77. )
  78. }
  79. const Cell = ({children, options, record, field, selected, model, models={}, onClick, ...props}) => {
  80. let Formatter = React.Fragment
  81. const type = (children && typeof children === 'object' && children.constructor) || model
  82. const viewOrEdit = (!record.constructor.inputs || record.constructor.inputs.some(input => input.name === field.name)) && selected ? 'edit' : 'view'
  83. const typeName = field.type.name || field.type.ofType.name
  84. if (typeName in options[viewOrEdit].formatters){
  85. Formatter = options[viewOrEdit].formatters[typeName]
  86. }
  87. if (field.name in options[viewOrEdit].fields){
  88. Formatter = options[viewOrEdit].fields[field.name]
  89. }
  90. if (type){
  91. if (type.name in options[viewOrEdit].formatters)
  92. Formatter = options[viewOrEdit].formatters[type.name]
  93. else
  94. Formatter = options[viewOrEdit].formatters.Object
  95. }
  96. return(
  97. <div className='Cell' onClick={onClick}>
  98. <Formatter record={record} options={options} model={model} models={models} {...props} field={field}>
  99. {Formatter === React.Fragment ? (children && children.toString()) : children}
  100. </Formatter>
  101. </div>
  102. )
  103. }
  104. const ModelListItem = "div"
  105. const ModelList = ({models, selected, onChange, Item=ModelListItem}) => {
  106. return (
  107. <aside>
  108. {Object.entries(models).map(([name, model]) => {
  109. return (
  110. <Item key={name}
  111. selected={name === selected}
  112. className={name === selected ? "selected" : undefined}
  113. onClick = {() => onChange(name)}
  114. >
  115. {name}
  116. </Item>
  117. )
  118. })}
  119. </aside>
  120. )
  121. }
  122. const Search = ({...props}) =>
  123. <input placeholder="Search..." {...props} className="Search"/>
  124. const Count = ({...props}) => <div className="Count" {...props} />
  125. const searchQueryBuilder = (search, model) => {
  126. if (!search) return {}
  127. const queryRegexp = `/${search.trim().split(/\s+/).join('|')}/`
  128. return {$or: model.fields.filter(field => field.type.kind == 'SCALAR').map(field => ({[field.name]: queryRegexp}))}
  129. }
  130. const GridHeaderItem = ({field, sort:[sort], ...props}) =>
  131. <div className="GridHeaderItem" {...props}>
  132. {field.name in sort && sort[field.name] === -1 && '^ '}
  133. {field.name}
  134. {field.name in sort && sort[field.name] === 1 && ' v'}
  135. </div>
  136. const GridHeader = ({fields, sort, onSort}) =>
  137. <div className="GridHeader">
  138. {fields.map(field => <GridHeaderItem key={field.name} field={field} sort={sort} onClick={() => onSort(field.name)}/>)}
  139. </div>
  140. const getModelByField = (field, models={}) => {
  141. if (field.type.kind === 'OBJECT') {
  142. return models[field.type.name]
  143. }
  144. if (field.type.kind === 'LIST') return models[field.type.ofType.name]
  145. }
  146. const VirtualScroll = ({options, gridHeight, count, rowHeight, components:Components={Row, Cell}, onScroll, records, skip, models={}}) => {
  147. const limit = gridHeight/rowHeight
  148. const {Row, Cell} = Components
  149. const [edit, setEdit] = useState({field: null, record: null})
  150. useEffect(() => setEdit({field: null, record: null}), [records])
  151. const fields = records && records[0] && records[0].constructor.fields
  152. return (
  153. <>
  154. <div className='GridViewport'
  155. style={{maxHeight: gridHeight, height: gridHeight}} >
  156. <div className='GridContent'
  157. style={{height: count*rowHeight, minHeight: count*rowHeight, maxHeight: count*rowHeight}} >
  158. {records && records.map((record,i) =><React.Fragment key={i}>
  159. {edit.record && edit.record._id === record._id ? <EditForm model={record.constructor} models={models} record={record} options={options}/>:
  160. <Row options={options}>
  161. {fields.map(field =>
  162. <Cell models={models} model={getModelByField(field, models)} record={record} key={field.name} field={field} options={options}
  163. onClick={() => setEdit({record: {...record}, field})}>
  164. {record[field.name]}
  165. </Cell>)}
  166. </Row>}
  167. </React.Fragment>
  168. )}
  169. </div>
  170. </div>
  171. </>
  172. )
  173. }
  174. const ModelView = ({model, models={}, options, components:Components={Search, Count, GridHeader, Grid:VirtualScroll}, rowHeight=150, gridHeight=500, overload=2}) => {
  175. const [records, setRecords] = useState([])
  176. const [search, setSearch] = useState("")
  177. const [count, setCount] = useState(0)
  178. const [query, setQuery] = useState({})
  179. const [cursorCalls, setCursorCalls] = useState({sort:[{}], skip: [0]/*, limit: [gridHeight/rowHeight]*/})
  180. const skip = cursorCalls.skip[0]
  181. console.log(cursorCalls)
  182. useEffect(() => {
  183. model.count(query, cursorCalls).then(count => setCount(count))
  184. model.find(query, cursorCalls).then(records => Promise.all(records)).then(records => setRecords(records))
  185. }, [query, model, cursorCalls])
  186. const timeout = useRef(0)
  187. useEffect(() => {
  188. clearInterval(timeout.current)
  189. if (!search)
  190. setQuery(searchQueryBuilder(search, model))
  191. else {
  192. timeout.current = setTimeout(() => {
  193. setQuery(searchQueryBuilder(search, model))
  194. },1000)
  195. }
  196. },[search, model])
  197. return (
  198. <>
  199. <Components.Search options={options} value={search} onChange={({target: {value}}) => setSearch(value)}/>
  200. <Components.Count options={options}>
  201. {count}
  202. </Components.Count>
  203. <Components.GridHeader fields={model.fields}
  204. sort={cursorCalls.sort}
  205. onSort={sort => setCursorCalls({...cursorCalls,
  206. sort: [{[sort]: cursorCalls.sort[0][sort] === 1 ? -1 : 1}]
  207. })}/>
  208. <button onClick={() => {
  209. (new model).save()
  210. setCursorCalls({...cursorCalls, sort: [{_id: -1}]})
  211. setSearch('')
  212. }}>+</button>
  213. {records && <Components.Grid
  214. models={models}
  215. options={options}
  216. skip={skip}
  217. count={count}
  218. records={records}
  219. gridHeight={700}
  220. rowHeight={50}
  221. onScroll={(skip, limit) => {
  222. limit = undefined;
  223. model.find(query, {...cursorCalls, limit: [limit], skip: [skip]}).then(records => Promise.all(records)).then(records => setRecords(records)).then(() =>
  224. setCursorCalls({...cursorCalls, limit: [limit], skip: [skip]}))
  225. }}/> }
  226. </>
  227. )
  228. }
  229. const ObjectShortView = ({children, options}) => {
  230. const [record, setRecord] = useState(children)
  231. if (!children) return <></>
  232. if (typeof children === 'object' && 'then' in children){
  233. children.then(child => setRecord({...child}))
  234. }
  235. if (typeof children === 'object' && children._id){
  236. return (
  237. <div className="ObjectShortView">
  238. <ShortName record={record} options={options}/>
  239. </div>
  240. )
  241. }
  242. else {
  243. return (
  244. <pre>
  245. {JSON.stringify(record, null, 4)}
  246. </pre>
  247. )
  248. }
  249. }
  250. const ForeignAutocomplete = ({models={}, children:value, onChange, model, options={}}) => {
  251. return (
  252. <Select cacheOptions
  253. defaultOptions
  254. placeholder={model.name}
  255. loadOptions={async search => {
  256. const suggesions = await model.find(searchQueryBuilder(search, model)) || []
  257. return [{_id: null, key: 'REMOVE'}, ...suggesions]//.filter(record => !exclude.find(e => (e === record._id) || (e._id === record._id)))
  258. .map((record) => ({value: record._id, label: <ShortName record={record} options={options}/>, record}))
  259. }}
  260. value={value && {value: value._id, label: <ShortName record={value} options={options}/>, record: value}}
  261. onChange={(obj) => {
  262. onChange(obj.record._id ? obj.record : null)
  263. }}
  264. />
  265. )
  266. }
  267. const ObjectShortEdit = ({...props}) =>
  268. <>
  269. <ForeignAutocomplete {...props}/>
  270. </>
  271. const SortableCell = sortableElement(({onAdd, onDelete, onCh, ...props}) =>
  272. <div className='SortableCell'>
  273. <button onClick={onAdd}>+</button>
  274. <button onClick={onDelete}>x</button>
  275. <Cell onChange={onCh} {...props} />
  276. </div>)
  277. const SortableContainer = sortableContainer(({children}) => {
  278. return <div>{children}</div>;
  279. });
  280. const arrayMove = (arr, newIndex, oldIndex) => {
  281. const withoutOld = arr.filter((item, i) => i !== oldIndex)
  282. return [...withoutOld.slice(0, newIndex), arr[oldIndex], ...withoutOld.slice(newIndex)]
  283. }
  284. const defaultAdminOptions =
  285. {
  286. view: {
  287. formatters:{
  288. ID: ({children}) => <b>{children && children.slice(-6).toUpperCase()}</b>,
  289. String: ({children}) => <>{children && children.length > 100 ? children.slice(0,100) + '...' : children}</>,
  290. Object: ObjectShortView,
  291. Array: ({children, ...props}) => <>{children.map((child,i) => <ObjectShortView key={child && (child._id || child.key || child.name) || i} children={child} {...props}/>)}</>
  292. },
  293. fields:{
  294. createdAt: ({children}) => <>{new Date(+children).toISOString()}</> ,
  295. url: ({children}) => <a href={children}>{children}</a>,
  296. color:(props) => <span style={{backgroundColor: props.value}}>{props.value}</span>
  297. },
  298. relations: {
  299. Image: ({children}) => <span className="ImageRelation"><img src={localStorage.url + children.url}/>{children.originalFileName}</span>
  300. },
  301. models: {
  302. }
  303. },
  304. edit: {
  305. formatters:{
  306. ID: ({children}) => <b>{children && children.slice(-6).toUpperCase()}</b>,
  307. String: ({children, field, ...props}) => <textarea placeholder={field.name} value={children} {...props}/>,
  308. Int: ({children, ...props}) => <input type='number' value={children} {...props}/>,
  309. Float: ({children, ...props}) => <input type='number' value={children} {...props} onChange={(e) => props.onChange(+e.target.value)}/>,
  310. Object: ({children, options, model, models, ...props}) => {
  311. return model.fields[0].name === '_id' ? <ObjectShortEdit children={children} options={options} model={model} {...props}/> : <EditForm model={model} models={models} record={children} options={options} />
  312. },
  313. Array: ({children, onChange, model, ...props}) => {
  314. const newItem = !model || (model && model.fields[0].name === '_id') ? null : new model
  315. return (<><SortableContainer
  316. pressDelay={200}
  317. onSortEnd={({newIndex, oldIndex}) => {
  318. onChange(arrayMove(children, newIndex, oldIndex))
  319. }}>{children.map((child, i) =>
  320. <SortableCell onDelete={() => onChange(children.filter((item, j) => j !== i))}
  321. onAdd={() => onChange([...children.slice(0, i),newItem,...children.slice(i)])}
  322. index={i} key={(child && (child._id || child.key || child.name)) || i} model={model} {...props} children={child} selected
  323. onCh={data => {
  324. data = (data && data.target) ? data.target.value : data
  325. console.log(data, children)
  326. if (!children.find(record => record && record._id === data._id)){
  327. const copy = [...children]
  328. copy[i] = data
  329. onChange(copy)
  330. }
  331. }}
  332. />)}
  333. </SortableContainer>
  334. <button onClick={() => onChange([...children, newItem])}>+</button></>)
  335. }
  336. },
  337. fields:{
  338. createdAt: ({children}) => <input value={new Date(+children).toISOString()} />,
  339. url: ({children}) => <a href={children}>{children}</a>,
  340. color:({children, ...props}) => <input type='color' value={children} {...props}/>
  341. },
  342. models: {
  343. }
  344. }
  345. }
  346. const Admin = ({models, components:{ModelList:ML=ModelList, Search:S=Search, ModelListItem:MLI=ModelListItem, ModelView:MV=ModelView, Count:C=Count, GridHeader:GH=GridHeader, Grid:G=VirtualScroll}={},
  347. options=defaultAdminOptions
  348. }) => {
  349. const [selected, setSelected] = useState()
  350. const mergedOptions = {...defaultAdminOptions, ...options}
  351. return (
  352. <>
  353. <ML Item={MLI} models={models} onChange={(name) => setSelected(name)} selected={selected}/>
  354. <content>
  355. {selected && <MV models={models} options={mergedOptions} model={models[selected]} components={{Search: S, Count: C, GridHeader:GH, Grid:G}} /> }
  356. </content>
  357. </>
  358. )
  359. }
  360. const LoginForm = ({onLogin}) => {
  361. const [url, setUrl] = useState(localStorage.url)
  362. const [login, setLogin] = useState('')
  363. const [password, setPassword] = useState('')
  364. return (
  365. <>
  366. <input placeholder="url" value={url} onChange={e => setUrl(e.target.value)}/>
  367. <input placeholder='login' value={login} onChange={e => setLogin(e.target.value)}/>
  368. <input type='password' placeholder='password' value={password} onChange={e => setPassword(e.target.value)}/>
  369. <button onClick={() => {
  370. onLogin({url, login, password})
  371. }}>Login</button>
  372. </>
  373. )
  374. }
  375. function App() {
  376. let [models, setModels] = useState()
  377. models || createModels2(gql).then(models => setModels(models))
  378. const classes = models
  379. return (
  380. <>
  381. <LoginForm onLogin={async ({url, login, password}) => {
  382. let gql = new GraphQLClient(url + 'graphql')
  383. let token = await gql.request(`query login ($login: String, $password: String){
  384. login(login: $login, password: $password)
  385. }`, {login, password})
  386. if (token.login){
  387. localStorage.authToken = token.login
  388. localStorage.url = url
  389. }
  390. else {
  391. localStorage.removeItem('authToken')
  392. }
  393. gql = getGQL()
  394. createModels2(gql).then(models => setModels(models))
  395. }}/>
  396. <div className="App">
  397. {models && <Admin models={models} />}
  398. </div>
  399. </>
  400. );
  401. }
  402. export default App;