Entity - create a new CzechIdM entity and its management
This tutorial shows, how to add a new agenda for reading, creating, editing and deleting a new entity. Source code for this tutorial can be found in the example module:
Backend
We start from the persistence layer - add a new entity ExampleProduct
, which will persist data for our example. Entity and property names should fit conventions. Example product will have unique code (Codeable
), which can be used in REST endpoints as identifier alias - uuid or code can be used as example product identifier. Price can be null
(product is for free).
Entity
/** * Product: * - with unique code * - can be disabled (e.g. not available) * - price can be {@code null} - product is for free */ @Entity @Table(name = "example_product", indexes = { @Index(name = "ux_example_product_code", columnList = "code", unique = true), @Index(name = "idx_example_product_name", columnList = "name") }) public class ExampleProduct extends AbstractEntity implements Disableable, Codeable { private static final long serialVersionUID = 1L; @Audited @NotEmpty @Size(min = 0, max = DefaultFieldLengths.NAME) @Column(name = "code", length = DefaultFieldLengths.NAME, nullable = false) private String code; @Audited @NotEmpty @Size(min = 0, max = DefaultFieldLengths.NAME) @Column(name = "name", length = DefaultFieldLengths.NAME, nullable = false) private String name; @Audited @Size(max = DefaultFieldLengths.DESCRIPTION) @Column(name = "description", length = DefaultFieldLengths.DESCRIPTION) private String description; @Audited @Column(name = "double_value", nullable = true, precision = 38, scale = 4) private BigDecimal price; @Audited @NotNull @Column(name = "disabled", nullable = false) private boolean disabled; ... getters and setters }
Flyway script
We are using flyway scripts to update database schema. To persist the new entity to product_example
table we add change script with name by convention to path <module>/src/main/resources/eu/bcvsolutions/idm/example/sql/postgresql
:
CREATE TABLE example_product ( id bytea NOT NULL, created TIMESTAMP WITHOUT TIME zone NOT NULL, creator CHARACTER VARYING(255) NOT NULL, creator_id bytea, modified TIMESTAMP WITHOUT TIME zone, modifier CHARACTER VARYING(255), modifier_id bytea, original_creator CHARACTER VARYING(255), original_creator_id bytea, original_modifier CHARACTER VARYING(255), original_modifier_id bytea, realm_id bytea, transaction_id bytea, code CHARACTER VARYING(255) NOT NULL, description CHARACTER VARYING(2000), disabled BOOLEAN NOT NULL, name CHARACTER VARYING(255) NOT NULL, double_value NUMERIC(38,4), CONSTRAINT example_product_pkey PRIMARY KEY (id), CONSTRAINT ux_example_product_code UNIQUE (code) ); CREATE INDEX idx_example_product_name ON example_product USING btree (name); CREATE TABLE example_product_a ( id bytea NOT NULL, rev BIGINT NOT NULL, revtype SMALLINT, created TIMESTAMP WITHOUT TIME zone, created_m BOOLEAN, creator CHARACTER VARYING(255), creator_m BOOLEAN, creator_id bytea, creator_id_m BOOLEAN, modified TIMESTAMP WITHOUT TIME zone, modified_m BOOLEAN, modifier CHARACTER VARYING(255), modifier_m BOOLEAN, modifier_id bytea, modifier_id_m BOOLEAN, original_creator CHARACTER VARYING(255), original_creator_m BOOLEAN, original_creator_id bytea, original_creator_id_m BOOLEAN, original_modifier CHARACTER VARYING(255), original_modifier_m BOOLEAN, original_modifier_id bytea, original_modifier_id_m BOOLEAN, realm_id bytea, realm_id_m BOOLEAN, transaction_id bytea, transaction_id_m BOOLEAN, code CHARACTER VARYING(255), code_m BOOLEAN, description CHARACTER VARYING(2000), description_m BOOLEAN, disabled BOOLEAN, disabled_m BOOLEAN, name CHARACTER VARYING(255), name_m BOOLEAN, double_value NUMERIC(38,4), price_m BOOLEAN, CONSTRAINT example_product_a_pkey PRIMARY KEY (id, rev), CONSTRAINT fk_l8tlj6h1v48rav5nulvesvbg1 FOREIGN KEY (rev) REFERENCES idm_audit (id) MATCH SIMPLE ON UPDATE NO ACTION ON DELETE NO ACTION );
Repository
We are using Spring Data as data access layer. So we create simple repository to our entity:
/** * Example product repository */ public interface ExampleProductRepository extends AbstractEntityRepository<ExampleProduct> { /** * Returns product by unique code. * * @param code * @return */ ExampleProduct findOneByCode(String code); }
Filter
ExampleProductFilter
is used for finding products. The filter is used in the repository above:
/** * Filter for example products * * @author Radek Tomiška * */ public class ExampleProductFilter extends DataFilter { public ExampleProductFilter() { this(new LinkedMultiValueMap<>()); } public ExampleProductFilter(MultiValueMap<String, Object> data) { super(ExampleProductDto.class, data); } }
DataFilter
super class contains filtering by text
and id
(uuid) parameter. Filtering by id
parameter is implemented in core (UuidFilter
) for all abstract entities. We need to add the implementation for text
parameter, which is custom for every abstract entity:
/** * Example product filter - by text. */ @Component @Description("Example product filter - by text. Search as \"like\" in name, code and description - lower, case insensitive.") public class TextExampleProductFilter extends AbstractFilterBuilder<ExampleProduct, ExampleProductFilter> { @Autowired public TextExampleProductFilter(ExampleProductRepository repository) { super(repository); } @Override public String getName() { return ExampleProductFilter.PARAMETER_TEXT; } @Override public Predicate getPredicate(Root<ExampleProduct> root, CriteriaQuery<?> query, CriteriaBuilder builder, ExampleProductFilter filter) { if (StringUtils.isEmpty(filter.getText())) { return null; } return builder.or( builder.like(builder.lower(root.get(ExampleProduct_.name)), "%" + filter.getText().toLowerCase() + "%"), builder.like(builder.lower(root.get(ExampleProduct_.code)), "%" + filter.getText().toLowerCase() + "%"), builder.like(builder.lower(root.get(ExampleProduct_.description)), "%" + filter.getText().toLowerCase() + "%") ); } }
Dto
The Dto layer creates the bridge between the persistence and the REST layer. An entity is transformed to a dto in the service layer, so we prepare dto class for the example product:
/** * Example product */ @Relation(collectionRelation = "exampleProducts") @ApiModel(description = "Example product") public class ExampleProductDto extends AbstractDto implements Codeable, Disableable { private static final long serialVersionUID = 1L; @NotEmpty @Size(min = 0, max = DefaultFieldLengths.NAME) @ApiModelProperty(required = true, notes = "Unique example product's code. Could be used as identifier in rest endpoints.") private String code; @NotEmpty @Size(min = 0, max = DefaultFieldLengths.NAME) private String name; @Size(max = DefaultFieldLengths.DESCRIPTION) private String description; @ApiModelProperty(notes = "Price can be null - product is for free") private BigDecimal price; @ApiModelProperty(notes = "Disabled product is not available for ordering.") private boolean disabled; ... getters and setters }
Service
The service contains business logic, adds transactions etc. to work (CRUD) with an entity (~repository). Services can only be used in the REST layer (controllers). Using repositories directly in controllers is prohibited.
Service - interface
Every service should have an interface. We are using default interface-based JDK proxy approach, so creating an interface is needed for appropriate Spring autowiring (e.g. with @Transactional
usage).
/** * Example product service */ public interface ExampleProductService extends ReadWriteDtoService<ExampleProductDto, ExampleProductFilter>, CodeableService<ExampleProductDto>, AuthorizableService<ExampleProductDto> { }
Service - default implementation
/** * Default product service implementation */ @Service("exampleProductService") public class DefaultExampleProductService extends AbstractReadWriteDtoService<ExampleProductDto, ExampleProduct, ExampleProductFilter> implements ExampleProductService { private final ExampleProductRepository repository; @Autowired public DefaultExampleProductService(ExampleProductRepository repository) { super(repository); // this.repository = repository; } @Override public AuthorizableType getAuthorizableType() { return new AuthorizableType(ExampleGroupPermission.EXAMPLEPRODUCT, getEntityClass()); } @Override @Transactional(readOnly = true) public ExampleProductDto getByCode(String code) { return toDto(repository.findOneByCode(code)); } }
Security
ExampleGroupPermission
is used in the service implementation - we want to support authorization policies in our example agenda. So we create a new group:
/** * Aggregate base permission. Name can't contain character '_' - its used for joining to authority name. */ public enum ExampleGroupPermission implements GroupPermission { EXAMPLEPRODUCT( IdmBasePermission.ADMIN, IdmBasePermission.AUTOCOMPLETE, IdmBasePermission.READ, IdmBasePermission.CREATE, IdmBasePermission.UPDATE, IdmBasePermission.DELETE); // String constants could be used in pre / post authotize SpEl expressions public static final String EXAMPLE_PRODUCT_ADMIN = "EXAMPLEPRODUCT" + BasePermission.SEPARATOR + "ADMIN"; public static final String EXAMPLE_PRODUCT_AUTOCOMPLETE = "EXAMPLEPRODUCT" + BasePermission.SEPARATOR + "AUTOCOMPLETE"; public static final String EXAMPLE_PRODUCT_READ = "EXAMPLEPRODUCT" + BasePermission.SEPARATOR + "READ"; public static final String EXAMPLE_PRODUCT_CREATE = "EXAMPLEPRODUCT" + BasePermission.SEPARATOR + "CREATE"; public static final String EXAMPLE_PRODUCT_UPDATE = "EXAMPLEPRODUCT" + BasePermission.SEPARATOR + "UPDATE"; public static final String EXAMPLE_PRODUCT_DELETE = "EXAMPLEPRODUCT" + BasePermission.SEPARATOR + "DELETE"; private final List<BasePermission> permissions; private ExampleGroupPermission(BasePermission... permissions) { this.permissions = Arrays.asList(permissions); } @Override public List<BasePermission> getPermissions() { return permissions; } @Override public String getName() { return name(); } @Override public String getModule() { return ExampleModuleDescriptor.MODULE_ID; } }
This group will be used in the REST layer to secure our endpoint.
Authorization evaluator
Example product can have empty price
(product for free) - this product will be available for logged identity by custom authorization policy:
/** * Adds permissions to products for free. */ @Component @Description("Adds permissions to products for free..") public class FreeProductEvaluator extends AbstractAuthorizationEvaluator<ExampleProduct> { @Override public Predicate getPredicate(Root<ExampleProduct> root, CriteriaQuery<?> query, CriteriaBuilder builder, AuthorizationPolicy policy, BasePermission... permission) { return builder.or( builder.isNull(root.get(ExampleProduct_.price)), builder.equal(root.get(ExampleProduct_.price), BigDecimal.ZERO) ); } @Override public Set<String> getPermissions(ExampleProduct authorizable, AuthorizationPolicy policy) { Set<String> permissions = super.getPermissions(authorizable, policy); if (authorizable.getPrice() == null || BigDecimal.ZERO.equals(authorizable.getPrice())) { permissions.addAll(policy.getPermissions()); } return permissions; } }
Rest controller
At last, we can expose a new REST endpoint (controller) with our example product.
/** * RESTful example product endpoint */ @RestController @Enabled(ExampleModuleDescriptor.MODULE_ID) @RequestMapping(value = BaseController.BASE_PATH + "/example-products") @Api( value = ExampleProductController.TAG, description = "Example products", tags = { ExampleProductController.TAG }) public class ExampleProductController extends DefaultReadWriteDtoController<ExampleProductDto, ExampleProductFilter> { protected static final String TAG = "Example products"; @Autowired public ExampleProductController(ExampleProductService service) { super(service); } @Override @ResponseBody @RequestMapping(method = RequestMethod.GET) @PreAuthorize("hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_READ + "')") @ApiOperation( value = "Search example products (/search/quick alias)", nickname = "searchExampleProducts", tags = { ExampleProductController.TAG }, authorizations = { @Authorization(value = SwaggerConfig.AUTHENTICATION_BASIC, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_READ, description = "") }), @Authorization(value = SwaggerConfig.AUTHENTICATION_CIDMST, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_READ, description = "") }) }) public Resources<?> find( @RequestParam(required = false) MultiValueMap<String, Object> parameters, @PageableDefault Pageable pageable) { return super.find(parameters, pageable); } @Override @ResponseBody @RequestMapping(value = "/search/quick", method = RequestMethod.GET) @PreAuthorize("hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_READ + "')") @ApiOperation( value = "Search example products", nickname = "searchQuickExampleProducts", tags = { ExampleProductController.TAG }, authorizations = { @Authorization(value = SwaggerConfig.AUTHENTICATION_BASIC, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_READ, description = "") }), @Authorization(value = SwaggerConfig.AUTHENTICATION_CIDMST, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_READ, description = "") }) }) public Resources<?> findQuick( @RequestParam(required = false) MultiValueMap<String, Object> parameters, @PageableDefault Pageable pageable) { return super.findQuick(parameters, pageable); } @Override @ResponseBody @RequestMapping(value = "/search/autocomplete", method = RequestMethod.GET) @PreAuthorize("hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_AUTOCOMPLETE + "')") @ApiOperation( value = "Autocomplete example products (selectbox usage)", nickname = "autocompleteExampleProducts", tags = { ExampleProductController.TAG }, authorizations = { @Authorization(value = SwaggerConfig.AUTHENTICATION_BASIC, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_AUTOCOMPLETE, description = "") }), @Authorization(value = SwaggerConfig.AUTHENTICATION_CIDMST, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_AUTOCOMPLETE, description = "") }) }) public Resources<?> autocomplete( @RequestParam(required = false) MultiValueMap<String, Object> parameters, @PageableDefault Pageable pageable) { return super.autocomplete(parameters, pageable); } @Override @ResponseBody @RequestMapping(value = "/{backendId}", method = RequestMethod.GET) @PreAuthorize("hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_READ + "')") @ApiOperation( value = "Example product detail", nickname = "getExampleProduct", response = ExampleProductDto.class, tags = { ExampleProductController.TAG }, authorizations = { @Authorization(value = SwaggerConfig.AUTHENTICATION_BASIC, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_READ, description = "") }), @Authorization(value = SwaggerConfig.AUTHENTICATION_CIDMST, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_READ, description = "") }) }) public ResponseEntity<?> get( @ApiParam(value = "Example product's uuid identifier or code.", required = true) @PathVariable @NotNull String backendId) { return super.get(backendId); } @Override @ResponseBody @RequestMapping(method = RequestMethod.POST) @PreAuthorize("hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_CREATE + "') or hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_UPDATE + "')") @ApiOperation( value = "Create / update example product", nickname = "postExampleProduct", response = ExampleProductDto.class, tags = { ExampleProductController.TAG }, authorizations = { @Authorization(value = SwaggerConfig.AUTHENTICATION_BASIC, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_CREATE, description = ""), @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_UPDATE, description = "")}), @Authorization(value = SwaggerConfig.AUTHENTICATION_CIDMST, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_CREATE, description = ""), @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_UPDATE, description = "")}) }) public ResponseEntity<?> post(@Valid @RequestBody ExampleProductDto dto) { return super.post(dto); } @Override @ResponseBody @RequestMapping(value = "/{backendId}", method = RequestMethod.PUT) @PreAuthorize("hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_UPDATE + "')") @ApiOperation( value = "Update example product", nickname = "putExampleProduct", response = ExampleProductDto.class, tags = { ExampleProductController.TAG }, authorizations = { @Authorization(value = SwaggerConfig.AUTHENTICATION_BASIC, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_UPDATE, description = "") }), @Authorization(value = SwaggerConfig.AUTHENTICATION_CIDMST, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_UPDATE, description = "") }) }) public ResponseEntity<?> put( @ApiParam(value = "Example product's uuid identifier or code.", required = true) @PathVariable @NotNull String backendId, @Valid @RequestBody ExampleProductDto dto) { return super.put(backendId, dto); } @Override @ResponseBody @RequestMapping(value = "/{backendId}", method = RequestMethod.DELETE) @PreAuthorize("hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_DELETE + "')") @ApiOperation( value = "Delete example product", nickname = "deleteExampleProduct", tags = { ExampleProductController.TAG }, authorizations = { @Authorization(value = SwaggerConfig.AUTHENTICATION_BASIC, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_DELETE, description = "") }), @Authorization(value = SwaggerConfig.AUTHENTICATION_CIDMST, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_DELETE, description = "") }) }) public ResponseEntity<?> delete( @ApiParam(value = "Example product's uuid identifier or code.", required = true) @PathVariable @NotNull String backendId) { return super.delete(backendId); } @Override @ResponseBody @RequestMapping(value = "/{backendId}/permissions", method = RequestMethod.GET) @PreAuthorize("hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_READ + "')" + " or hasAuthority('" + ExampleGroupPermission.EXAMPLE_PRODUCT_AUTOCOMPLETE + "')") @ApiOperation( value = "What logged identity can do with given record", nickname = "getPermissionsOnExampleProduct", tags = { ExampleProductController.TAG }, authorizations = { @Authorization(value = SwaggerConfig.AUTHENTICATION_BASIC, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_READ, description = ""), @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_AUTOCOMPLETE, description = "")}), @Authorization(value = SwaggerConfig.AUTHENTICATION_CIDMST, scopes = { @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_READ, description = ""), @AuthorizationScope(scope = ExampleGroupPermission.EXAMPLE_PRODUCT_AUTOCOMPLETE, description = "")}) }) public Set<String> getPermissions( @ApiParam(value = "Example product's uuid identifier or code.", required = true) @PathVariable @NotNull String backendId) { return super.getPermissions(backendId); } @Override protected ExampleProductFilter toFilter(MultiValueMap<String, Object> parameters) { return new ExampleProductFilter(parameters); } }
As you can see, security and swagger annotations are used in the REST controller.
Frontend
Now we have all parts on the backend prepared and we can move to the frontend. The first thing what we need to do is to create a service, which will consume the REST endpoint on the backend.
Service
import { Services } from 'czechidm-core'; import { Domain } from 'czechidm-core'; /** * Example products */ export default class ExampleProductService extends Services.AbstractService { getApiPath() { return '/example-products'; } getNiceLabel(entity) { if (!entity) { return ''; } return `${entity.name} (${entity.code})`; } /** * Agenda supports authorization policies */ supportsAuthorization() { return true; } /** * Group permission - all base permissions (`READ`, `WRITE`, ...) will be evaluated under this group */ getGroupPermission() { return 'EXAMPLEPRODUCT'; } /** * Almost all dtos doesn§t support rest `patch` method */ supportsPatch() { return false; } /** * Returns default searchParameters for current entity type * * @return {object} searchParameters */ getDefaultSearchParameters() { return super.getDefaultSearchParameters().setName(Domain.SearchParameters.NAME_QUICK).clearSort().setSort('name'); } }
We can register services and managers in index.js
files placed in folders, where services and managers are defined - it's simpler to import services from one file:
... import { ExampleProductService } from '../services'; ...
where the relative path leads to the module service's index.js
:
import ExampleProductService from './ExampleProductService'; const ServiceRoot = { ExampleProductService }; ServiceRoot.version = '0.1.0'; module.exports = ServiceRoot;
Manager
The service can be used in the manager:
import { Managers } from 'czechidm-core'; import { ExampleProductService } from '../services'; /** * Example product manager * * @author Radek Tomiška */ export default class ExampleProductManager extends Managers.EntityManager { constructor() { super(); this.service = new ExampleProductService(); } getModule() { return 'example'; } getService() { return this.service; } /** * Controlled entity */ getEntityType() { return 'ExampleProduct'; } /** * Collection name in search / find response */ getCollectionType() { return 'exampleProducts'; } }
Route and navigation
We define new routes in routes.js
descriptor:
module.exports = { module: 'example', childRoutes: [ ... { path: '/example/products', component: require('./src/content/example-product/ExampleProducts'), access: [ { 'type': 'HAS_ANY_AUTHORITY', 'authorities': ['EXAMPLEPRODUCT_READ'] } ] }, { path: 'example/product/:entityId/', component: require('./src/content/example-product/ExampleProductRoute'), access: [ { 'type': 'HAS_ANY_AUTHORITY', 'authorities': ['EXAMPLEPRODUCT_READ'] } ], childRoutes: [ { path: 'detail', component: require('./src/content/example-product/ExampleProductContent'), access: [ { 'type': 'HAS_ANY_AUTHORITY', 'authorities': ['EXAMPLEPRODUCT_READ'] } ] } ] }, { path: 'example/product/:entityId/new', component: require('./src/content/example-product/ExampleProductContent'), access: [ { 'type': 'HAS_ANY_AUTHORITY', 'authorities': ['EXAMPLEPRODUCT_CREATE'] } ] } ] };
and create new navigation items in module-descriptor.js
:
module.exports = { 'id': 'example', 'npmName': 'czechidm-example', 'backendId': 'example', 'name': 'Example module for CzechIdM devstack.', 'description': 'Example module for CzechIdM devstack. This module can be duplicated and renamed for create new optional CzechIdM module.', 'mainRouteFile': 'routes.js', 'mainComponentDescriptorFile': 'component-descriptor.js', 'mainLocalePath': 'src/locales/', 'navigation': { 'items': [ { 'id': 'example-main-menu', 'labelKey': 'example:content.examples.label', 'titleKey': 'example:content.examples.title', 'icon': 'gift', 'iconColor': '#FF8A80', 'order': 9, 'items': [ { 'id': 'example-content', 'type': 'DYNAMIC', 'section': 'main', 'labelKey': 'example:content.example.label', 'titleKey': 'example:content.example.title', 'order': 10, 'path': '/example/content', 'priority': 0 }, { 'id': 'example-products', 'type': 'DYNAMIC', 'section': 'main', 'icon': 'gift', 'labelKey': 'example:content.example-products.label', 'titleKey': 'example:content.example-products.title', 'order': 20, 'path': '/example/products', 'access': [ { 'type': 'HAS_ANY_AUTHORITY', 'authorities': ['EXAMPLEPRODUCT_READ'] } ], 'items': [ { 'id': 'example-product-detail', 'type': 'TAB', 'labelKey': 'example:content.example-product.detail.basic', 'order': 10, 'path': '/example/product/:entityId/detail', 'icon': 'gift', 'access': [ { 'type': 'HAS_ANY_AUTHORITY', 'authorities': ['EXAMPLEPRODUCT_READ'] } ] }, ] } ] } ... ] } };
Contents
We create new contents on paths as added routes above says:
ExampleProducts
The route for the table of products:
import React from 'react'; // import { Basic } from 'czechidm-core'; import ExampleProductTable from './ExampleProductTable'; /** * List of example products */ export default class ExampleProducts extends Basic.AbstractContent { constructor(props, context) { super(props, context); } /** * "Shorcut" for localization */ getContentKey() { return 'example:content.example-products'; } /** * Selected navigation item */ getNavigationKey() { return 'example-products'; } render() { return ( <div> { this.renderPageHeader() } <Basic.Alert text={ this.i18n('info') }/> <Basic.Panel> <ExampleProductTable uiKey="example-product-table" filterOpened /> </Basic.Panel> </div> ); } }
ExampleProductTable
The table of products:
import React, { PropTypes } from 'react'; import { connect } from 'react-redux'; import _ from 'lodash'; import uuid from 'uuid'; // import { Basic, Advanced, Utils, Managers, Domain } from 'czechidm-core'; import { ExampleProductManager } from '../../redux'; const manager = new ExampleProductManager(); /** * Table of example products */ export class ExampleProductTable extends Advanced.AbstractTableContent { constructor(props, context) { super(props, context); this.state = { filterOpened: this.props.filterOpened, }; } /** * "Shorcut" for localization */ getContentKey() { return 'example:content.example-products'; } /** * Base manager for this agenda (used in `AbstractTableContent`) */ getManager() { return manager; } /** * Submit filter action */ useFilter(event) { if (event) { event.preventDefault(); } this.refs.table.getWrappedInstance().useFilterForm(this.refs.filterForm); } /** * Cancel filter action */ cancelFilter(event) { if (event) { event.preventDefault(); } this.refs.table.getWrappedInstance().cancelFilter(this.refs.filterForm); } /** * Link to detail / create */ showDetail(entity) { if (Utils.Entity.isNew(entity)) { const uuidId = uuid.v1(); this.context.router.push(`/example/product/${uuidId}/new?new=1`); } else { this.context.router.push(`/example/product/${entity.id}/detail`); } } render() { const { uiKey, columns, forceSearchParameters, showAddButton, showRowSelection } = this.props; const { filterOpened } = this.state; return ( <div> <Basic.Confirm ref="confirm-delete" level="danger"/> <Advanced.Table ref="table" uiKey={ uiKey } manager={ manager } rowClass={ ({rowIndex, data}) => { return Utils.Ui.getRowClass(data[rowIndex]); } } filterOpened={filterOpened} forceSearchParameters={ forceSearchParameters } showRowSelection={ Managers.SecurityManager.hasAuthority('EXAMPLEPRODUCT_DELETE') && showRowSelection } filter={ <Advanced.Filter onSubmit={this.useFilter.bind(this)}> <Basic.AbstractForm ref="filterForm"> <Basic.Row className="last"> <Basic.Col lg={ 4 }> <Advanced.Filter.TextField ref="text" placeholder={this.i18n('filter.text.placeholder')}/> </Basic.Col> <Basic.Col lg={ 4 }> </Basic.Col> <Basic.Col lg={ 4 } className="text-right"> <Advanced.Filter.FilterButtons cancelFilter={this.cancelFilter.bind(this)}/> </Basic.Col> </Basic.Row> </Basic.AbstractForm> </Advanced.Filter> } actions={ [ { value: 'delete', niceLabel: this.i18n('action.delete.action'), action: this.onDelete.bind(this), disabled: false }, ] } buttons={ [ <Basic.Button level="success" key="add_button" className="btn-xs" onClick={this.showDetail.bind(this, { })} rendered={Managers.SecurityManager.hasAuthority('EXAMPLEPRODUCT_CREATE') && showAddButton}> <Basic.Icon type="fa" icon="plus"/> {' '} {this.i18n('button.add')} </Basic.Button> ] } _searchParameters={ this.getSearchParameters() } > <Advanced.Column header="" className="detail-button" cell={ ({ rowIndex, data }) => { return ( <Advanced.DetailButton title={this.i18n('button.detail')} onClick={this.showDetail.bind(this, data[rowIndex])}/> ); } } sort={false}/> <Advanced.ColumnLink to="example/product/:id/detail" property="code" width={ 100 } sort face="text" rendered={_.includes(columns, 'code')}/> <Advanced.Column property="name" width="15%" sort face="text" rendered={_.includes(columns, 'name')}/> <Advanced.Column property="description" sort face="text" rendered={_.includes(columns, 'description')}/> <Advanced.Column property="price" width={ 125 } sort rendered={_.includes(columns, 'price')}/> <Advanced.Column property="disabled" width={ 100 } sort face="bool" rendered={_.includes(columns, 'disabled')}/> </Advanced.Table> </div> ); } } ExampleProductTable.propTypes = { /** * Entities, permissions etc. fro this content are stored in redux under given key */ uiKey: PropTypes.string.isRequired, /** * Rendered columns (all by default) */ columns: PropTypes.arrayOf(PropTypes.string), /** * Show filter or collapse */ filterOpened: PropTypes.bool, /** * "Hard filters" */ forceSearchParameters: PropTypes.object, /** * Show add button to create new product */ showAddButton: PropTypes.bool, /** * Show row selection for bulk actions */ showRowSelection: PropTypes.bool }; ExampleProductTable.defaultProps = { columns: ['code', 'name', 'description', 'price', 'disabled'], filterOpened: false, forceSearchParameters: new Domain.SearchParameters(), showAddButton: true, showRowSelection: true }; function select(state, component) { return { _searchParameters: Utils.Ui.getSearchParameters(state, component.uiKey), // persisted filter state in redux }; } export default connect(select, null, null, { withRef: true })(ExampleProductTable);
ExampleProductRoute
Route for product detail with tabs:
import React, { PropTypes } from 'react'; import { connect } from 'react-redux'; // import { Basic, Advanced } from 'czechidm-core'; import { ExampleProductManager } from '../../redux'; const manager = new ExampleProductManager(); /** * ExampleProduct detail with tabs */ class ExampleProductRoute extends Basic.AbstractContent { componentDidMount() { const { entityId } = this.props.params; // load entity from BE - for nice labels etc. this.context.store.dispatch(manager.fetchEntityIfNeeded(entityId)); } render() { const { entity, showLoading } = this.props; return ( <div> <Basic.PageHeader showLoading={!entity && showLoading}> <Basic.Icon value="link"/> {' '} { this.i18n('example:content.example-product.detail.edit.header', { name: manager.getNiceLabel(entity), escape: false }) } </Basic.PageHeader> <Advanced.TabPanel parentId="example-products" params={ this.props.params }> {this.props.children} </Advanced.TabPanel> </div> ); } } ExampleProductRoute.propTypes = { entity: PropTypes.object, showLoading: PropTypes.bool }; ExampleProductRoute.defaultProps = { entity: null, showLoading: false }; function select(state, component) { const { entityId } = component.params; return { entity: manager.getEntity(state, entityId), showLoading: manager.isShowLoading(state, null, entityId) }; } export default connect(select)(ExampleProductRoute);
ExampleProductContent
Product detail with form. It's a simple reusable form wrapper - loads entity info form:
import React, { PropTypes } from 'react'; import { connect } from 'react-redux'; // import { Basic } from 'czechidm-core'; import { ExampleProductManager } from '../../redux'; import ExampleProductDetail from './ExampleProductDetail'; const manager = new ExampleProductManager(); /** * Example product detait wrapper */ class ExampleProductContent extends Basic.AbstractContent { constructor(props, context) { super(props, context); } /** * "Shorcut" for localization */ getContentKey() { return 'acc:content.system.detail'; } /** * Selected navigation item */ getNavigationKey() { return 'example-product-detail'; } componentDidMount() { super.componentDidMount(); // const { entityId } = this.props.params; if (this._isNew()) { // persist new entity to redux this.context.store.dispatch(manager.receiveEntity(entityId, { })); } else { // load entity from BE - we need load actual entity and set her to the form this.getLogger().debug(`[ExampleProductContent] loading entity detail [id:${entityId}]`); this.context.store.dispatch(manager.fetchEntity(entityId)); } } /** * Helper - returns `true`, when new entity is created */ _isNew() { const { query } = this.props.location; return (query) ? query.new : null; } render() { const { entity, showLoading } = this.props; return ( <Basic.Row> <div className={this._isNew() ? 'col-lg-offset-1 col-lg-10' : 'col-lg-12'}> { showLoading ? <Basic.Loading isStatic showLoading /> : <ExampleProductDetail uiKey="example-product-detail" entity={entity} /> } </div> </Basic.Row> ); } } ExampleProductContent.propTypes = { /** * Loaded entity */ entity: PropTypes.object, /** * Entity is currently loaded from BE */ showLoading: PropTypes.bool }; ExampleProductContent.defaultProps = { }; function select(state, component) { const { entityId } = component.params; // return { entity: manager.getEntity(state, entityId), showLoading: manager.isShowLoading(state, null, entityId) }; } export default connect(select)(ExampleProductContent);
ExampleProductDetail
Product form:
import React, { PropTypes } from 'react'; import Helmet from 'react-helmet'; import { connect } from 'react-redux'; import Joi from 'joi'; // import { Basic, Utils } from 'czechidm-core'; import { ExampleProductManager } from '../../redux'; const manager = new ExampleProductManager(); /** * Example product form */ class ExampleProductDetail extends Basic.AbstractContent { constructor(props, context) { super(props, context); // this.state = { showLoading: false }; } /** * "Shorcut" for localization */ getContentKey() { return 'example:content.example-product.detail'; } componentDidMount() { const { entity } = this.props; // set loaded entity to form this.refs.form.setData(entity); this.refs.code.focus(); } /** * Component will receive new props, try to compare with actual, * then init form */ componentWillReceiveProps(nextProps) { const { entity } = this.props; if (entity && entity.id !== nextProps.entity.id) { this.refs.form.setData(nextProps.entity); } } /** * Save entity to BE */ save(afterAction, event) { if (event) { event.preventDefault(); } // get entity from form const entity = this.refs.form.getData(); // check form validity if (!this.refs.form.isFormValid()) { return; } // ui redux store identifier const { uiKey } = this.props; // this.setState({ showLoading: true }, () => { const saveEntity = { ...entity, }; if (Utils.Entity.isNew(saveEntity)) { this.context.store.dispatch(manager.createEntity(saveEntity, `${uiKey}-detail`, (createdEntity, newError) => { this._afterSave(createdEntity, newError, afterAction); })); } else { this.context.store.dispatch(manager.updateEntity(saveEntity, `${uiKey}-detail`, (patchedEntity, newError) => { this._afterSave(patchedEntity, newError, afterAction); })); } }); } /** * `Callback` after save action ends */ _afterSave(entity, error, afterAction = 'CLOSE') { this.setState({ showLoading: false }, () => { if (error) { this.addError(error); return; } this.addMessage({ message: this.i18n('save.success', { name: entity.name }) }); // if (afterAction === 'CLOSE') { // reload options with remote connectors this.context.router.replace(`example/products`); } else { this.context.router.replace(`example/product/${entity.id}/detail`); } }); } render() { const { uiKey, entity, _permissions } = this.props; const { showLoading } = this.state; // return ( <div> <Helmet title={ Utils.Entity.isNew(entity) ? this.i18n('create.header') : this.i18n('edit.title') } /> <form onSubmit={this.save.bind(this, 'CONTINUE')}> <Basic.Panel className={Utils.Entity.isNew(entity) ? '' : 'no-border last'}> <Basic.PanelHeader text={ Utils.Entity.isNew(entity) ? this.i18n('create.header') : this.i18n('basic') } /> <Basic.PanelBody style={ Utils.Entity.isNew(entity) ? { paddingTop: 0, paddingBottom: 0 } : { padding: 0 } } showLoading={ showLoading } > <Basic.AbstractForm ref="form" uiKey={ uiKey } readOnly={ !manager.canSave(entity, _permissions) } > <Basic.Row> <Basic.Col lg={ 2 }> <Basic.TextField ref="code" label={ this.i18n('example:entity.ExampleProduct.code.label') } required max={ 255 }/> </Basic.Col> <Basic.Col lg={ 10 }> <Basic.TextField ref="name" label={this.i18n('example:entity.ExampleProduct.name.label')} required max={ 255 }/> </Basic.Col> </Basic.Row> <Basic.TextField ref="price" label={ this.i18n('example:entity.ExampleProduct.price.label') } placeholder={ this.i18n('example:entity.ExampleProduct.price.placeholder') } helpBlock={ this.i18n('example:entity.ExampleProduct.price.help') } validation={ Joi.number().min(-Math.pow(10, 33)).max(Math.pow(10, 33)).concat(Joi.number().allow(null)) }/> <Basic.TextArea ref="description" label={ this.i18n('example:entity.ExampleProduct.description.label') } max={ 2000 }/> <Basic.Checkbox ref="disabled" label={ this.i18n('example:entity.ExampleProduct.disabled.label') } helpBlock={ this.i18n('example:entity.ExampleProduct.disabled.help') }/> </Basic.AbstractForm> </Basic.PanelBody> <Basic.PanelFooter> <Basic.Button type="button" level="link" onClick={ this.context.router.goBack }>{ this.i18n('button.back') }</Basic.Button> <Basic.SplitButton level="success" title={ this.i18n('button.saveAndContinue') } onClick={ this.save.bind(this, 'CONTINUE') } showLoading={ showLoading } showLoadingIcon showLoadingText={ this.i18n('button.saving') } rendered={ manager.canSave(entity, _permissions) } pullRight dropup> <Basic.MenuItem eventKey="1" onClick={ this.save.bind(this, 'CLOSE')}>{this.i18n('button.saveAndClose') }</Basic.MenuItem> </Basic.SplitButton> </Basic.PanelFooter> </Basic.Panel> {/* onEnter action - is needed because SplitButton is used instead standard submit button */} <input type="submit" className="hidden"/> </form> </div> ); } } ExampleProductDetail.propTypes = { /** * Loaded entity */ entity: PropTypes.object, /** * Entity, permissions etc. fro this content are stored in redux under given key */ uiKey: PropTypes.string.isRequired, /** * Logged identity permissions - what can do with currently loaded entity */ _permissions: PropTypes.arrayOf(PropTypes.string) }; ExampleProductDetail.defaultProps = { _permissions: null }; function select(state, component) { if (!component.entity) { return {}; } return { _permissions: manager.getPermissions(state, null, component.entity.id) }; } export default connect(select)(ExampleProductDetail);
Localization
Localization keys are used in contents and components. We add localization:
{ "module": { "name": "Example module", "author": "BCV solutions s.r.o." }, "entity": { "ExampleProduct": { "_type": "Example produkt", "code": { "label": "Code" }, "name": { "label": "Name" }, "description": { "label": "Description" }, "price": { "label": "Price", "placeholder": "Product's price", "help": "Price can be empty - produkt for free." }, "disabled": { "label": "Disabled", "help": "Product is not available." } } }, "permission": { "group": { "EXAMPLEPRODUCT": "Ukázkové produkty" } }, "content": { "identity":{ "example": "Example tab" }, "dashboard": { "exampleDashboard": { "header": "Dashboard example", "title": "Dashbord from example module.", "text": "Example dashboard, added by czechidm-example module." } }, "examples": { "header": "Examples", "label": "Examples", "title": "Examples" }, "example": { "header": "Example content", "label": "Example content", "title": "Example content available from navigation, added by czechidm-example module.", "text": "New example content.", "error": { "header": "Localization for error codes", "parameter": { "label": "Error parameter value" }, "button": { "client": "Client error (validation)", "server": "Server error" } } }, "example-products": { "header": "Products", "label": "Products", "title": "Products", "info": "Example agenda with basic operations with products.", "filter": { "text": { "placeholder": "Code, name or description" } } }, "example-product": { "detail" : { "basic": "Basic information", "header": "Product", "title": "$t(example:content.example-product.detail.header)", "edit": { "title": "Produkt detail", "header": "{{name}} <small>product detail</small>" }, "create": { "header": "New product" }, "save": { "success": "Product [{{name}}] successfully saved" } } } }, "error": { "EXAMPLE_SERVER_ERROR": { "title": "Example server error", "message": "Example server with parameter [{{parameter}}]." }, "EXAMPLE_CLIENT_ERROR": { "title": "Example client error", "message": "Example client error, bad value given [{{parameter}}]." } } }