Appearance
Tags & Custom Fields ​
Overview ​
Two metadata systems let workspace members enrich assets beyond standard fields.
- Tags — free-form string labels applied to one or more assets simultaneously. Tags are created implicitly on assignment; no pre-definition is required. Shared across the workspace and indexed in Typesense as a multi-value facet for filter-based search.
- Custom Fields — schema-defined metadata fields configured at the workspace level. The field schema is managed in workspace settings; values are filled per asset. Custom fields are also indexed in Typesense and surfaced as dynamic filter controls in the search filter panel.
- Tag UI —
asset/AddTags.vuedelegates touseAssetDetailTagsfor typeahead, suggestion, common-tag display, add, and remove. Bulk tagging is available viaDamBulkActionsBar. - Custom Field Schema UI —
dialogs/ManageCustomFields.vueanddialogs/CustomFieldDialog.vueprovide the schema editor for admins;asset/AssetCustomFieldsSection.vuerenders the read view per asset. - Mutations —
useCustomFieldsMutationswraps schema mutations (create, update, delete, toggle status) with cache patching; tag mutations are handled inline viauseTagsApi.
Architecture ​
Tags are applied to assets (not pre-defined) via useTagsApi.addTagsToFilesApi which calls digital-assets/add-tags-to-multiple-file. Suggested tags come from getSuggestedTagsApi and common tags (across selected assets) from getCommonTagListApi. Custom field definitions are fetched via useCustomFieldsQuery (TanStack reactive query keyed on workspace ID) and mutated via useCustomFieldsMutations. Field value updates are saved inline via useAssetsApi.updateAsset — custom field values are a key on the main asset object, not a separate endpoint. The schema management page is at /workspace-settings/custom-fields.
File Structure ​
Vue Component Files ​
app/components/asset/AddTags.vue— tag management component for the asset detail sidebar; wrapsTagsBoxand delegates logic touseAssetDetailTags; emitsupdate-tagsandremove-tagsto the parentapp/components/asset/AssetCustomFieldsSection.vue— read-only display of an asset's custom field values; shows a "Manage Custom Field" button for admins; handles multi-select and date display formatsapp/components/dialogs/AddTags/— sub-components for the tag input UI:TagsBox.vue— the main tag input widget with typeahead list, added-tags chips, and submit button- Supporting components for tag search results and chip rendering
app/components/dialogs/ManageCustomFields.vue— full schema management dialog: lists all field definitions with toggle, drag-and-drop reorder, edit, and delete; used in workspace settings and inline from the asset detail panelapp/components/dialogs/CustomFieldDialog.vue— add/edit dialog for a single custom field definition; collects field type (Text or Dropdown), label, placeholder, brand portal visibility (admin_only), and dropdown options (at least one)
Composable Files ​
app/composables/api/useTagsApi.ts— tag API operations:addTagsToFilesApi,deleteTagFromFilesApi,getSuggestedTagsApi,getCommonTagListApiapp/composables/api/useCustomFieldsApi.ts— custom field schema CRUD:listCustomFields,createCustomField,updateCustomField,deleteCustomField,toggleCustomFieldStatus,saveCustomFieldapp/composables/queries/useCustomFieldsQuery.ts— TanStack reactive query for the workspace custom field definition list; keyed oncustomFieldDefinitions.list(workspaceId)app/composables/queries/useCustomFieldsMutations.ts— TanStack mutations:deleteFieldMutation,toggleStatusMutation,saveFieldMutation
Page Files ​
app/pages/[workspace_id]/workspace-settings/custom-fields/index.vue— custom fields workspace settings page; displays the list of field definitions with add, edit, delete, and enable/disable controls
Route: /:workspace_id/workspace-settings/custom-fields
Type Files ​
app/types/custom-fields.ts—CustomFieldDefinition(id, field_type, field_label, placeholder_text, status, position, field_settings, required, workspace_id)app/types/asset-detail.ts—AssetTag(id, tag_name),AssetCustomField(id, field_label, field_type, value, placeholder_text, field_settings)
Page Details ​
Custom Fields Settings (workspace-settings/custom-fields/index.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/workspace-settings/custom-fields |
| Layout | general-settings-layout |
| Middleware | auth-check, check-workspace, check-workspace-access, can-access-general-settings, can-access-dam-module |
| Primary composable | useCustomFieldsPage() |
| RBAC | canManageCustomFields — resolves from useHelpers().canManageCustomFieldsSettings(workspace); gates the "Add Custom Field" button |
All field definitions load at once — no pagination or search. The Status column renders a toggle switch via the #cell-status slot; no columns are sortable. Field type cannot be changed after creation (CustomFieldDialog disables the type selector for existing fields).
List columns: Field Label (tooltip), Field Type (Single Select / Multi Select for Dropdown fields from field_settings.selection_mode, otherwise the raw field_type or -), Options (comma-joined values), Brand Portal? (Yes/No from admin_only), #Assets (assets_custom_fields_count, loaded per field by useCustomFieldCounts), Status (toggle switch)
Row menu: Edit, Delete (always both items)
Dialogs (both client-only): CustomFieldDialog (add/edit), ConfirmationDialog (delete)
AddTags Component ​
File: app/components/asset/AddTags.vue
Manages tag assignment on a single asset from the asset detail panel. Renders TagsBox with the tag state and handlers provided by useAssetDetailTags. Tags already on the asset appear as chips; clicking the input shows a typeahead dropdown of suggested and common tags; submitting adds the tag via useTagsApi.addTagsToFilesApi.
Features ​
- Typeahead search against
getSuggestedTagsApias the user types - Common tags section showing tags shared across the current asset
- Tag chip removal via
deleteTagFromFilesApi - RBAC gate —
canAddTagsprop disables the input when the user lacksadding_tagspermission - Emits tag mutations back to parent for immediate UI update without refetching the full asset
Props ​
typescript
interface Props {
tags: AssetTag[] // current tags on the asset
file: {
id: number
[key: string]: unknown
}
canAddTags?: boolean // default: false — RBAC gate
}Events ​
typescript
{
'update-tags': [tag: AssetTag] // new tag added successfully
'remove-tags': [index: number] // tag removed at this index
}Usage ​
vue
<template>
<AddTags
:tags="asset.tags"
:file="asset"
:can-add-tags="permissions.adding_tags"
@update-tags="onTagAdded"
@remove-tags="onTagRemoved"
/>
</template>
<script setup lang="ts">
import type { AssetTag } from '~/types/asset-detail'
const asset = ref(/* asset from detail query */)
const permissions = ref(/* RBAC permissions */)
const onTagAdded = (tag: AssetTag) => {
asset.value.tags.push(tag)
}
const onTagRemoved = (index: number) => {
asset.value.tags.splice(index, 1)
}
</script>AssetCustomFieldsSection Component ​
File: app/components/asset/AssetCustomFieldsSection.vue
Read-only view of an asset's custom field values. Field definitions are rendered in order from customFields prop. Multi-select fields (detected by field_settings.selection_mode === 'multi') show values as v-chip components. Date fields get a form-calendar-disabled style. An empty state is shown when there are no fields defined. When canManage is true, a "Manage Custom Field" button emits open-manage-dialog to open the schema editor.
Features ​
- Detects multi-select mode from
field_settings.selection_mode - Splits comma-separated or array values for multi-select display
- Falls back to
placeholder_textwhenvalueis empty - Empty state with
EmptyStateIconwhen no fields defined
Props ​
typescript
interface Props {
isLoading?: boolean // default: false
customFields?: AssetCustomField[] // default: []
canManage?: boolean // default: false — shows "Manage Custom Field" button
}Events ​
typescript
{
'open-manage-dialog': [] // emitted when "Manage Custom Field" button clicked
}Usage ​
vue
<template>
<AssetCustomFieldsSection
:custom-fields="asset.custom_fields"
:can-manage="permissions.manage_custom_fields"
@open-manage-dialog="showManageDialog = true"
/>
<ManageCustomFields v-if="showManageDialog" @close="showManageDialog = false" />
</template>
<script setup lang="ts">
const showManageDialog = ref(false)
</script>useTagsApi Composable ​
File: app/composables/api/useTagsApi.ts
All tag API operations. Every method is wrapped with useApiRequestState().track().
Methods ​
typescript
// Add one or more tags to one or more assets
addTagsToFilesApi(payload: AddTagsPayload): Promise<ApiResponse>
// AddTagsPayload: { workspace_id, digital_assets_ids: (number|string)[], tags: string[] }
// Remove a specific tag from one or more assets
deleteTagFromFilesApi(payload: DeleteTagPayload): Promise<ApiResponse>
// DeleteTagPayload: { workspace_id, digital_assets_ids: { id }[], tag_name?, tag_id? }
// Typeahead suggestions based on partial text
getSuggestedTagsApi(payload: GetSuggestedTagsPayload): Promise<TagApiResponse>
// GetSuggestedTagsPayload: { workspace_id, search_text, asset_ids }
// Tags that all selected assets have in common (for bulk display)
getCommonTagListApi(payload: GetCommonTagListPayload): Promise<TagApiResponse>
// GetCommonTagListPayload: { workspace_id, asset_ids }Usage ​
vue
<script setup lang="ts">
const { addTagsToFilesApi, deleteTagFromFilesApi, getSuggestedTagsApi, isLoading } = useTagsApi()
const route = useRoute()
const { getWorkspaceId } = useHelpers()
const workspaceId = computed(() => (route.params.workspace_id as string) || getWorkspaceId())
const addTag = async (tagName: string, assetId: number) => {
await addTagsToFilesApi({
workspace_id: workspaceId.value,
digital_assets_ids: [assetId],
tags: [tagName],
})
}
const removeTag = async (tagId: number, assetId: number) => {
await deleteTagFromFilesApi({
workspace_id: workspaceId.value,
digital_assets_ids: [{ id: assetId }],
tag_id: tagId,
})
}
const suggestTags = async (text: string, assetIds: number[]) => {
const res = await getSuggestedTagsApi({
workspace_id: workspaceId.value,
search_text: text,
asset_ids: assetIds,
})
return res.data
}
</script>useCustomFieldsApi Composable ​
File: app/composables/api/useCustomFieldsApi.ts
Custom field schema CRUD. workspaceId is resolved from route.params.workspace_id with fallback to useHelpers().getWorkspaceId(). All methods are wrapped with useApiRequestState().track().
Methods ​
typescript
// Fetch all custom field definitions for the workspace
listCustomFields(): Promise<CustomFieldDefinition[]>
// Create a new field definition
createCustomField(payload: Omit<CustomFieldDefinition, 'id' | 'created_at' | 'updated_at'>): Promise<CustomFieldDefinition>
// Update an existing field definition
updateCustomField(payload: Partial<CustomFieldDefinition> & { id: number }): Promise<void>
// Delete a field definition
deleteCustomField(id: number): Promise<void>
// Toggle a field's active/inactive status
toggleCustomFieldStatus(payload: { id: number; workspace_id: string | number; status: number }): Promise<void>
// Create or update — returns { message, data } for snackbar use
saveCustomField(payload: Record<string, unknown>, isEdit: boolean): Promise<{ message?: string; data?: CustomFieldDefinition }>useCustomFieldsQuery Composable ​
File: app/composables/queries/useCustomFieldsQuery.ts
TanStack reactive query for the workspace's custom field definitions list. Enabled when workspaceId is non-empty. Query key: queryKeys.customFieldDefinitions.list(workspaceId).
Returns ​
typescript
{ fieldsQuery: UseQueryReturnType<CustomFieldDefinition[], Error> }Usage ​
vue
<script setup lang="ts">
const { fieldsQuery } = useCustomFieldsQuery()
const customFields = computed(() => fieldsQuery.data.value ?? [])
const isLoading = computed(() => fieldsQuery.isLoading.value)
</script>useCustomFieldsMutations Composable ​
File: app/composables/queries/useCustomFieldsMutations.ts
TanStack mutations for schema management. All mutations invalidate queryKeys.customFieldDefinitions.list(workspaceId) on success. toggleStatusMutation applies an optimistic cache patch directly (single-field change) before the safety-net invalidation.
Methods ​
typescript
// Delete a field definition and invalidate the list
deleteFieldMutation: UseMutationReturnType<void, Error, number>
// Toggle status with optimistic cache patch
toggleStatusMutation: UseMutationReturnType<void, Error, { id: number; workspace_id: string | number; status: number }>
// Save (create or edit) a field definition and invalidate the list
saveFieldMutation: UseMutationReturnType<{ message?: string; data?: CustomFieldDefinition }, Error, { payload: Record<string, unknown>; isEdit: boolean }>Usage ​
vue
<script setup lang="ts">
const { deleteFieldMutation, toggleStatusMutation, saveFieldMutation } = useCustomFieldsMutations()
const onDelete = (id: number) => deleteFieldMutation.mutate(id)
const onToggle = (field: CustomFieldDefinition) =>
toggleStatusMutation.mutate({
id: field.id,
workspace_id: getWorkspaceId(),
status: field.status === 1 ? 0 : 1,
})
const onSave = async (payload: Record<string, unknown>, isEdit: boolean) => {
const res = await saveFieldMutation.mutateAsync({ payload, isEdit })
showSnackbar(res.message ?? 'Saved')
}
</script>Custom Field Types ​
CustomFieldDialog creates only two types (fieldTypes = ['Text', 'Dropdown']). The value editor (ManageCustomFields.vue) also renders Year and Date fields that already exist on the workspace; fields with status !== 1 are not editable there.
| Type | Created in dialog | Value editor | field_type value | Selection mode |
|---|---|---|---|---|
| Text | yes | text input | Text | — |
| Dropdown (single) | yes | single select | Dropdown | single (default when field_settings is missing) |
| Dropdown (multi) | yes | multi select | Dropdown | multi |
| Year | no | autocomplete, current year + 20 down to 1900 | Year | — |
| Date | no | date picker | Date | — |
Field type and selection mode are set at creation and cannot be changed after saving (field_type select is disabled in CustomFieldDialog for existing fields).
Tag & Custom Field Workflows ​
Tagging Assets (Single Asset) ​
Bulk Tagging Assets ​
Custom Field Schema Management ​
Editing Custom Field Values on an Asset ​
API Integration ​
Add Tags to Files ​
Endpoint: POST digital-assets/add-tags-to-multiple-file
Request:
json
{
"workspace_id": 5,
"digital_assets_ids": [101, 102],
"tags": ["hero", "summer-2026"]
}Response:
json
{ "data": { "added": 2 }, "message": "Tags added successfully" }Remove Tag from Files ​
Endpoint: POST digital-assets/delete-tag-from-multiple-file
Request:
json
{
"workspace_id": 5,
"digital_assets_ids": [{ "id": 101 }],
"tag_name": "hero"
}Get Suggested Tags ​
Endpoint: POST digital-assets/get-suggest-tag-list
Request:
json
{
"workspace_id": 5,
"search_text": "sum",
"asset_ids": [101]
}Response:
json
{ "data": [{ "id": 14, "tag_name": "summer-2026" }, { "id": 15, "tag_name": "summary" }] }List Custom Field Definitions ​
Endpoint: GET digital-assets/custom-field/list
Query Parameters: workspace_id
Response:
json
{
"data": [
{
"id": 3,
"field_type": "Text",
"field_label": "Project Name",
"placeholder_text": "Enter project name",
"status": 1,
"position": 1,
"required": 0,
"field_settings": null
},
{
"id": 4,
"field_type": "Dropdown",
"field_label": "Region",
"placeholder_text": "Select region",
"status": 1,
"position": 2,
"required": 0,
"field_settings": "{\"selection_mode\":\"single\",\"options\":[\"APAC\",\"EMEA\",\"NA\"]}"
}
]
}Create Custom Field ​
Endpoint: POST digital-assets/custom-field/add
Request:
json
{
"workspace_id": 5,
"field_type": "Text",
"field_label": "Campaign ID",
"placeholder_text": "Enter campaign ID",
"required": 0
}Response:
json
{ "data": { "id": 9, "field_label": "Campaign ID" }, "message": "Custom field added" }Toggle Custom Field Status ​
Endpoint: POST digital-assets/custom-field/update-with-field
Request:
json
{
"id": 9,
"field_name": "status",
"field_value": 0,
"workspace_id": 5
}Component Integration ​
Custom Fields Settings Page (pages/[workspace_id]/workspace-settings/custom-fields/index.vue) ​
The workspace-level custom field schema is managed through a facade composable. The page adds its own RBAC check because the permission helper requires the workspace object resolved from the current route.
vue
<template>
<div>
<v-btn v-if="canManageCustomFields" @click="addField">Add Custom Field</v-btn>
<!-- Loading state -->
<DamListViewSkeleton v-if="loading || contentLoading" :columns="customFieldColumns" />
<!-- Field list -->
<DamSettingsListView
v-else-if="searchResult.length"
:items="customFieldItems"
:columns="customFieldColumns"
:resolve-menu-items="() => customFieldMenu"
@menu-click="handleCustomFieldMenuClick"
>
<!-- Status column uses a toggle switch -->
<template #cell-status="{ item }">
<v-switch :model-value="!!item._switchOn" @update:model-value="toggleFieldStatus(item)" />
</template>
</DamSettingsListView>
<!-- Dialogs -->
<CustomFieldDialog :dialog="fieldDialog" :value="fieldToEdit" @save="handleSaveField" />
<ConfirmationDialog :dialog="deleteDialog" @confirm="confirmDeleteField" @cancel="closeDeleteDialog" />
</div>
</template>
<script setup lang="ts">
// Permission check: canManageCustomFieldsSettings is admin-only
const { user: authUser } = useAuth()
const route = useRoute()
const { canManageCustomFieldsSettings } = useHelpers()
const canManageCustomFields = computed<boolean>(() => {
const workspaceId = route.params.workspace_id as string
const workspace = (authUser.value?.accessibleWorkspaces as { id: number | string }[] | undefined)?.find(
(w) => parseInt(String(w.id)) === parseInt(workspaceId),
)
return canManageCustomFieldsSettings(workspace as Parameters<typeof canManageCustomFieldsSettings>[0])
})
// useCustomFieldsPage is the facade composable — it wraps useCustomFieldsQuery
// and useCustomFieldsMutations, and owns all dialog and action state.
const {
contentLoading,
loading,
searchResult,
fieldDialog,
fieldToEdit,
deleteDialog,
fieldToDelete,
confirmDeleteField,
closeDeleteDialog,
toggleFieldStatus,
addField,
editField,
handleSaveField,
deleteField,
} = useCustomFieldsPage()
</script>Tags in the Asset Detail Panel ​
Tags are not managed through a page-level facade. The AddTags component receives the current asset's tags and delegates all API calls to useAssetDetailTags internally. The asset detail page binds it like this:
vue
<AddTags
:tags="asset.tags"
:file="asset"
:can-add-tags="canAddTagsComputed"
@update-tags="(tag) => asset.tags.push(tag)"
@remove-tags="(index) => asset.tags.splice(index, 1)"
/>canAddTagsComputed is resolved from useHelpers().canAddTags(currentWorkspace) in the asset detail page composable (useAssetDetail.ts).