Adding New Page
This tutorial below explains backend and frontend development of adding a new page/process step by step.
UI / Frontend​
You'll generally be dealing with 4 files in src
folder for a standard page which you can manage easily with JSON objects without coding javascript or typescript.
1) Menu Item Component​
views/Routes/YourPageName/YourPageNamePageConfig.js
It includes a generic component to be used in pages.js
for menu. You'll just need to import related SamplePageConfig.js
import React from 'react';
import BasePage from '../../../components/BasePage';
import PageConfig from './SamplePageConfig'; // Edit this line
export default class SampleUserCls extends React.PureComponent {
render() {
return (
<BasePage config={PageConfig} />
)
}
}
2) Page Config​
views/Routes/YourPageName/YourPageNamePageConfig.tsx
Page structure specifying title, tabs, resource code, CRUD service urls, buttons and so on.
import { IPageConfig, Methods, pageConfigWrapper, Tables } from '../../../common/typeConfig';
import { getLocalizedText } from '../../../common/localizationManager';
import Constants from '../../../common/constants';
import SamplePage from '../../../entities/SamplePage';
const { LIST, GET, INSERT, UPDATE, DELETE, EXPORT, IMPORT } = Methods;
let pageConfig: IPageConfig = {
headerTitle: getLocalizedText('SEARCH_LIST_TITLE'),
tabs: [
{
title: 'User Title', // Tab title
type: SamplePage,
resourceCode: 'SamplePage_Resource_Code',
editOnModal: false, // Behaviour when clicked on a row of the table
list: {
url: `${Constants.ApiURL}/sampleService/list`
},
get: {
url: `${Constants.ApiURL}/sampleService/getById`
},
insert: {
url: `${Constants.ApiURL}/sampleService/insert`
},
update: {
url: `${Constants.ApiURL}/sampleService/update`
},
delete: {
url: `${Constants.ApiURL}/sampleService/delete`
},
allowedMethods: // Allowed buttons/actions of the table
[
LIST,
GET,
INSERT,
UPDATE,
DELETE,
IMPORT,
EXPORT,
DUPLICATE
],
},
],
};
export default pageConfigWrapper(pageConfig);
3) Model Class​
entities/YourPageNamePage.ts
Simply consists of the model class as JSON specifying components, groupings, rules, validations and so on.
import TableFormatters from '../common/TableFormatters';
import { Regexes } from '../common/validations';
import { IType, ComponentType, Visibility, typeConfigWrapper, IGroup, CaseStyles, LabelPositions } from '../common/typeConfig';
import { ParameterOptionTemplate } from '../common/typePresets';
import { getLocalizedText } from '../common/localizationManager';
const { TABLE, FORM, FILTER, BG_FILTER, BG_FORM, FK_FILTER, FK_FORM } = Visibility;
// We have 3 groups to gather components in order to have a better look & feel
let group1: IGroup = { title: 'User Info', columnSize: { all: 4 } };
let group2: IGroup = { title: 'Job Info', columnSize: { all: 4 } };
let group3: IGroup = { title: 'Profile Picture', columnSize: { all: 4 } };
let type: IType = {
userId: {
label: getLocalizedText('userId'),
isPrimaryId: true, // Almost every page should have one primary key
typeKey: 'user',
onValueChanged: (value, type, initial, isFilter, that) => {
type.password.valRules.acceptEmptyStrings = value > 0; //
type.password.updateState(); // Refresh the component state
},
},
infoMessage: {
typeInd: ComponentType.LABEL,
labelPosition: LabelPositions.NONE,
visibility: [FORM],
columnSize: { all: 12 },
defaultValue: "This page is specially designed for Live Preview with many different examples"
},
userName: {
label: "Name", // static label without localization
typeInd: ComponentType.FORM_CONTROL,
forceCaseTo: CaseStyles.UPPER_CASE, // Make the letters uppercase as typed
visibility: [FORM, FILTER, TABLE],
group: group1, // dislay in group1
valRules: {
minLength: 2, // must be at least 2 char-long
maxLength: 30 // must be at most 30 char-long
}
},
userSurname: { // if typeInd not specified, it is FORM_CONTROL (text-input) by default
label: "Surname",
forceCaseTo: CaseStyles.UPPER_CASE,
visibility: [FORM, FILTER, TABLE], // display in FORM=insert/update, FILTER=search criteria, TABLE=grid
group: group1,
valRules: {
minLength: 2,
maxLength: 50
}
},
companyId: {
label: getLocalizedText('COMPANY_LABEL'), // Multi-language label. COMPANY_LABEL is a keyCode in coreParameters
typeInd: ComponentType.DROPDOWN_ASYNC,
visibility: [FORM], // display only in insert/update mode
group: group2,
valRules: {
minLength: 1
},
optionConfig: { // state how to fill the options
listUrl: 'coreCompany/list', // service url and method
getValue: (item) => '@{companyId}',
getLabel: (item) => '@{companyName}',
filterBy: (type, inputText) => ({
Criteria:
{
companyId: type.companyId.value || 0, // post current companyId if exists
companyName: inputText // and post typed text to search and filter
}
}),
}
},
userTitle: {
label: getLocalizedText('JOB_TITLE_IN_COMPANY_LABEL'),
typeInd: ComponentType.DROPDOWN,
visibility: [FORM],
group: group1,
optionConfig: {
...ParameterOptionTemplate, // fill with options from coreParameters which are currently in browser's cache
filterBy: (type, inputText) => ({
keyCode: 'USER_TITLE', // use this keyCode to filter
}),
},
},
email: {
label: getLocalizedText('EMAIL_LABEL'),
visibility: [TABLE, FILTER, FORM],
group: group1,
valRules: {
regex: Regexes.email, // use pre-defined regular expression to validate
minLength: 6,
maxLength: 50,
customErrMsg: getLocalizedText("ENTER_VALID_EMAIL"), // use this multi-lingual custom message for regex mismatch
}
},
password: {
label: getLocalizedText('PASSWORD_LABEL'),
visibility: [FORM],
group: group1,
exportConfig: {
excelConfig: {
exportable: false // don't include when excel downloaded/exported
},
valRules: {
regex: Regexes.password, // use pre-defined regular expression to validate
customErrMsg: "This is a custom message when regex is not validated instantly"
},
customProps: {
type: 'password' // special attribute to mask the value with *
}
},
gender: {
label: getLocalizedText("GENDER_LABEL"),
typeInd: ComponentType.DROPDOWN,
visibility: [FORM],
group: group1,
optionConfig: {
...ParameterOptionTemplate,
filterBy: (type, inputText) => ({
keyCode: 'GENDER',
}),
}
},
phoneNumber: {
label: getLocalizedText('TELEPHONE_LABEL'),
typeInd: ComponentType.PHONE_INPUT, // phone input with country code. But you must handle country code.
visibility: [FORM],
group: group1,
valRules: {
acceptEmptyStrings: true, // allow to be empty. But if not empty, validate by regex and others
minLength: 9,
maxLength: 16
}
},
ibanNumber: {
label: getLocalizedText('ibanNumber'),
visibility: [FORM],
group: group1,
maskPattern: "AA99 999 99999999999999999", // A represents letter and 9 represents digits.
forceCaseTo: CaseStyles.UPPER_CASE,
valRules: {
acceptEmptyStrings: true,
minLength: 20,
maxLength: 32,
},
},
taxNumber: {
label: "Tax Number",
visibility: [FORM],
group: group2,
valRules: {
exactLength: 10 // must be exactly 10 char-long
}
},
countriesServed: {
label: "Countries Served",
typeInd: ComponentType.MULTIPLE_SELECT, // Multiple choice dropdown
visibility: [FORM],
group: group2,
optionConfig: {
...ParameterOptionTemplate,
filterBy: (type, inputText) => ({
keyCode: 'COUNTRY'
}),
},
},
cityId: {
label: getLocalizedText('CITY_LABEL'),
typeInd: ComponentType.DROPDOWN,
visibility: [FORM, FILTER],
group: group2,
optionConfig: {
...ParameterOptionTemplate,
filterBy: (type, inputText) => ({
keyCode: 'CITY',
}),
},
onValueChanged: (value, type, initial, isFilter, that) => { // fill countyId dropdown when city changes
if (!isFilter && type.countyId)
that.dataManagement.fillDropdownData(type.countyId); // trigger filling
if (isFilter && type.countyId_filter)
that.dataManagement.fillDropdownData(type.countyId_filter); // a second instance of countyId is created namely countyId_filter for filter. So fill it also
},
},
countyId: {
label: getLocalizedText('countyId'),
typeInd: ComponentType.DROPDOWN,
visibility: [FORM, FILTER],
group: group2,
optionConfig: {
...ParameterOptionTemplate,
filterBy: (type, inputText) => ({
keyCode: 'COUNTY',
parentValue: type.cityId.value || 0 // post cityId as well as keyCode to the web service
}),
},
filterOptionConfig: {
filterBy: (type, inputText) => ({
parentValue: type.cityId_filter.value || 0 // a second instance of cityId is created namely cityId_filter for filter. Get its value to post
}),
},
},
workStartDate: {
label: getLocalizedText('WORK_START_DATE'),
typeInd: ComponentType.DATE_PICKER,
visibility: [FORM, TABLE],
group: group2,
tableDataFormatter: TableFormatters.defaultDateFormatter // Format the date value in table display according
},
salary: {
label: 'Salary',
typeInd: ComponentType.NUMERIC_INPUT,
visibility: [FORM],
group: group2,
customProps: {
decimalPrecision: 3, // default is 2
thousandSeparator: ',', // default is dot
decimalSeparator: '.' // default is comma
},
valRules:{
customValidator: (value, typeElement) => {
return parseFloat(value as string) > 10000 ? 'Good Salary! Keep going on' : ''
}
}
},
note: {
label: "Note",
typeInd: ComponentType.TEXT_AREA,
labelPosition: LabelPositions.ABOVE_INPUT,
placeholder: "enter any note about the user or company",
visibility: [FORM],
group: group2,
customProps: {
rows: 5
}
},
picture: {
label: "Will not be shown",
typeInd: ComponentType.FILE_UPLOADER,
labelPosition: LabelPositions.NONE, // Display no label
visibility: [FORM],
group: group3,
customProps: {
accept: 'image/*' // allow only image files
}
},
status: {
label: getLocalizedText('statusName'),
typeInd: ComponentType.TOGGLE, // On-Off switch
visibility: [TABLE, FILTER, FORM],
group: group1,
defaultValue: 1, // Set to true/on. According to your data type you can set a boolean value
tableDataFormatter: TableFormatters.toggleFormatter // Change label to show in table according to the language
}
}
export default type;
4) Sidebar Menu & Navigation​
pages.js
Place its menu item.
Be aware of the code block below is simplified and shortened for better understanding.
import React from 'react';
import _ from 'lodash';
import { getLocalizedText } from './common/localizationManager';
const SamplePage = React.lazy(() => import('./views/Routes/SamplePage/SamplePageConfig'));
var PageInfos = {
// -- Addition Beg --
SamplePage: {
name: 'Sample Page Title',
url: `/SamplePage`,
component: SamplePage,
resourceCode: 'SamplePage_Resource_Code',
icon: 'icon-some',
}
// -- Addition End --
}
....
5) Here below is what you're gonna get by almost no-coding but only json-declaration.​
Outcome
Automatic Validations and Corresponding Warning Messages
Search, List and the Table
With Data
To form a customized UI page, check Custom Page for details.
Backend​
1) Model​
Be aware of the code block below is simplified and shortened for better understanding.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Microservice.DataLib.DBModels
{
public partial class SampleModelClass
{
[Column("userId")]
public int UserId { get; set; }
[Required]
[Column("userName")]
[StringLength(50)]
public string UserName { get; set; }
[Required]
[Column("userSurname")]
[StringLength(50)]
public string UserSurname { get; set; }
[Column("companyId")]
public int CompanyId { get; set; }
[Column("userTitle")]
public int? UserTitle { get; set; }
[Required]
[Column("email")]
[StringLength(80)]
public string Email { get; set; }
[Column("password")]
[StringLength(64)]
[HashedLogging] // Log the password as hashed
public string Password { get; set; }
[EncryptedPersistence] // Store the gender as encrypted since it is subject to GDPR
[IgnoreLogging] // Don't log the gender
[Column("gender")]
public short? Gender { get; set; }
....
}
}
2) DBContext​
Be aware of the code block below is simplified and shortened for better understanding.
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using CoreData.Common;
using CoreData.Infrastructure.Common;
namespace Microservice.DataLib.DBModels
{
public partial class Your_DBContext : ContextBase
{
public Your_DBContext()
{
}
public Your_DBContext(DbContextOptions<Your_DBContext> options)
: base(options)
{
}
public virtual DbSet<SampleModelClass> SampleModelClass { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseNpgsql(ConfigurationManager.GetConnectionString("PostgreSQL"), b => b.MigrationsAssembly("Microservice.API"));
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasAnnotation("ProductVersion", "2.2.6-servicing-10079");
modelBuilder.Entity<SampleModelClass>(entity =>
{
entity.HasKey(e => e.userId)
.HasName("userId_pk");
});
}
}
}
3) Repository​
Microservice.DataLib.Repositories.SampleModelClassRepository.cs
using System.Collections.Generic;
using System.Linq;
using CoreData.Common;
using CoreType.Types;
using Microsoft.EntityFrameworkCore;
using Microservice.DataLib.DBModels;
using BaseRepository = Microservice.DataLib.Common.BaseRepository;
using CoreHelper = CoreData.Common.Helper;
namespace Microservice.DataLib.Repositories
{
public partial class SampleModelClassRepository : BaseRepository
{
public PaginationWrapper<SampleModelClass> List(RequestWithPagination<SampleModelClass> entity)
{
PaginationWrapper<SampleModelClass> res = new PaginationWrapper<SampleModelClass>();
using (var context = GetDbContext<Your_DBContext>())
{
res.List = context.Set<SampleModelClass>()
.AsNoTracking() // For EF Core performance, use this
.AddFilters(entity) // One of our extension methods that places "where" conditions
.AddSortings(entity)
.ToPaginatedList(entity);
return res;
}
}
public SampleModelClass GetById(SampleModelClass entity)
{
using (var context = GetDbContext<Your_DBContext>())
{
return context.Set<SampleModelClass>().Where(x => x.SampleModelClassId == entity.SampleModelClassId).FirstOrDefault();
}
}
public SampleModelClass Save(SampleModelClass entity)
{
using (var context = GetDbContext<Your_DBContext>())
{
context.Set<SampleModelClass>().Update(entity);
context.SaveChanges();
return entity;
}
}
public List<ResponseWrapper> BulkSave(List<SampleModelClass> dataList)
{
return CoreHelper.BulkSave(dataList, Save);
}
public bool Delete(SampleModelClass entity)
{
using (var context = GetDbContext<Your_DBContext>())
{
context.Set<SampleModelClass>().Remove(entity); // For soft delete, you can check "FAQ Backend"
context.SaveChanges();
return true;
}
}
}
}
4) Validator​
This is an example of FluentValidation. It is used in Controller methods insert and update.
Be aware of the code block below is simplified and shortened for better understanding.
Microservice.DataLib.Validators.SampleClassModelValidator.cs
using Microservice.DataLib.DBModels;
using CoreData.Validators;
using FluentValidation;
namespace Microservice.DataLib.Validators
{
public class SampleClassModelValidator : AbstractValidator<User>
{
public SampleClassModelValidatorValidator()
{
RuleFor(x => x.UserId)
.NotNull();
RuleFor(x => x.UserName)
.NotNull()
.NotEmpty()
.MinimumLength(2)
.MaximumLength(50);
RuleFor(x => x.UserSurname)
.NotNull()
.NotEmpty()
.Length(2, 50);
RuleFor(x => x.CompanyId)
.NotNull()
.NotEqual(0);
RuleFor(x => x.Email)
.EmailAddress()
.MaximumLength(80);
RuleFor(x => x.IdentificationNo)
.NotNull()
.MaximumLength(11);
RuleFor(x => x.Password)
.MaximumLength(64)
.Matches("some regex here");
RuleFor(x => x.Password)
.Equal(x => x.PasswordAgain);
...
}
}
}
Please visit FluentValidation official web site for details.
5) Controller​
Microservice.API.Controllers.SampleModelClassController.cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CoreData;
using CoreData.CacheManager;
using CoreData.Validators;
using CoreSvc.Common;
using CoreSvc.Filters;
using CoreType.Types;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microservice.DataLib.DBModels;
using Microservice.DataLib.Validators;
using Microservice.DataLib.Repositories;
using CoreHelper = CoreData.Common.Helper;
using AdminHelper = Admin.Data.Common.Helper;
namespace Microservice.API.Controllers
{
[Authorize]
[Route("[controller]")]
[Resources("SamplePage_Resource_Code")] // It should be compatible with pages.js and YourPageNamePageConfig.tsx
public class SampleModelClassController : BaseController
{
private readonly SampleModelClassRepository _mainRepository = new SampleModelClassRepository();
private readonly SampleModelClassValidator _SampleModelClassValidator = new SampleModelClassValidator();
private readonly HubContext _hubContext;
public SampleModelClassController(HubContext hubContext)
{
_hubContext = hubContext;
}
[HttpPost("list")]
[ClaimRequirement(ActionType.List)]
public ResponseWrapper List([FromBody] RequestWithPagination<SampleModelClass> request)
{
ResponseWrapper genericResponse = new ResponseWrapper();
genericResponse.Data = _mainRepository.List(request);
genericResponse.Message = DistributedCache.Get(Messages.PROCESS_SUCCESSFUL);
genericResponse.Success = true;
return genericResponse;
}
[HttpPost("getById")]
[ClaimRequirement(ActionType.GetRecord)]
public ResponseWrapper GetById([FromBody] SampleModelClass request)
{
ResponseWrapper genericResponse = new ResponseWrapper();
genericResponse.Data = _mainRepository.GetById(request);
genericResponse.Message = DistributedCache.Get(Messages.PROCESS_SUCCESSFUL);
genericResponse.Success = true;
return genericResponse;
}
[HttpPost("insert")]
[ClaimRequirement(ActionType.Insert)]
public ResponseWrapper Insert([FromBody] SampleModelClass request)
{
_SampleModelClassValidator.ValidateAndThrow(request); // FluentValidation
return Save(request);
}
[HttpPost("update")]
[ClaimRequirement(ActionType.Update)]
public ResponseWrapper Update([FromBody] SampleModelClass request)
{
_SampleModelClassValidator.ValidateAndThrow(request); // FluentValidation
return Save(request);
}
private ResponseWrapper Save(SampleModelClass request)
{
ResponseWrapper genericResponse = new ResponseWrapper();
SampleModelClass res = _mainRepository.Save(request);
if (CoreHelper.GetPrimaryIdVal(res) > 0)
{
genericResponse.Data = res;
genericResponse.Message = DistributedCache.Get(Messages.PROCESS_SUCCESSFUL);
genericResponse.Success = true;
}
else
genericResponse.Message = DistributedCache.Get(Messages.PROCESS_FAILED);
return genericResponse;
}
[HttpPost("bulkSave")]
[ClaimRequirement(ActionType.Import)]
public ResponseWrapper BulkSave([FromBody] RequestWithExcelData<SampleModelClass> request)
{
ResponseWrapper genericResponse = new ResponseWrapper();
if (request != null)
{
Task.Run(() => BulkSaveAsync(request, _mainRepository.BulkSave, HttpContext.Request));
genericResponse.Message = DistributedCache.Get(Messages.PROCESS_SUCCESSFUL); // Multilanguage message
genericResponse.Success = true;
}
else
genericResponse.Message = DistributedCache.Get(Messages.PROCESS_FAILED);
return genericResponse;
}
private async Task BulkSaveAsync<T>(RequestWithExcelData<T> request, Func<List<T>, List<ResponseWrapper>> BulkSave, HttpRequest httpRequest)
{
var notification = await AdminHelper.BulkSaveWithNotificationAsync(request, BulkSave, Session, httpRequest.GetDisplayUrl());
await _hubContext.Send(Session, notification, SocketActionType.EXCEL_IMPORT);
}
[HttpPost("delete")]
[ClaimRequirement(ActionType.Delete)]
public ResponseWrapper Delete([FromBody] SampleModelClass request)
{
ResponseWrapper genericResponse = new ResponseWrapper();
if (_mainRepository.Delete(request))
{
genericResponse.Message = DistributedCache.Get(Messages.PROCESS_SUCCESSFUL);
genericResponse.Success = true;
}
else
genericResponse.Message = DistributedCache.Get(Messages.PROCESS_FAILED);
return genericResponse;
}
}
}