====== 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: * [[https://github.com/bcvsolutions/CzechIdMng/tree/develop/Realization/backend/example|backend]] * [[https://github.com/bcvsolutions/CzechIdMng/tree/develop/Realization/frontend/czechidm-example|frontend]] ===== 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 [[https://wiki.czechidm.com/7.3/dev/conventions/database-conventions|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 [[https://wiki.czechidm.com/7.3/dev/architecture/flyway|flyway scripts]] to update database schema. To persist the new entity to ''product_example'' table we add change script with [[https://wiki.czechidm.com/7.3/dev/architecture/flyway|name by convention]] to path ''/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 [[http://docs.spring.io/spring-data/jpa/docs/current/reference/html/|Spring Data]] as data access layer. So we create simple repository to our entity: /** * Example product repository */ public interface ExampleProductRepository extends AbstractEntityRepository { /** * 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 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 { @Autowired public TextExampleProductFilter(ExampleProductRepository repository) { super(repository); } @Override public String getName() { return ExampleProductFilter.PARAMETER_TEXT; } @Override public Predicate getPredicate(Root 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, CodeableService, AuthorizableService { } === Service - default implementation === /** * Default product service implementation */ @Service("exampleProductService") public class DefaultExampleProductService extends AbstractReadWriteDtoService 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 [[https://wiki.czechidm.com/7.3/dev/security/authorization|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 permissions; private ExampleGroupPermission(BasePermission... permissions) { this.permissions = Arrays.asList(permissions); } @Override public List 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 [[https://wiki.czechidm.com/7.3/dev/security/authorization|authorization policy]]: /** * Adds permissions to products for free. */ @Component @Description("Adds permissions to products for free..") public class FreeProductEvaluator extends AbstractAuthorizationEvaluator { @Override public Predicate getPredicate(Root 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 getPermissions(ExampleProduct authorizable, AuthorizationPolicy policy) { Set 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 { 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 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 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 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 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 parameters) { return new ExampleProductFilter(parameters); } } As you can see, security and [[https://wiki.czechidm.com/7.3/dev/architecture/swagger|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 (
{ this.renderPageHeader() }
); } }
==== 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 (
{ return Utils.Ui.getRowClass(data[rowIndex]); } } filterOpened={filterOpened} forceSearchParameters={ forceSearchParameters } showRowSelection={ Managers.SecurityManager.hasAuthority('EXAMPLEPRODUCT_DELETE') && showRowSelection } filter={ } actions={ [ { value: 'delete', niceLabel: this.i18n('action.delete.action'), action: this.onDelete.bind(this), disabled: false }, ] } buttons={ [ {' '} {this.i18n('button.add')} ] } _searchParameters={ this.getSearchParameters() } > { return ( ); } } sort={false}/>
); } } 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 (
{' '} { this.i18n('example:content.example-product.detail.edit.header', { name: manager.getNiceLabel(entity), escape: false }) } {this.props.children}
); } } 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 (
{ showLoading ? : }
); } } 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 (
{ this.i18n('button.back') } {this.i18n('button.saveAndClose') } {/* onEnter action - is needed because SplitButton is used instead standard submit button */}
); } } 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}} product detail" }, "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}}]." } } }