Skip to content

Tags & Custom Fields

Overview

The Tags and Custom Fields system provides flexible metadata management for digital assets, allowing users to organize, categorize, and search assets using custom tags and field definitions. This system supports multiple data types, validation rules, and workspace-specific configurations.

Architecture

System Components

  1. Tags System: Simple key-value tags for quick categorization
  2. Custom Fields System: Structured fields with types, validation, and default values
  3. Field Templates: Reusable field definitions for consistent metadata
  4. Field Values: Actual data stored against assets

Tags System

Tag Structure

Tag Object

javascript
{
  id: 1,
  workspace_id: 123,
  name: "marketing",
  color: "#FF5722",
  usage_count: 45,
  created_at: "2024-01-15T10:00:00Z",
  updated_at: "2024-01-20T14:30:00Z"
}

Custom Fields System

Field Definition Structure

Field Definition Object:

javascript
{
  id: 1,
  workspace_id: 123,
  name: "Project Name",
  field_key: "project_name",
  field_type: "text",
  is_required: false,
  is_searchable: true,
  default_value: null,
  options: null,
  display_order: 0,
  created_at: "2024-01-15T10:00:00Z",
  updated_at: "2024-01-20T14:30:00Z"
}

Field Value Object:

javascript
{
  id: 1,
  asset_id: 456,
  field_definition_id: 1,
  field_value: "Summer Campaign 2024",
  created_at: "2024-01-15T10:00:00Z",
  updated_at: "2024-01-20T14:30:00Z"
}

Frontend Implementation

Tag Management Component

vue
<template>
  <div class="tag-manager">
    <v-card>
      <v-card-title>Manage Tags</v-card-title>
      <v-card-text>
        <v-text-field
          v-model="newTagName"
          label="Tag Name"
          @keyup.enter="createTag"
        />
        <v-color-picker
          v-model="newTagColor"
          mode="hexa"
        />
        <v-btn color="primary" @click="createTag">Create Tag</v-btn>
        
        <v-divider class="my-4" />
        
        <v-chip
          v-for="tag in tags"
          :key="tag.id"
          :color="tag.color"
          text-color="white"
          class="ma-1"
          closable
          @click:close="deleteTag(tag)"
        >
          {{ tag.name }}
          <v-chip small class="ml-2">{{ tag.usage_count }}</v-chip>
        </v-chip>
      </v-card-text>
    </v-card>
  </div>
</template>

<script>
export default {
  data() {
    return {
      tags: [],
      newTagName: '',
      newTagColor: '#1976D2'
    }
  },
  async mounted() {
    await this.fetchTags()
  },
  methods: {
    async fetchTags() {
      const response = await this.$axios.get(
        `/api/workspaces/${this.workspaceId}/tags`
      )
      this.tags = response.data.tags
    },
    async createTag() {
      if (!this.newTagName.trim()) return
      
      try {
        const response = await this.$axios.post(
          `/api/workspaces/${this.workspaceId}/tags`,
          {
            name: this.newTagName.trim(),
            color: this.newTagColor
          }
        )
        this.tags.push(response.data.tag)
        this.newTagName = ''
      } catch (error) {
        this.$toast.error('Failed to create tag')
      }
    },
    async deleteTag(tag) {
      try {
        await this.$axios.delete(
          `/api/workspaces/${this.workspaceId}/tags/${tag.id}`
        )
        this.tags = this.tags.filter(t => t.id !== tag.id)
      } catch (error) {
        this.$toast.error('Failed to delete tag')
      }
    }
  }
}
</script>

Asset Tag Editor Component

vue
<template>
  <div class="asset-tag-editor">
    <v-autocomplete
      v-model="selectedTags"
      :items="availableTags"
      item-text="name"
      item-value="id"
      label="Tags"
      multiple
      chips
      deletable-chips
      :search-input.sync="tagSearch"
      @update:search-input="searchTags"
    >
      <template v-slot:item="{ item }">
        <v-chip
          :color="item.color"
          text-color="white"
          small
        >
          {{ item.name }}
        </v-chip>
      </template>
    </v-autocomplete>
    
    <v-btn
      v-if="!availableTags.find(t => t.name === tagSearch)"
      color="primary"
      small
      @click="createTagFromSearch"
    >
      Create "{{ tagSearch }}"
    </v-btn>
  </div>
</template>

<script>
import { debounce } from 'lodash'

export default {
  props: {
    assetId: {
      type: Number,
      required: true
    },
    currentTags: {
      type: Array,
      default: () => []
    }
  },
  data() {
    return {
      selectedTags: [],
      availableTags: [],
      tagSearch: ''
    }
  },
  watch: {
    currentTags: {
      immediate: true,
      handler(tags) {
        this.selectedTags = tags.map(t => t.id)
      }
    },
    selectedTags: {
      handler(newTags) {
        this.updateAssetTags(newTags)
      }
    }
  },
  created() {
    this.fetchAvailableTags()
    this.debouncedSearch = debounce(this.searchTags, 300)
  },
  methods: {
    async fetchAvailableTags() {
      const response = await this.$axios.get(
        `/api/workspaces/${this.workspaceId}/tags`
      )
      this.availableTags = response.data.tags
    },
    async searchTags(query) {
      if (!query || query.length < 2) {
        this.fetchAvailableTags()
        return
      }
      
      const response = await this.$axios.get(
        `/api/workspaces/${this.workspaceId}/tags/search`,
        { params: { q: query } }
      )
      this.availableTags = response.data.tags
    },
    async updateAssetTags(tagIds) {
      try {
        await this.$axios.put(
          `/api/workspaces/${this.workspaceId}/assets/${this.assetId}/tags`,
          { tag_ids: tagIds }
        )
        this.$emit('tags-updated', tagIds)
      } catch (error) {
        this.$toast.error('Failed to update tags')
      }
    },
    async createTagFromSearch() {
      if (!this.tagSearch.trim()) return
      
      try {
        const response = await this.$axios.post(
          `/api/workspaces/${this.workspaceId}/tags`,
          { name: this.tagSearch.trim() }
        )
        this.availableTags.push(response.data.tag)
        this.selectedTags.push(response.data.tag.id)
        this.tagSearch = ''
      } catch (error) {
        this.$toast.error('Failed to create tag')
      }
    }
  }
}
</script>

Custom Fields Manager Component

vue
<template>
  <div class="custom-fields-manager">
    <v-card>
      <v-card-title>Custom Fields</v-card-title>
      <v-card-text>
        <v-btn color="primary" @click="openFieldDialog">Add Custom Field</v-btn>
        
        <v-list>
          <v-list-item
            v-for="field in fields"
            :key="field.id"
          >
            <v-list-item-content>
              <v-list-item-title>{{ field.name }}</v-list-item-title>
              <v-list-item-subtitle>
                Type: {{ field.field_type }} | 
                Key: {{ field.field_key }} |
                {{ field.is_required ? 'Required' : 'Optional' }}
              </v-list-item-subtitle>
            </v-list-item-content>
            <v-list-item-action>
              <v-btn icon @click="editField(field)">
                <EditIcon /> <!-- Custom SVG icon component from @/components/svg -->
              </v-btn>
              <v-btn icon @click="deleteField(field)">
                <DeleteIcon /> <!-- Custom SVG icon component from @/components/svg -->
              </v-btn>
            </v-list-item-action>
          </v-list-item>
        </v-list>
      </v-card-text>
    </v-card>
    
    <v-dialog v-model="fieldDialog" max-width="600">
      <v-card>
        <v-card-title>
          {{ editingField ? 'Edit Field' : 'Create Field' }}
        </v-card-title>
        <v-card-text>
          <v-text-field
            v-model="fieldForm.name"
            label="Field Name"
            required
          />
          <v-text-field
            v-model="fieldForm.field_key"
            label="Field Key"
            hint="Used in API (lowercase, underscores)"
            required
          />
          <v-select
            v-model="fieldForm.field_type"
            :items="fieldTypes"
            label="Field Type"
            required
          />
          <v-switch
            v-model="fieldForm.is_required"
            label="Required"
          />
          <v-switch
            v-model="fieldForm.is_searchable"
            label="Searchable"
          />
          <v-text-field
            v-if="fieldForm.field_type === 'text' || fieldForm.field_type === 'number'"
            v-model="fieldForm.default_value"
            label="Default Value"
          />
          <v-combobox
            v-if="fieldForm.field_type === 'select' || fieldForm.field_type === 'multiselect'"
            v-model="fieldForm.options"
            label="Options"
            multiple
            chips
            hint="Enter options for select field"
          />
        </v-card-text>
        <v-card-actions>
          <v-spacer />
          <v-btn @click="fieldDialog = false">Cancel</v-btn>
          <v-btn color="primary" @click="saveField">Save</v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </div>
</template>

<script>
export default {
  data() {
    return {
      fields: [],
      fieldDialog: false,
      editingField: null,
      fieldForm: {
        name: '',
        field_key: '',
        field_type: 'text',
        is_required: false,
        is_searchable: true,
        default_value: '',
        options: []
      },
      fieldTypes: [
        { text: 'Text', value: 'text' },
        { text: 'Number', value: 'number' },
        { text: 'Date', value: 'date' },
        { text: 'Boolean', value: 'boolean' },
        { text: 'Select', value: 'select' },
        { text: 'Multi-Select', value: 'multiselect' },
        { text: 'URL', value: 'url' },
        { text: 'Email', value: 'email' }
      ]
    }
  },
  async mounted() {
    await this.fetchFields()
  },
  methods: {
    async fetchFields() {
      const response = await this.$axios.get(
        `/api/workspaces/${this.workspaceId}/custom-fields`
      )
      this.fields = response.data.fields
    },
    openFieldDialog() {
      this.editingField = null
      this.resetFieldForm()
      this.fieldDialog = true
    },
    editField(field) {
      this.editingField = field
      this.fieldForm = { ...field }
      this.fieldDialog = true
    },
    resetFieldForm() {
      this.fieldForm = {
        name: '',
        field_key: '',
        field_type: 'text',
        is_required: false,
        is_searchable: true,
        default_value: '',
        options: []
      }
    },
    async saveField() {
      try {
        const url = this.editingField
          ? `/api/workspaces/${this.workspaceId}/custom-fields/${this.editingField.id}`
          : `/api/workspaces/${this.workspaceId}/custom-fields`
        
        const method = this.editingField ? 'put' : 'post'
        const response = await this.$axios[method](url, this.fieldForm)
        
        if (this.editingField) {
          const index = this.fields.findIndex(f => f.id === this.editingField.id)
          this.fields.splice(index, 1, response.data.field)
        } else {
          this.fields.push(response.data.field)
        }
        
        this.fieldDialog = false
        this.$toast.success('Field saved successfully')
      } catch (error) {
        this.$toast.error('Failed to save field')
      }
    },
    async deleteField(field) {
      if (!confirm(`Delete field "${field.name}"?`)) return
      
      try {
        await this.$axios.delete(
          `/api/workspaces/${this.workspaceId}/custom-fields/${field.id}`
        )
        this.fields = this.fields.filter(f => f.id !== field.id)
        this.$toast.success('Field deleted')
      } catch (error) {
        this.$toast.error('Failed to delete field')
      }
    }
  }
}
</script>

Asset Custom Fields Editor

vue
<template>
  <div class="asset-custom-fields">
    <v-card v-for="field in customFields" :key="field.id" class="mb-2">
      <v-card-text>
        <v-text-field
          v-if="field.field_type === 'text'"
          v-model="fieldValues[field.id]"
          :label="field.name"
          :required="field.is_required"
          @input="updateFieldValue(field.id, $event)"
        />
        
        <v-text-field
          v-else-if="field.field_type === 'number'"
          v-model.number="fieldValues[field.id]"
          :label="field.name"
          type="number"
          :required="field.is_required"
          @input="updateFieldValue(field.id, $event)"
        />
        
        <v-date-picker
          v-else-if="field.field_type === 'date'"
          v-model="fieldValues[field.id]"
          :label="field.name"
          :required="field.is_required"
          @change="updateFieldValue(field.id, $event)"
        />
        
        <v-switch
          v-else-if="field.field_type === 'boolean'"
          v-model="fieldValues[field.id]"
          :label="field.name"
          :required="field.is_required"
          @change="updateFieldValue(field.id, $event)"
        />
        
        <v-select
          v-else-if="field.field_type === 'select'"
          v-model="fieldValues[field.id]"
          :items="field.options"
          :label="field.name"
          :required="field.is_required"
          @change="updateFieldValue(field.id, $event)"
        />
        
        <v-combobox
          v-else-if="field.field_type === 'multiselect'"
          v-model="fieldValues[field.id]"
          :items="field.options"
          :label="field.name"
          multiple
          chips
          :required="field.is_required"
          @change="updateFieldValue(field.id, $event)"
        />
        
        <v-text-field
          v-else-if="field.field_type === 'url'"
          v-model="fieldValues[field.id]"
          :label="field.name"
          type="url"
          :required="field.is_required"
          @input="updateFieldValue(field.id, $event)"
        />
        
        <v-text-field
          v-else-if="field.field_type === 'email'"
          v-model="fieldValues[field.id]"
          :label="field.name"
          type="email"
          :required="field.is_required"
          @input="updateFieldValue(field.id, $event)"
        />
      </v-card-text>
    </v-card>
  </div>
</template>

<script>
import { debounce } from 'lodash'

export default {
  props: {
    assetId: {
      type: Number,
      required: true
    }
  },
  data() {
    return {
      customFields: [],
      fieldValues: {}
    }
  },
  async mounted() {
    await Promise.all([
      this.fetchCustomFields(),
      this.fetchAssetFieldValues()
    ])
  },
  methods: {
    async fetchCustomFields() {
      const response = await this.$axios.get(
        `/api/workspaces/${this.workspaceId}/custom-fields`
      )
      this.customFields = response.data.fields
      
      // Initialize field values with defaults
      this.customFields.forEach(field => {
        if (!this.fieldValues[field.id] && field.default_value) {
          this.fieldValues[field.id] = field.default_value
        }
      })
    },
    async fetchAssetFieldValues() {
      const response = await this.$axios.get(
        `/api/workspaces/${this.workspaceId}/assets/${this.assetId}/custom-fields`
      )
      
      response.data.values.forEach(value => {
        this.fieldValues[value.field_definition_id] = value.field_value
      })
    },
    updateFieldValue: debounce(async function(fieldId, value) {
      try {
        await this.$axios.put(
          `/api/workspaces/${this.workspaceId}/assets/${this.assetId}/custom-fields/${fieldId}`,
          { field_value: value }
        )
        this.$emit('field-updated', { fieldId, value })
      } catch (error) {
        this.$toast.error('Failed to update field')
      }
    }, 500)
  }
}
</script>

API Design

Tags API

Get Tags

Endpoint: GET /api/workspaces/:workspace_id/tags

Response:

json
{
  "tags": [
    {
      "id": 1,
      "workspace_id": 123,
      "name": "marketing",
      "color": "#FF5722",
      "usage_count": 45,
      "created_at": "2024-01-15T10:00:00Z"
    }
  ]
}

Create Tag

Endpoint: POST /api/workspaces/:workspace_id/tags

Request Body:

json
{
  "name": "new-tag",
  "color": "#1976D2"
}

Search Tags

Endpoint: GET /api/workspaces/:workspace_id/tags/search

Query Parameters:

  • q (string, required) - Search query

Response: Array of matching tags

Update Asset Tags

Endpoint: PUT /api/workspaces/:workspace_id/assets/:asset_id/tags

Request Body:

json
{
  "tag_ids": [1, 2, 3]
}

Custom Fields API

Get Custom Fields

Endpoint: GET /api/workspaces/:workspace_id/custom-fields

Response:

json
{
  "fields": [
    {
      "id": 1,
      "workspace_id": 123,
      "name": "Project Name",
      "field_key": "project_name",
      "field_type": "text",
      "is_required": false,
      "is_searchable": true,
      "default_value": null,
      "options": null,
      "display_order": 0,
      "created_at": "2024-01-15T10:00:00Z"
    }
  ]
}

Create Custom Field

Endpoint: POST /api/workspaces/:workspace_id/custom-fields

Request Body:

json
{
  "name": "Project Name",
  "field_key": "project_name",
  "field_type": "text",
  "is_required": false,
  "is_searchable": true,
  "default_value": "",
  "options": null
}

Get Asset Custom Field Values

Endpoint: GET /api/workspaces/:workspace_id/assets/:asset_id/custom-fields

Response:

json
{
  "values": [
    {
      "id": 1,
      "asset_id": 456,
      "field_definition_id": 1,
      "field_value": "Summer Campaign 2024",
      "field_definition": {
        "name": "Project Name",
        "field_key": "project_name",
        "field_type": "text"
      }
    }
  ]
}

Update Custom Field Value

Endpoint: PUT /api/workspaces/:workspace_id/assets/:asset_id/custom-fields/:field_id

Request Body:

json
{
  "field_value": "Updated Project Name"
}

Typesense Integration

Indexing Tags and Custom Fields

Tags and custom fields are indexed in Typesense for search:

javascript
// Typesense document structure
{
  id: "asset-123",
  display_file_name: "example.jpg",
  tags: ["marketing", "summer", "campaign"],
  custom_fields: {
    project_name: "Summer Campaign 2024",
    client_name: "Acme Corp",
    campaign_date: "2024-06-01"
  },
  // ... other fields
}

Search with Tags

javascript
// Filter by tags in Typesense
{
  "filterQuery": {
    "digital_assets": "(tags:=[marketing] && tags:=[summer])"
  }
}

Search with Custom Fields

javascript
// Filter by custom field
{
  "filterQuery": {
    "digital_assets": "(custom_fields.project_name:=Summer Campaign 2024)"
  }
}

Workflow

Tag Assignment Flow

1. User opens asset details

2. Click "Edit Tags" button

3. Tag editor opens with autocomplete

4. User types tag name

5. System shows matching tags

6. User selects existing tag OR creates new tag

7. Tag added to asset

8. Asset re-indexed in Typesense

9. Tag usage count updated

Custom Field Creation Flow

1. Admin navigates to Custom Fields settings

2. Clicks "Add Custom Field"

3. Enters field name, key, type

4. Configures validation and options

5. Saves field definition

6. Field appears in asset editor

7. Field added to Typesense schema

8. Existing assets can now use this field

Sample Data

Tag Examples

javascript
[
  {
    id: 1,
    workspace_id: 123,
    name: "marketing",
    color: "#FF5722",
    usage_count: 45
  },
  {
    id: 2,
    workspace_id: 123,
    name: "summer",
    color: "#FFC107",
    usage_count: 32
  },
  {
    id: 3,
    workspace_id: 123,
    name: "campaign",
    color: "#4CAF50",
    usage_count: 28
  }
]

Custom Field Examples

javascript
[
  {
    id: 1,
    workspace_id: 123,
    name: "Project Name",
    field_key: "project_name",
    field_type: "text",
    is_required: false,
    is_searchable: true,
    default_value: null,
    options: null
  },
  {
    id: 2,
    workspace_id: 123,
    name: "Client",
    field_key: "client",
    field_type: "select",
    is_required: true,
    is_searchable: true,
    default_value: null,
    options: ["Acme Corp", "Tech Solutions", "Global Inc"]
  },
  {
    id: 3,
    workspace_id: 123,
    name: "Campaign Date",
    field_key: "campaign_date",
    field_type: "date",
    is_required: false,
    is_searchable: true,
    default_value: null,
    options: null
  },
  {
    id: 4,
    workspace_id: 123,
    name: "Approved",
    field_key: "approved",
    field_type: "boolean",
    is_required: false,
    is_searchable: true,
    default_value: false,
    options: null
  }
]

Asset with Tags and Custom Fields

javascript
{
  id: 456,
  display_file_name: "summer-campaign-banner.jpg",
  file_type: "jpg",
  tags: [
    { id: 1, name: "marketing", color: "#FF5722" },
    { id: 2, name: "summer", color: "#FFC107" },
    { id: 3, name: "campaign", color: "#4CAF50" }
  ],
  custom_field_values: [
    {
      field_definition_id: 1,
      field_value: "Summer Campaign 2024",
      field_definition: {
        name: "Project Name",
        field_key: "project_name",
        field_type: "text"
      }
    },
    {
      field_definition_id: 2,
      field_value: "Acme Corp",
      field_definition: {
        name: "Client",
        field_key: "client",
        field_type: "select"
      }
    },
    {
      field_definition_id: 3,
      field_value: "2024-06-01",
      field_definition: {
        name: "Campaign Date",
        field_key: "campaign_date",
        field_type: "date"
      }
    },
    {
      field_definition_id: 4,
      field_value: "true",
      field_definition: {
        name: "Approved",
        field_key: "approved",
        field_type: "boolean"
      }
    }
  ]
}