App.js 22 KB

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