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