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, reorder, 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, label, placeholder, required flag, and dropdown options
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,reorderCustomFields,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,reorderFieldsMutation,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, reorder, 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 (Text/Textarea/Number/Date/Single Select/Multi Select/URL), Options (comma-joined values), Brand Portal? (Yes/No from admin_only), #Assets (assets_custom_fields_count), 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>
// Reorder field definitions by position
reorderCustomFields(order: { id: number; position: 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>
// Reorder fields by position and invalidate the list
reorderFieldsMutation: UseMutationReturnType<void, Error, { id: number; position: 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 ​
| Type | Description | field_type value | Selection mode |
|---|---|---|---|
| Text | Single-line string | Text | — |
| Textarea | Multi-line string | Textarea | — |
| Number | Numeric value | Number | — |
| Date | Date picker | Date | — |
| Dropdown (single) | Single value from options | Dropdown | single |
| Dropdown (multi) | Multiple values from options | Dropdown | multi |
| URL | URL with validation | URL | — |
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) ​
1. User opens asset detail panel
Component: asset/AddTags.vue
- Existing tags displayed as chips
↓
2. User types in the tag input
Composable: useAssetDetailTags → useTagsApi.getSuggestedTagsApi
Endpoint: POST digital-assets/get-suggest-tag-list
- Typeahead dropdown shows matching existing workspace tags
↓
3. User selects or enters a tag and submits
Composable: useTagsApi.addTagsToFilesApi
Endpoint: POST digital-assets/add-tags-to-multiple-file
↓
4. On success
- Emit 'update-tags' with the new AssetTag
- Parent updates asset.tags immediately
- Typesense index updated asynchronously by the backendBulk Tagging Assets ​
1. User selects multiple assets in the DAM browser or search results
Component: DamBulkActionsBar
↓
2. User clicks "Tag" bulk action
- Common tags across selection fetched via getCommonTagListApi
↓
3. User adds or removes tags in the bulk tag dialog
Composable: useTagsApi.addTagsToFilesApi / deleteTagFromFilesApi
- Both accept arrays of asset IDs
↓
4. On success
- Asset list refreshed to reflect new tag countsCustom Field Schema Management ​
1. Workspace admin navigates to workspace settings
Route: /:workspace_id/workspace-settings/custom-fields
Page: pages/[workspace_id]/workspace-settings/custom-fields/index.vue
↓
2. Admin clicks "Add Custom Field"
Component: dialogs/CustomFieldDialog.vue
- Selects field type (Text, Textarea, Number, Date, Dropdown, URL)
- For Dropdown: picks single or multi selection mode, adds options
- Enters field label and optional placeholder text
- Marks as required if needed
↓
3. Dialog saves
Composable: useCustomFieldsMutations.saveFieldMutation
→ useCustomFieldsApi.saveCustomField(payload, false)
Endpoint: POST digital-assets/custom-field/add
↓
4. List invalidated; new field appears in schema list
↓
5. Admin can reorder fields by dragging
Composable: useCustomFieldsMutations.reorderFieldsMutation
Endpoint: POST digital-assets/custom-field/reorder
↓
6. Admin can toggle a field active/inactive
Composable: useCustomFieldsMutations.toggleStatusMutation
- Optimistic cache patch: list updated immediately
Endpoint: POST digital-assets/custom-field/update-with-fieldEditing Custom Field Values on an Asset ​
1. User opens asset detail panel
Component: asset/AssetCustomFieldsSection.vue
- Reads field schema from useCustomFieldsQuery (cached)
- Displays current values per field
↓
2. User clicks "Manage Custom Field" (or inline edit trigger)
- ManageCustomFields dialog opens OR inline edit activates
↓
3. User fills in / updates values
Composable: useAssetsApi.updateAsset
- Patches the asset's custom_fields object
Endpoint: POST digital-assets/update (or similar)
↓
4. On success
- Asset detail refreshed
- Typesense index updated asynchronouslyAPI 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).