Skip to content

Tags & Custom Fields ​

Overview ​

Two metadata systems let workspace members enrich assets beyond standard fields.

  1. 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.
  2. 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.
  3. Tag UI — asset/AddTags.vue delegates to useAssetDetailTags for typeahead, suggestion, common-tag display, add, and remove. Bulk tagging is available via DamBulkActionsBar.
  4. Custom Field Schema UI — dialogs/ManageCustomFields.vue and dialogs/CustomFieldDialog.vue provide the schema editor for admins; asset/AssetCustomFieldsSection.vue renders the read view per asset.
  5. Mutations — useCustomFieldsMutations wraps schema mutations (create, update, delete, reorder, toggle status) with cache patching; tag mutations are handled inline via useTagsApi.

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; wraps TagsBox and delegates logic to useAssetDetailTags; emits update-tags and remove-tags to the parent
  • app/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 formats
  • app/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 panel
  • app/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, getCommonTagListApi
  • app/composables/api/useCustomFieldsApi.ts — custom field schema CRUD: listCustomFields, createCustomField, updateCustomField, deleteCustomField, reorderCustomFields, toggleCustomFieldStatus, saveCustomField
  • app/composables/queries/useCustomFieldsQuery.ts — TanStack reactive query for the workspace custom field definition list; keyed on customFieldDefinitions.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) ​

FieldValue
Route/:workspace_id/workspace-settings/custom-fields
Layoutgeneral-settings-layout
Middlewareauth-check, check-workspace, check-workspace-access, can-access-general-settings, can-access-dam-module
Primary composableuseCustomFieldsPage()
RBACcanManageCustomFields — 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 getSuggestedTagsApi as the user types
  • Common tags section showing tags shared across the current asset
  • Tag chip removal via deleteTagFromFilesApi
  • RBAC gate — canAddTags prop disables the input when the user lacks adding_tags permission
  • 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_text when value is empty
  • Empty state with EmptyStateIcon when 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 ​

TypeDescriptionfield_type valueSelection mode
TextSingle-line stringText—
TextareaMulti-line stringTextarea—
NumberNumeric valueNumber—
DateDate pickerDate—
Dropdown (single)Single value from optionsDropdownsingle
Dropdown (multi)Multiple values from optionsDropdownmulti
URLURL with validationURL—

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 backend

Bulk 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 counts

Custom 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-field

Editing 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 asynchronously

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).