src/admin.js
import React from 'react';
import _ from 'lodash';
import Set from './set.js';
import Select from 'react-select';
import 'react-select/dist/react-select.css';
const uuidv1 = require('uuid/v1');
export const display_type = {
list : "list",
change : "change"
};
/** Admin Class extends React.Component. To implement a CRUD interface similar to Django Admin you need to extend the Admin class. */
class Admin extends React.Component {
constructor(props)
{
super(props);
this.name='default'
this.name_plural='defaults'
this.live_search=false
this.field_transforms={}
this.header_transforms={}
this.extra_fields={}
this.list_display=[]
this.list_display_links=[]
this.list_per_page=10
this.sort_fields=[]
this.is_object_equal=(a,b)=> a==b
this.pages_in_pagination = 3
this.actions={
"delete" : (selected_objects)=>{
}
}
this.queryset=[]
this._all_selected=false
this.register_listener()
if(props)
{
this.bind_exclude= props.bind_exclude ? props.bind_exclude : []
_.keys(props).map((key)=>{
if(typeof props[key] == "function")
{
if(_.includes(key,this.bind_exclude))
{
this[key] = props[key]
this[key].bind(this);
}
}
else
{
this[key]=props[key];
}
});
}
/**
* Initialize the state of the component
*/
this.state = {display_type : display_type.list , page_number : 1,object : null,selected_objects:new Set([],this.is_object_equal),total:1,filter_options:{},filter_values:[]}
let queryset=this.get_queryset(this.state.page_number,this.list_per_page,this.state.queryset) ? this.get_queryset(this.state.page_number,this.list_per_page,this.state.queryset) : [] ;
this.state.queryset=queryset;
this.state.total=queryset.length;
this._handle_search = this._handle_search.bind(this);
this._select_all = this._select_all.bind(this);
this._select_one=this._select_one.bind(this);
this.response_change = this.response_change.bind(this);
this.response_add = this.response_add.bind(this);
}
/**
* This function returns an array of objects that will serve as the
* queryset for the admin interface. Typically involves an HTTP request
* to a backend.
* @param {number} page_number - The current page number
* @param {number} list_per_page - Number items to list per page. Defaults to `list_per_page`
* @param {object[]} queryset - The current queryset
* @returns {object[]} An array of objects. - Objects to display
*/
get_queryset(page_number=this.state.page_number,list_per_page=this.list_per_page,queryset=this.state.queryset)
{
return this.queryset;
}
/**
* This functions returns a JSON Schema Form for editing
* the objects in the array returned by get_queryset(). This method needs to be overridden to
* so as to return a Form Component for the object. Learn more on
* JSON schema forms from https://github.com/mozilla-services/react-jsonschema-form
* and JSON Schema from https://spacetelescope.github.io/understanding-json-schema/
* @param {object} object - The current selected object.
* @returns A JSON Schema Form Component.
*/
get_form(object=null)
{
return {}
}
/**
* Returns a true/false value. Controls wether search is implement on live input or not.
* Can be overriden by the live_search member variable. Default is false.
*
*@return {boolean}
*/
get_live_search()
{
return this.live_search;
}
/**
*Returns an object whose properties are field names corresponding to properties of any object
*in the queryset and whose values are transform functions. The example below will transform each "name"
*property of objects in the queryset to upper case in the list display view.
*@example
*get_field_transforms()
*{
* return { 'name' : function(name,object)
* {
* return name.toUpperCase()
* }
* }
*
*}
*@return {object}
*/
get_field_transforms()
{
if(this.field_transforms)
{
return this.field_transforms
}
return {}
}
/**
*Returns an object whose properties are field names corresponding to properties of any object
*in the queryset and whose values are transform functions. The example below will transform the header "name" to upper case in the list display view.
*@example
*get_header_transforms()
*{
* return { 'name' : function(name)
* {
* return name.toUpperCase()
* }
* }
*}
*
*@return {object}
*/
get_header_transforms()
{
if(this.header_transforms)
{
return this.header_transforms;
}
return {}
}
/**
* Returns an object whose properties are extra field names not corresponding to properties of any object
* in the queryset and whose values are display functions. This will create extra fields that are not tied to objects. Extra fields have to be manually included in the `list_display` in order to appear in the list display page. The display functions take the current object being rendered in the table row and the field name
*@example
*get_extra_fields()
*{
* return { 'now' : function(object,label)
* {
* return moment(new Date()).format('LLL');
* }
* }
*}
*
*@return {object}
*/
get_extra_fields()
{
if(this.extra_fields)
{
return this.extra_fields;
}
return {}
}
/**
* Returns the number of items to be listed in a page. Can be overridden by `list_per_page`.
*@return {number} The number of objects in a page
*/
get_list_per_page()
{
if(this.list_per_page)
{
return this.list_per_page
}
return 10
}
/**
* Gets an actions object whose properties are action names and values are action methods.
* This can be overridden by the action member variable. The default "delete" method is not
* implemented.
*
* Each actions object property (e.g. "delete") is passed an array of selected objects. One
* can then handle those objects. Actions will appear on the list display page within a
* dropdown. Selecting an action should have the action method applied to all currently
* selected objects.
* @example
*
* actions = { "delete" : (selected_objects)=>{ } }
*@return {object} An actions object
*/
get_actions()
{
if(this.actions)
{
return this.actions;
}
return {}
}
/**
* Gets the list/array of properties of the objects in the queryset that are clickable
* when displayed on the list display page. It can be overridden by the member variable
* `list_display_links`. A property is any string that should exist as a property in the objects within
* a queryset and works with lodash's `_.at` function.
*
* The properties "name","address.street" and "emails[0]" are all acceptable by `_.at` in the example below
* @example
* let object={ name : "any name",{ address : { street : "any"}},emails: ["any@any.com"]}
*
*
*
* @return {string[]} A list of properties of the object to be displayed
*/
get_list_display_links()
{
if(this.list_display_links)
{
return this.list_display_links;
}
return [];
}
/**
* Gets the list/array of properties/field names of the objects in the queryset to be
* displayed on the list display page. It can be overridden by the member variable
* list_display. A property is any string that should exist in the objects within
* a queryset and works with lodash's _.at function. See more at
* https://lodash.com/docs/#at
*
* @example
* let object={ name : "any name",{ address : { street : "any"}},emails: ["any@any.com"]}
*
* The properties "name","address.street" and "emails[0]" are all acceptable
*
* @return {string[]} A list of properties of the object to be displayed
*/
get_list_display()
{
if(this.list_display)
{
return this.list_display;
}
return []
}
/**
* Returns an ordered queryset. The method checks to see if sorting is active and sorts
* the current queryset based on the sort order.
* @private
* @return {object[]} An ordered queryset
*/
_get_ordered_queryset()
{
if(this.sort_fields.length>0)
{
return this.sort_by(this.sort_fields,this.state.queryset)
}
return this.state.queryset ? this.state.queryset : [];
}
/**
* Implements search. This method should be overridden to implement a custom search
*
*@param {string} term - the search term
*@param {object[]} queryset - the current queryset
*@return {object[]} the queryset as a result of the search
*/
search(term,queryset)
{
return queryset
}
/**
* Grants permission to delete object. This method is not implemented and can be handled via
* implementing actions.
*
*@param {object} object -
*@return {boolean} Returns true is the object has been deleted or false otherwise
*/
has_delete_permission(object)
{
return true
}
/**
* Grants permission to add an object. This method controls rendering of the Add
* button
*
*
*@return {boolean} Returns true is the object can be added or false otherwise
*/
has_add_permission()
{
return true;
}
/**
* Grants permission to change an object. It disables all links to the add/change page when
* enabled
*
*@return {boolean} Returns true is the object can be changed or false otherwise
*/
has_change_permission(object=null)
{
return true
}
/**
* Grants permission to the this admin interface.
*
*@return {boolean} Returns true if access is allowed false otherwise
*/
has_module_permission()
{
return true
}
/*
_order_state(field)
{
let index=_.findIndex(this._sort_fields,item=> item.hasOwnProperty(field));
if(index<0)
{
return 'sort-not-active';
}
if(this._sort_fields[index][field]=='desc')
{
return 'sort-reverse-active';
}
return 'sort-active';
}*/
/**
* This method adds up/down arrows to field headers on the list display page table
*
* @private
* @param {string} field - the field/property name
*/
_order_state_arrow(field)
{
let index=_.findIndex(this.sort_fields,item=> item.hasOwnProperty(field));
if(index<0)
{
return null;
}
if(this.sort_fields[index][field]=='desc')
{
return <span>⇩</span>
}
return <span> ⇧</span>
}
/**
* This method should be overriden and called after saving an object in the add/change view. * This method is not called at all here but provides hints on what to do after saving
* an object. Change the state display_type to "list", object to "null" and refresh the quer * yset.
*/
response_add()
{
this._display_will_change()
this.setState({display_type :display_type.list, object :null,queryset: this.get_queryset(this.state.page_number,this.list_per_page,this.state.queryset) },this._display_changed);
return true
}
/**
* This method should be overriden and called after saving an object in the add/change view. * This method is not called at all here but provides hints on what to do after saving
* an object. Change the state display_type to "list", object to "null" and refresh the quer * yset.
*/
response_change()
{
this._display_will_change()
this.setState({display_type :display_type.list, object :null,queryset: this.get_queryset(this.state.page_number,this.list_per_page,this.state.queryset) },this._display_changed);
return true
}
/**
* A private method to wrap a table entry in an <a></a> tag in the display page.
* The method checks if permission is given to display links using the has_change_permission method
* @private
* @param {object} object - the current object to be displayed as a table
* entry in the display page
* @param {string} label - the name of the field
*/
_create_object_link(object,label)
{
if(this.has_change_permission(object))
{
return <a onClick={this._object_link_clicked(object)} href="#"> {label} </a>
}
else
{
return <span> {label} </span>
}
}
set_queryset(queryset)
{
this.setState({queryset : queryset})
}
set_total(total)
{
this.setState({total : total})
}
_get_prop_label(label)
{
let labels=label.split('.');
return labels[labels.length-1];
}
_get_table_header()
{
return this.get_list_display().map((item)=>
{
return <th key={item} onClick={this._sort_handler(item)} > { this.get_header_transforms()[item] ? this.get_header_transforms()[item](item): _.startCase(this._get_prop_label(item))}{ this._order_state_arrow(item)} </th>
})
}
_display_changed()
{
this.display_changed(this.state.display_type,this.state.object)
}
/**
* An event listener that listens to state changes in the display type i.e.
* from list to add/change. It is fired after state changes are made.
*
* @param {string} display_type - the current display type of the component either "list" or "change"
* @param {object} object - the current object or null
*/
display_changed(display_type,object)
{
}
_display_will_change()
{
this.display_will_change(this.state.display_type,this.state.object)
}
/**
* An event listener that listens to state changes in the display type i.e.
* from list to add/change . It is fired before state changes are made.
*
* @param {string} display_type - the current display type of the component either "list" or "change"
* @param {object} object - the current object or null
*/
display_will_change(display_type,object)
{
}
_object_link_clicked(object)
{
return (event)=>
{
this._display_will_change()
this.setState({display_type :display_type.change, object :object },this._display_changed);
event.preventDefault();
}
}
_display_field(object,item)
{
let label=_.at(object,item)['0']
if(_.has(this.get_field_transforms(),item))
{
return this.get_field_transforms()[item](label,object)
}
if(_.at(this.get_extra_fields(),item))
{
if(this.get_extra_fields()[item])
{
label=this.get_extra_fields()[item](object,item)
}
}
return label;
}
set_filter_options(filter_name,options)
{
let t={}
t[filter_name]= options.map((option)=>{ option._filter_ = filter_name; return option; });
this.setState({ filter_options : _.assign(this.state.filter_options,t)} )
}
get_filter_values()
{
return this.state.filter_values;
}
get_filters()
{
/*
return {
"by_id" : { "options" : [
{ value: 'one', label: 'One' },
{ value: 'two', label: 'Two' },
],
"filter_function" : (option,queryset)=>
{
return queryset;
}
}
}*/
}
_handle_filter_change(values)
{
if(!(values instanceof Array) && values!=null)
{
values=[values];
}
this.setState({ filter_values : values || [] });
if(values==null || values.length<=0)
{
this.set_queryset(this.get_queryset(this.state.page_number,this.list_per_page,this.state.queryset));
return
}
let filters=this.get_filters();
for(let value of values)
{
let queryset=filters[value._filter_].filter_function(value,this.state.queryset)
this.setState({queryset:queryset});
}
}
render_filters()
{
let options=[];
let filters=this.get_filters();
for(let filter of _.keys(filters))
{
options.push({label: _.startCase(filter),
value: filter,
disabled: true,
filter: null})
if(filters[filter].options.length >0)
{
for(let option of filters[filter].options)
{
option._filter_=filter;
options.push(option)
}
}
if(this.state.filter_options[filter])
{
for(let option of this.state.filter_options[filter])
{
option._filter_=filter;
options.push(option);
}
}
}
return <div style={{marginLeft:"10px",width:"100%"}}>
<Select
name="filter-form"
multi={true}
onChange={this._handle_filter_change.bind(this)}
closeOnSelect={true}
value ={this.state.filter_values}
removeSelected = {true}
placeholder = {"Select a filter"}
options={options}
/>
</div>
}
sort_by(sort_fields)
{
return this.state.queryset ? this.state.queryset : [];
}
_sort_handler(field)
{
return (event)=>
{
let index=_.findIndex(this.sort_fields,item=> item.hasOwnProperty(field));
if(index>=0)
{
if(this.sort_fields[index][field]=='asc')
{
this.sort_fields[index][field]='desc';
}
else
{
this.sort_fields=_.filter(this.sort_fields,item => !item.hasOwnProperty(field));
}
}
else
{
let temp={}
temp[field]='asc';
this.sort_fields.push(temp);
}
this.sort_by(this.sort_fields)
this.forceUpdate();
}
}
/**
* This method is an event handler that listens to when all objects of the queryset
* displayed within a single display page are selected
*/
_select_all(event)
{
if(this._all_selected)
{
this.setState({selected_objects : new Set([],this.is_object_equal)});
}
else
{
this.setState({selected_objects : new Set(this.state.queryset,this.is_object_equal)})
}
this._all_selected=!this._all_selected;
}
/**
* This method is an event handler that listens when a single objects of the queryset
* displayed is selected
*/
_select_one(object)
{
return (event)=>{
if(this.state.selected_objects.contains(object))
{
this.state.selected_objects.remove(object);
this.setState({selected_objects : this.state.selected_objects});
}
else
{
this.state.selected_objects.add(object);
this.setState({selected_objects : this.state.selected_objects});
}
}
}
/**
* Generate the table body for the list display page
*
* @return An array of table rows <tr/>
*/
_get_table_body()
{
return this._get_ordered_queryset().map((object,i)=>
{
return <tr key={"row-"+i}>
<td> <input type="checkbox" id={i+'_checkbox'} onChange={this._select_one(object)} checked={this.state.selected_objects.contains(object)}/> <label htmlFor={i+'_checkbox'}> </label> </td>
{this.get_list_display().map((item)=>
{
return <td key={item} > { this.get_list_display_links().find((a)=>{return item==a}) ? this._create_object_link(object, this._display_field(object,item)) : this._display_field(object,item)} </td>
})}
</tr>
})
}
/**
* Changes the state property "loading" to true. The state property can be used to show a
* progress indicator.
*/
show_progress()
{
this.setState({loading: true});
}
/**
* Changes the state property "loading" to false. This method can be used to hide a
* progress indicator by inspecting the "loading" property of the state object.
*/
hide_progress()
{
this.setState({loading: false});
}
/**
* The default progress indicator. Can be overriden.
* @return A progress indicator component
*/
render_progress(loading)
{
if(loading)
{
return <div className="fetch-progress" >
<progress>
</progress>
</div>
}
}
/**
* An event listener that listens to the search event. This method calls search method which
* implements a custom search. The method uses the "live_search" property to implement live
* search or not.
* @private
* @param {object} event The search onChange event
*/
_handle_search(event)
{
let term=event.target.value;
if(term)
{
let key= event.which || event.keyCode;
if(this.get_live_search() || key===13)
{
let queryset=this.search(term,this.state.queryset);
this.setState({queryset:queryset,total:queryset.length})
}
}
else
{
this.setState({queryset:this.get_queryset(this.state.page_number,this.list_per_page,this.state.queryset),total:this.get_queryset(this.state.page_number,this.list_per_page,this.state.queryset).length})
}
}
/**
* Renders the search component
*
* @return A search input field
*/
render_search_field()
{
return <div >
<input name="search" type="text" className="ra-search-field" placeholder="Search" onChange={this._handle_search} onKeyUp={this._handle_search}/>
</div>
}
/**
* Renders the add object button. Checks to see if permission is given by has_add_permission
*
* @return An add button component
*/
render_add_button()
{
if(this.has_add_permission())
{
return <button className={"ra-add-button"} onClick={this._object_link_clicked(null)} > Add {_.startCase(this.name)}</button>
}
}
/**
* Renders the table in the display page. This calls _get_table_header and _get_table_body
*
* @return An a table displaying the state queryset set objects
*/
render_table()
{
return <table className="table">
<thead>
<tr key="header">
<th>
<input type="checkbox" id="all_boxes" onChange={this._select_all} /> <label htmlFor="all_boxes"> </label>
</th>
{this._get_table_header()}
</tr>
</thead>
<tbody>
{this._get_table_body()}
</tbody>
</table>
}
/**
* An event listener that listens to actions selected.
*
*@param {object} event - the DOM on-change event
*/
action_selected(event)
{
let action=event.target.value;
console.log(this.state.selected_objects.getItems())
this.get_actions()[action](this.state.selected_objects.getItems());
this.get_queryset(this.state.page_number,this.list_per_page);
}
/**
* An event listener that listens when a page is selected.
*
*@param {number} page - the page number selected
*/
selectPage(page)
{
return (event)=>
{
this.setState({page_number: page.page},()=>{
this.setState({queryset: this.get_queryset(this.state.page_number,this.list_per_page,this.state.queryset)})
});
event.preventDefault();
}
}
/**
* An event listener that listens when the next page is selected.
*
*/
nextPage()
{
this.setState({page_number: Math.min(this.state.page_number+1,this.state.total)});
}
/**
* An event listener that listens when the previous page is selected.
*
*/
prevPage()
{
this.setState({page_number: Math.max(this.state.page_number-1,1)});
}
/**
* Renders the pagination UI
*
* @return A component that renders the pagination controls
*/
render_pagination()
{
let pages=[];
if(this.state.total)
{
let numpages=Math.ceil(this.state.total/this.list_per_page);
let pages_in_pagination= numpages < this.pages_in_pagination ? numpages : this.pages_in_pagination;
if(this.state.page_number ==1)
{
for(let i=0;i<pages_in_pagination;i++)
{
pages.push(i+1);
}
}
else if(this.state.page_number ==numpages)
{
for(let i=0;i<pages_in_pagination;i++)
{
pages.push(numpages-i);
}
}
else
{
for(let i=0;i<pages_in_pagination;i++)
{
pages.push(this.state.page_number+i);
}
}
/*
for(var i=0;i<numpages;i++)
{
pages.push(i+1);
}*/
}
return <div className="pull-right">
<span className="summary">{this.list_per_page*(this.state.page_number-1)+1 }-{Math.min(this.list_per_page*(this.state.page_number-1)+this.list_per_page,this.state.total)} of {this.state.total} </span>
<nav aria-label="Page navigation">
<ul className="pagination">
<li key={"left"} className="page-item">
<a href="#" aria-label="Previous" onClick={this.prevPage.bind(this)} className="page-link">
<span aria-hidden="true">«</span>
</a>
</li>
{pages.map((page)=>{
return <li key={page} className="page-item"><a href="#" className="page-link" onClick={this.selectPage({page})}>{page}</a></li>
})
}
<li className="page-item">
<a href="#" key={"right"} onClick={this.nextPage.bind(this)} aria-label="Next" className="page-link">
<span aria-hidden="true">»</span>
</a>
</li>
</ul>
</nav>
</div>
}
/**
* Renders the actions select component
*
* @return A component that renders a select input for all actions in the list display page
*/
render_actions()
{
return <select className="ra-action-button" onChange={this.action_selected.bind(this)} defaultValue="">
<option key="key" value="" disabled={true}>Choose an action</option>
{ _.keys(this.actions).map((action)=>{
return <option key={action} value={action}> {_.startCase(action)}</option>
})}
</select>
}
/**
* Renders the back button component in the add/change view
* @ignore
* @return A component that renders a back button
*/
render_back_button()
{
// return <div><button className={"ra-back-button"} onClick={()=>{this.setState({display_type : display_type.list,object: null });this.get_queryset(this.state.page_number,this.list_per_page,this.state.queryset); }}> Back </button></div>
return null;
}
/**
* Renders the admin interface component
*
* @return A component that renders the admin interface
*/
render_list_view()
{
return (
<div >
{this.render_add_button()}
{this.render_below_add_button()}
{this.render_search_field()}
{this.render_below_search_field()}
{this.render_actions()}
{this.render_below_actions()}
{this.render_filters()}
{this.render_below_filters()}
{this.render_table()}
{this.render_below_table()}
{this.render_progress(this.state.loading)}
{this.render_below_progress()}
{this.render_pagination()}
</div>
)
}
render_below_add_button(){
}
render_below_search_field()
{
}
render_below_actions()
{
}
render_below_filters()
{
}
render_below_table()
{
}
render_below_progress()
{
}
render_change_view()
{
return (<div>
{this.render_progress(this.state.loading)}
{this.get_form(this.state.object)}
</div>
)
}
render_above_change_view()
{
}
render_below_change_view()
{
}
render_above_list_view()
{
}
render_below_list_view()
{
}
render_list_page()
{
return <div>
{this.render_above_list_view()}
{this.render_list_view()}
{this.render_below_list_view()}
</div>
}
render_change_page()
{
return <div>
{this.render_above_change_view()}
{this.render_change_view()}
{this.render_below_change_view()}
</div>
}
render()
{
if(!this.has_module_permission())
{
return <div> <h1> Permission Denied </h1></div>
}
if(this.state.display_type == display_type.list)
{
return this.render_list_page();
}
else
{
//Important: the next two lines are for URL navigation and handling the browser back button
this._change_uuid=uuidv1();
history.pushState({}, "Change View", window.location.hash+"/change/"+this._change_uuid);
return this.render_change_page();
}
}
show_list_view()
{
this._display_will_change()
this.setState({display_type : display_type.list},this._display_changed)
}
register_listener()
{
this._hash_change_listener=(event)=> {
if(this && this.state.display_type!=display_type.list && !_.endsWith(window.location.hash,this._change_uuid))
{
this.show_list_view()
}
}
window.addEventListener("hashchange",this._hash_change_listener );
}
componentWillUnmount()
{
if(this._hash_change_listener){
window.removeEventListener("hashchange",this._hash_change_listener,true );}
}
}
export default Admin;