Appearance
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
- Tags System: Simple key-value tags for quick categorization
- Custom Fields System: Structured fields with types, validation, and default values
- Field Templates: Reusable field definitions for consistent metadata
- 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 updatedCustom 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 fieldSample 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"
}
}
]
}Related Documentation
- Global Search - Search integration
- Typesense Integration - Search backend
- Advanced Filters - Filtering system