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:

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).

/**
 * 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
}

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
);

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);
 
}

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() + "%")					
				);
	}
}

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
}

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));
	}
 
}

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;
	}
}

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.

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.

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;

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';
  }
}

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'] } ]
              },
            ]
          }
        ]
      }
      ...
    ]
  }
};

We create new contents on paths as added routes above says:

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>
    );
  }
}

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);

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);

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);

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 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}}]."
    }
  }
}
  • by tomiskar