HotCRM Logo
Development

Creating Objects

How to create custom business objects in HotCRM using TypeScript

Creating Objects

HotCRM uses a metadata-driven architecture where all business objects are defined in TypeScript using the @objectstack/spec protocol. This guide shows you how to create custom objects.

File Suffix Protocol

HotCRM follows a strict file naming convention:

{object_name}.object.ts    # Object definitions
{object_name}.hook.ts      # Business logic triggers
{object_name}.page.ts      # UI page configurations
{object_name}.view.ts      # UI view configurations

Naming Rules:

  • Use snake_case for file names
  • Always include the appropriate suffix (.object.ts, .hook.ts, etc.)
  • Object names in the schema use PascalCase

Basic Object Structure

Step 1: Import the Schema Type

import type { ObjectSchema } from '@objectstack/spec/data';

Step 2: Define the Object

const MyCustomObject: ObjectSchema = {
  name: 'CustomObject',           // PascalCase, no spaces
  label: 'Custom Object',          // Display name (singular)
  labelPlural: 'Custom Objects',   // Display name (plural)
  icon: 'briefcase',               // Icon name
  description: 'Description of the object',
  
  features: {
    searchable: true,              // Enable full-text search
    trackFieldHistory: true,       // Track field changes
    enableActivities: true,        // Link to activities
    enableNotes: true,             // Allow notes
    enableAttachments: true,       // Allow file attachments
    enableDuplicateDetection: true // Check for duplicates
  },
  
  fields: [
    // Field definitions go here
  ],
  
  relationships: [
    // Relationship definitions go here
  ],
  
  listViews: [
    // List view definitions go here
  ],
  
  validationRules: [
    // Validation rules go here
  ],
  
  pageLayout: {
    // Page layout configuration
  }
};

export default MyCustomObject;

Step 3: Export the Object

export default MyCustomObject;

Field Types

Text Fields

// Simple text
{
  name: 'Name',
  type: 'text',
  label: 'Name',
  required: true,
  searchable: true,
  length: 255,
  unique: false
}

// Long text
{
  name: 'Description',
  type: 'textarea',
  label: 'Description',
  length: 32000,
  rows: 5
}

// Email
{
  name: 'Email',
  type: 'email',
  label: 'Email',
  unique: true,
  searchable: true
}

// Phone
{
  name: 'Phone',
  type: 'phone',
  label: 'Phone'
}

// URL
{
  name: 'Website',
  type: 'url',
  label: 'Website'
}

Number Fields

// Integer
{
  name: 'Quantity',
  type: 'number',
  label: 'Quantity',
  precision: 0,
  min: 0,
  max: 99999
}

// Decimal
{
  name: 'Price',
  type: 'number',
  label: 'Price',
  precision: 2,
  min: 0
}

// Currency
{
  name: 'Amount',
  type: 'currency',
  label: 'Amount',
  precision: 2,
  currency: 'CNY'
}

// Percentage
{
  name: 'Discount',
  type: 'percent',
  label: 'Discount',
  precision: 2,
  min: 0,
  max: 100
}

Date & Time Fields

// Date only
{
  name: 'StartDate',
  type: 'date',
  label: 'Start Date'
}

// Date and time
{
  name: 'CreatedAt',
  type: 'datetime',
  label: 'Created At',
  defaultValue: '$now',
  readonly: true
}

// Time only
{
  name: 'MeetingTime',
  type: 'time',
  label: 'Meeting Time'
}

Select Fields

// Single select (picklist)
{
  name: 'Status',
  type: 'select',
  label: 'Status',
  required: true,
  defaultValue: 'Draft',
  options: [
    { label: '📝 Draft', value: 'Draft' },
    { label: '🔄 In Progress', value: 'In Progress' },
    { label: '✅ Completed', value: 'Completed' },
    { label: '❌ Cancelled', value: 'Cancelled' }
  ]
}

// Multi-select
{
  name: 'Skills',
  type: 'multiselect',
  label: '技能',
  options: [
    { label: 'JavaScript', value: 'javascript' },
    { label: 'Python', value: 'python' },
    { label: 'Java', value: 'java' },
    { label: 'Go', value: 'go' }
  ]
}

Boolean Fields

{
  name: 'IsActive',
  type: 'checkbox',
  label: 'Active',
  defaultValue: true
}

Lookup Fields (Relationships)

// Many-to-one relationship
{
  name: 'AccountId',
  type: 'lookup',
  label: 'Account',
  referenceTo: 'Account',    // Related object
  required: true,
  cascadeDelete: false       // Delete behavior
}

// Polymorphic lookup (multiple object types)
{
  name: 'WhatId',
  type: 'lookup',
  label: 'Related To',
  referenceTo: ['Account', 'Opportunity', 'Case']
}

Formula Fields

// Calculated field
{
  name: 'ExpectedRevenue',
  type: 'currency',
  label: 'Expected Revenue',
  precision: 2,
  formula: 'Amount * Probability / 100',
  readonly: true,
  description: 'Calculated based on amount and win probability'
}

// Date calculation
{
  name: 'DaysOpen',
  type: 'number',
  label: 'Days Open',
  precision: 0,
  formula: 'DATEDIFF(TODAY(), CreatedDate)',
  readonly: true
}

AI-Enhanced Fields

// AI-generated score
{
  name: 'LeadScore',
  type: 'number',
  label: 'Lead Score',
  precision: 0,
  min: 0,
  max: 100,
  readonly: true,
  description: 'AI-calculated lead quality score'
}

// AI-generated text
{
  name: 'AISummary',
  type: 'textarea',
  label: 'AI Summary',
  readonly: true,
  length: 2000
}

Relationships

One-to-Many (hasMany)

relationships: [
  {
    name: 'Opportunities',        // Relationship name
    type: 'hasMany',              // One-to-many
    object: 'Opportunity',        // Related object
    foreignKey: 'AccountId',      // Foreign key in related object
    label: 'Opportunities'
  }
]

Many-to-One (belongsTo)

// Automatically created when you define a lookup field
{
  name: 'AccountId',
  type: 'lookup',
  label: 'Account',
  referenceTo: 'Account'
}
// Creates reverse relationship: Account.belongsTo

Many-to-Many

// Use junction object
const CampaignMember: ObjectSchema = {
  name: 'CampaignMember',
  fields: [
    {
      name: 'CampaignId',
      type: 'lookup',
      referenceTo: 'Campaign',
      required: true
    },
    {
      name: 'LeadId',
      type: 'lookup',
      referenceTo: 'Lead'
    },
    {
      name: 'ContactId',
      type: 'lookup',
      referenceTo: 'Contact'
    },
    {
      name: 'Status',
      type: 'select',
      options: [
        { label: 'Sent', value: 'Sent' },
        { label: 'Responded', value: 'Responded' }
      ]
    }
  ]
};

List Views

Define how records are displayed in list views:

listViews: [
  {
    name: 'AllRecords',
    label: 'All Records',
    filters: [],                   // No filters - show all
    columns: ['Name', 'Status', 'CreatedDate'],
    sort: [['CreatedDate', 'desc']]
  },
  
  {
    name: 'MyActive',
    label: 'My Active Records',
    filters: [
      ['OwnerId', '=', '$currentUser'],
      ['Status', '=', 'Active']
    ],
    columns: ['Name', 'Priority', 'DueDate'],
    sort: [['DueDate', 'asc']]
  },
  
  {
    name: 'HighPriority',
    label: 'High Priority',
    filters: [
      ['Priority', '=', 'High'],
      ['Status', 'not in', ['Completed', 'Cancelled']]
    ],
    columns: ['Name', 'Status', 'AssignedTo', 'DueDate'],
    sort: [['DueDate', 'asc']]
  }
]

Filter Operators

// Comparison
['Field', '=', 'value']
['Field', '!=', 'value']
['Field', '>', 100]
['Field', '>=', 100]
['Field', '<', 100]
['Field', '<=', 100]

// Lists
['Field', 'in', ['value1', 'value2']]
['Field', 'not in', ['value1', 'value2']]

// Null checks
['Field', 'is null']
['Field', 'is not null']

// Text search
['Field', 'like', '%search%']

// Date ranges
['CreatedDate', 'last_n_days', 7]
['CreatedDate', 'this_month']
['CreatedDate', 'this_year']

// Current user
['OwnerId', '=', '$currentUser']

Validation Rules

Add business rules to ensure data quality:

validationRules: [
  {
    name: 'EmailOrPhoneRequired',
    errorMessage: 'Email or Phone is required',
    formula: 'AND(ISBLANK(Email), ISBLANK(Phone))'
  },
  
  {
    name: 'EndDateAfterStartDate',
    errorMessage: 'End date must be after start date',
    formula: 'EndDate < StartDate'
  },
  
  {
    name: 'DiscountLimit',
    errorMessage: 'Discount cannot exceed 30%',
    formula: 'AND(Discount > 30, NOT(ISPICKVAL(ApprovalStatus, "Approved")))'
  },
  
  {
    name: 'AmountRequiredForAdvancedStage',
    errorMessage: 'Amount is required for Proposal stage',
    formula: 'AND(ISPICKVAL(Stage, "Proposal"), ISBLANK(Amount))'
  }
]

Formula Functions

  • ISBLANK(field) - Check if field is empty
  • AND(condition1, condition2, ...) - Logical AND
  • OR(condition1, condition2, ...) - Logical OR
  • NOT(condition) - Logical NOT
  • ISPICKVAL(field, 'value') - Check picklist value
  • DATEDIFF(date1, date2) - Days between dates
  • TODAY() - Current date
  • NOW() - Current date and time
  • PRIORVALUE(field) - Previous value (before update)

Page Layout

Define how fields are displayed on detail pages:

pageLayout: {
  sections: [
    {
      label: 'Basic Information',
      columns: 2,                  // 2-column layout
      fields: ['Name', 'Status', 'Priority', 'OwnerId']
    },
    
    {
      label: 'Details',
      columns: 2,
      fields: ['StartDate', 'EndDate', 'Amount', 'Probability']
    },
    
    {
      label: 'Description',
      columns: 1,                  // 1-column layout (full width)
      fields: ['Description']
    },
    
    {
      label: 'AI Analysis',
      columns: 1,
      fields: ['AISummary', 'AIRecommendedAction']
    }
  ]
}

Complete Example

Here's a complete custom object for tracking projects:

import type { ObjectSchema } from '@objectstack/spec/data';

const Project: ObjectSchema = {
  name: 'Project',
  label: 'Project',
  labelPlural: 'Projects',
  icon: 'folder',
  description: 'Project management and tracking',
  
  features: {
    searchable: true,
    trackFieldHistory: true,
    enableActivities: true,
    enableNotes: true,
    enableAttachments: true
  },
  
  fields: [
    // Basic Information
    {
      name: 'Name',
      type: 'text',
      label: 'Project Name',
      required: true,
      searchable: true,
      length: 255
    },
    
    {
      name: 'AccountId',
      type: 'lookup',
      label: 'Account',
      referenceTo: 'Account',
      required: true
    },
    
    {
      name: 'OwnerId',
      type: 'lookup',
      label: 'Project Manager',
      referenceTo: 'User',
      required: true,
      defaultValue: '$currentUser'
    },
    
    // Status & Dates
    {
      name: 'Status',
      type: 'select',
      label: 'Status',
      required: true,
      defaultValue: 'Planning',
      options: [
        { label: '📋 Planning', value: 'Planning' },
        { label: '🚀 In Progress', value: 'In Progress' },
        { label: '⏸️ On Hold', value: 'On Hold' },
        { label: '✅ Completed', value: 'Completed' },
        { label: '❌ Cancelled', value: 'Cancelled' }
      ]
    },
    
    {
      name: 'Priority',
      type: 'select',
      label: 'Priority',
      options: [
        { label: 'Critical', value: 'Critical' },
        { label: 'High', value: 'High' },
        { label: 'Medium', value: 'Medium' },
        { label: 'Low', value: 'Low' }
      ]
    },
    
    {
      name: 'StartDate',
      type: 'date',
      label: 'Start Date',
      required: true
    },
    
    {
      name: 'EndDate',
      type: 'date',
      label: 'End Date',
      required: true
    },
    
    {
      name: 'DurationDays',
      type: 'number',
      label: 'Duration (Days)',
      precision: 0,
      formula: 'DATEDIFF(EndDate, StartDate)',
      readonly: true
    },
    
    // Budget
    {
      name: 'Budget',
      type: 'currency',
      label: 'Budget',
      precision: 2,
      currency: 'CNY'
    },
    
    {
      name: 'ActualCost',
      type: 'currency',
      label: 'Actual Cost',
      precision: 2,
      currency: 'CNY'
    },
    
    {
      name: 'BudgetVariance',
      type: 'currency',
      label: 'Budget Variance',
      precision: 2,
      formula: 'Budget - ActualCost',
      readonly: true
    },
    
    // Progress
    {
      name: 'PercentComplete',
      type: 'percent',
      label: 'Percent Complete',
      precision: 0,
      min: 0,
      max: 100,
      defaultValue: 0
    },
    
    {
      name: 'Description',
      type: 'textarea',
      label: 'Description',
      length: 32000
    },
    
    // AI Fields
    {
      name: 'AIRiskScore',
      type: 'number',
      label: 'AI Risk Score',
      precision: 0,
      min: 0,
      max: 100,
      readonly: true,
      description: 'AI-predicted project risk score'
    },
    
    {
      name: 'AIRecommendations',
      type: 'textarea',
      label: 'AI Recommendations',
      readonly: true
    }
  ],
  
  relationships: [
    {
      name: 'Tasks',
      type: 'hasMany',
      object: 'Task',
      foreignKey: 'ProjectId',
      label: 'Tasks'
    },
    {
      name: 'Activities',
      type: 'hasMany',
      object: 'Activity',
      foreignKey: 'WhatId',
      label: 'Activities'
    }
  ],
  
  listViews: [
    {
      name: 'AllProjects',
      label: 'All Projects',
      filters: [],
      columns: ['Name', 'AccountId', 'Status', 'Priority', 'StartDate', 'EndDate', 'PercentComplete'],
      sort: [['StartDate', 'desc']]
    },
    
    {
      name: 'MyActiveProjects',
      label: 'My Active Projects',
      filters: [
        ['OwnerId', '=', '$currentUser'],
        ['Status', 'in', ['Planning', 'In Progress']]
      ],
      columns: ['Name', 'AccountId', 'Status', 'PercentComplete', 'EndDate'],
      sort: [['EndDate', 'asc']]
    },
    
    {
      name: 'AtRisk',
      label: 'At Risk Projects',
      filters: [
        ['AIRiskScore', '>', 70],
        ['Status', 'not in', ['Completed', 'Cancelled']]
      ],
      columns: ['Name', 'AccountId', 'AIRiskScore', 'PercentComplete', 'EndDate', 'OwnerId'],
      sort: [['AIRiskScore', 'desc']]
    }
  ],
  
  validationRules: [
    {
      name: 'EndDateAfterStartDate',
      errorMessage: 'End date must be after start date',
      formula: 'EndDate < StartDate'
    },
    
    {
      name: 'ActualCostNotExceedBudget',
      errorMessage: 'Actual cost cannot exceed 150% of budget',
      formula: 'ActualCost > (Budget * 1.5)'
    },
    
    {
      name: 'CompletedProjectMustBe100Percent',
      errorMessage: 'Completed project must be 100% complete',
      formula: 'AND(ISPICKVAL(Status, "Completed"), PercentComplete < 100)'
    }
  ],
  
  pageLayout: {
    sections: [
      {
        label: 'Project Information',
        columns: 2,
        fields: ['Name', 'AccountId', 'OwnerId', 'Status', 'Priority']
      },
      {
        label: 'Time Planning',
        columns: 2,
        fields: ['StartDate', 'EndDate', 'DurationDays', 'PercentComplete']
      },
      {
        label: 'Budget Management',
        columns: 2,
        fields: ['Budget', 'ActualCost', 'BudgetVariance']
      },
      {
        label: 'AI Analysis',
        columns: 1,
        fields: ['AIRiskScore', 'AIRecommendations']
      },
      {
        label: 'Description',
        columns: 1,
        fields: ['Description']
      }
    ]
  }
};

export default Project;

Best Practices

1. Use Meaningful Names

// ✅ Good
{
  name: 'ExpectedCloseDate',
  label: 'Expected Close Date'
}

// ❌ Bad
{
  name: 'Date1',
  label: 'Date 1'
}

2. Add Descriptions

// ✅ Good
{
  name: 'LeadScore',
  label: 'Lead Score',
  description: 'AI-calculated lead quality score (0-100)'
}

3. Use Validation Rules

// ✅ Good - Enforce data quality
validationRules: [
  {
    name: 'EmailOrPhoneRequired',
    errorMessage: 'Email or Phone is required',
    formula: 'AND(ISBLANK(Email), ISBLANK(Phone))'
  }
]

4. Provide Helpful List Views

// ✅ Good - Multiple useful views
listViews: [
  { name: 'All', ... },
  { name: 'MyActive', ... },
  { name: 'HighPriority', ... },
  { name: 'OverdueItems', ... }
]

5. Use Readonly for Calculated Fields

// ✅ Good
{
  name: 'TotalAmount',
  formula: 'Quantity * UnitPrice',
  readonly: true
}

Next Steps

On this page