Skip to content

Folder Management ​

Overview ​

Folders organize assets into a named hierarchy and are always visible in the left panel of the DAM shell. The folder system is composed of four composables with distinct responsibilities:

  1. useFolderApi — raw API layer covering the full CRUD surface: create, rename, delete, fetch flat list, fetch sub-folders, fetch folder contents, move files, copy files, and update asset fields within a folder.
  2. useFoldersQuery — TanStack query for the flat top-level folder list. Normalizes each FolderResponse into a typed Folder with sensible defaults. Also queries the workspace overview counters alongside the folder list.
  3. useFolderMutations — TanStack mutations for delete and rename. Rename uses an optimistic cache patch (patchFolderName) that updates the folder list and the dashboard recent-folders widget immediately, without waiting for a refetch.
  4. useFolderContentsQuery — per-folder asset list query. Uses queryClient.fetchQuery rather than useQuery so parents control when a fetch fires; exposes invalidateContents for targeted post-mutation cache busting.

The dam.ts Pinia store (useDamStore) holds the legacy folder list (folderList), loading flags (foldersLoading), and the currently open folder (currentFolder). New code should prefer TanStack Query via useFoldersQuery over the store's folder state.

Architecture ​

The folder tree in the left panel is driven by useDamStore.folderList. Sub-folder expansion triggers useFolderApi.getSubFolder to lazy-load children. The store's currentFolder is set on navigation so breadcrumbs and the folder detail header always reflect the open folder without an additional API call. CRUD operations go through useFolderMutations (for rename and delete) or direct useFolderApi calls (for create, move, copy) followed by manual cache invalidation via useFoldersQuery.invalidateFoldersList. Folder creation uses two different endpoints depending on depth: digital-assets/category/add for root folders, digital-assets/category/add-sub-category for sub-folders. The addFolder method in useFolderApi selects the correct endpoint based on whether body.category_id is non-zero.

File Structure ​

Vue Component Files ​

  • components/dam/DamLibraryListView.vue — left-panel folder tree; renders the hierarchical folder list with expand/collapse, context menu, and drag-and-drop
  • components/dam/DamDetailListView.vue — folder detail view; lists assets and sub-folders within a selected folder
  • components/dam/DamListHeader.vue — header bar above the folder contents; shows folder name, sort controls, view-toggle, and upload button
  • components/dam/DamDisplayPanel.vue — right-side detail panel; shows folder or asset metadata, permissions, and sharing options
  • components/dam/DamItemMenu.vue — context menu for a folder item: rename, new sub-folder, move, copy, delete, share
  • components/dam/DamBulkActionsBar.vue — bulk-action toolbar for multi-select folder and asset operations
  • components/dam/SearchBreadCrumbs.vue — breadcrumb trail showing the path from workspace root to the current folder
  • components/dam/DamListViewSkeleton.vue — skeleton loading state for the folder list

Composable Files ​

  • composables/api/useFolderApi.ts — full folder API surface; all methods are track()-wrapped for loading/error state
  • composables/queries/useFoldersQuery.ts — TanStack query for the flat top-level folder list plus workspace overview
  • composables/queries/useFolderMutations.ts — TanStack mutations for rename (with optimistic patch) and delete (with trash and folder cache invalidation)
  • composables/queries/useFolderContentsQuery.ts — per-folder content query via fetchQuery; exposes targeted invalidateContents

Page Files ​

  • pages/[workspace_id]/dam/folders/index.vue — workspace folder list; grid/list toggle, create folder button, inline sort, infinite scroll, per-row context menu
  • pages/[workspace_id]/dam/folders/[_id]/index.vue — folder detail; displays sub-folders and assets, in-folder Typesense search, filter chips, bulk-action bar, marquee selection, grid/list view with thumbnail-size control

Store Files ​

  • stores/dam.ts — Pinia store (useDamStore); holds folderList, foldersLoading, currentFolder, workspace storage, DAM instance settings

Type Files ​

  • types/folder.ts — exports Folder, FolderResponse, FolderParams, DeleteFolderParams, RenameFolderData, MoveFilesPayload, AddFolderBody, CopyFileBody, MoveMultipleBody

useFolderApi ​

Raw API composable for all folder operations. The workspace ID is resolved from useWorkspaceId() internally. All methods are wrapped in track() from useApiRequestState.

Methods ​

javascript
{
  getRecentsFolders: () => Promise<Folder[]>,
  createFolder: (name: string) => Promise<ApiResponse<Folder>>,
  deleteFolder: (params: DeleteFolderParams) => Promise<ApiResponse>,
  fetchFolders: (params: FolderParams) => Promise<ApiResponse<Folder[]>>,
  fetchAssetsWithThrottle: (folderId: number) => Promise<ApiResponse<{ total_assets: number; total_category: number }>>,
  renameFolder: (folderId: number, data: RenameFolderData) => Promise<ApiResponse<Folder>>,
  fetchFolderCount: (folderId: number, workspace_id: string | number) => Promise<ApiResponse<{ total_assets: number; total_category: number }>>,
  deleteFolderApi: (folderId: number, workspace_id: string | number) => Promise<ApiResponse>,
  fetchFolderContent: (params: FolderParams) => Promise<ApiResponse<Folder[]>>,
  fetchAssetCount: (folderId: number, workspace_id: string | number) => Promise<ApiResponse<{ total_assets: number; total_category: number }>>,
  renameCategory: (id: number, data: RenameFolderData) => Promise<ApiResponse<Folder>>,
  moveFilesToCategory: (payload: MoveFilesPayload) => Promise<ApiResponse>,
  getSubFolder: (workspace_id: string | number, category_id: number) => Promise<ApiResponse<Folder[]>>,
  addFolder: (body: AddFolderBody) => Promise<ApiResponse<Folder>>,
  copyFile: (body: CopyFileBody) => Promise<ApiResponse>,
  moveMultiple: (body: MoveMultipleBody) => Promise<ApiResponse>,
  updateAssetField: (data: { workspace_id: string | number; digital_assets_id: number; field_name: string; field_value: string }) => Promise<ApiResponse>,
  updateCustomFields: (data: { workspace_id: string | number; updated_data: unknown[]; asset_ids: number[] }) => Promise<ApiResponse>,
  searchCollection: (data: { request: { collection: string; id: number }; workspace_id: string | number }) => Promise<ApiResponse>,
  isLoading: Ref<boolean>,
  error: Ref<Error | null>
}

Endpoint Selection for addFolder ​

javascript
// Root folder: category_id === 0
POST digital-assets/category/add

// Sub-folder: category_id > 0
POST digital-assets/category/add-sub-category  (body.sub_category_id = 0)

Usage Example ​

vue
<script setup lang="ts">
const { addFolder, renameFolder, deleteFolder, getSubFolder, isLoading } = useFolderApi()

async function createSubFolder(parentId: number, name: string) {
  const result = await addFolder({
    workspace_id: workspaceId,
    folder_name: name,
    category_id: parentId,
  })
  return result.data
}

async function loadChildren(folderId: number) {
  const result = await getSubFolder(workspaceId, folderId)
  return result.data
}
</script>

useFoldersQuery ​

TanStack query for the workspace folder list. Normalizes raw FolderResponse objects from the API into typed Folder objects with isLoading: true as initial state — a per-folder loading flag used by the folder tree while fetchAssetsWithThrottle populates accurate counts asynchronously.

Options ​

javascript
{
  sortValue: Ref<string>,  // sort field: 'folder_name' | 'modified_at' | 'created_at'
  sortBy: Ref<string>      // direction: 'ASC' | 'DESC'
}

Return Value ​

javascript
{
  foldersQuery: UseQueryReturnType<Folder[]>,
  overviewQuery: UseQueryReturnType<OverviewData | undefined>,
  invalidateFoldersList: () => Promise<void>,
  refetchOverview: () => void
}

Folder Normalization ​

Each raw FolderResponse from the API is mapped to a Folder with these computed defaults:

javascript
{
  id: obj.id,
  folder_name: obj.folder_name,
  description: obj.description,
  total_assets: obj.total_assets ?? 0,
  total_category: obj.total_category ?? 0,
  visibility: obj.visibility,
  instance_visibility: obj.instance_visibility,
  permission: obj.permission ?? null,
  modified_at: obj.modified_at,
  created_at: obj.created_at,
  is_selected: false,
  is_downloading: false,
  isLoading: true,           // placeholder until fetchAssetsWithThrottle resolves
  parent_id: obj.parent_id ?? null,
  parent_folder_id: obj.parent_folder_id,
  sub_category_exist: obj.sub_category_exist ?? false,
  sub_category_count: obj.sub_category_count ?? 0
}

Usage Example ​

vue
<script setup lang="ts">
const sortValue = ref('folder_name')
const sortBy = ref('ASC')

const {
  foldersQuery,
  overviewQuery,
  invalidateFoldersList,
} = useFoldersQuery({ sortValue, sortBy })
</script>

<template>
  <div>
    <p>{{ overviewQuery.data.value?.total_folders }} folders total</p>
    <div v-if="foldersQuery.isPending.value">
      <DamListViewSkeleton />
    </div>
    <div v-else class="folder-list">
      <div
        v-for="folder in foldersQuery.data.value"
        :key="folder.id"
        class="folder-row"
      >
        {{ folder.folder_name }}
      </div>
    </div>
  </div>
</template>

useFolderMutations ​

TanStack mutations for folder delete and rename. Rename applies an optimistic cache patch that updates the folder list and dashboard recent-folders widget synchronously before any refetch.

Mutations ​

javascript
{
  deleteFoldersMutation: UseMutationReturnType<ApiResponse, DeleteFolderParams>,
  renameFolderMutation: UseMutationReturnType<ApiResponse<Folder>, RenameFolderVariables>,
  patchFolderName: (folderId: number, data: RenameFolderData) => void
}

Optimistic Rename Patch ​

patchFolderName synchronously updates two cache entries without triggering a network refetch:

  1. queryKeys.folders.list(workspaceId) — the folder list page
  2. queryKeys.dashboard.recentFolders(workspaceId) — the dashboard recent-folders widget

After patching it marks both keys as stale (refetchType: 'none'), guaranteeing fresh data on the next window-focus or page mount even if the cache entry was not present in this session.

Cache Invalidation on Delete ​

javascript
Promise.all([
  invalidateFolderCaches(),      // folders.all + dashboard.recentFolders
  queryClient.invalidateQueries({ queryKey: queryKeys.trash.all }),
])

The trash cache is invalidated because a deleted folder moves into trash; without this, a user opening the trash page within the 30-second global staleTime would see a stale list.

Usage Example ​

vue
<script setup lang="ts">
import type { DeleteFolderParams, RenameFolderData } from '~/types/folder'

const { deleteFoldersMutation, renameFolderMutation } = useFolderMutations()

function onRename(folderId: number, newName: string) {
  renameFolderMutation.mutate({
    folderId,
    data: { folder_name: newName },
  })
}

function onDelete(params: DeleteFolderParams) {
  deleteFoldersMutation.mutate(params)
}
</script>

<template>
  <div class="folder-actions">
    <v-btn
      :loading="renameFolderMutation.isPending.value"
      @click="onRename(folder.id, newName)"
    >
      Rename
    </v-btn>
    <v-btn
      color="error"
      :loading="deleteFoldersMutation.isPending.value"
      @click="onDelete({ category_id: folder.id })"
    >
      Delete
    </v-btn>
  </div>
</template>

useFolderContentsQuery ​

Per-folder asset/sub-folder content query. Uses fetchQuery so the parent controls when a fetch fires (e.g., on expand or navigation) rather than triggering automatically on mount. Cache keys include workspace ID, folder ID, sort field, sort direction, and page number so each combination is stored independently.

Methods ​

javascript
{
  fetchPage: (params: FolderParams) => Promise<ApiResponse<Folder[]>>,
  invalidateContents: (workspaceId: number | string, folderId: number | string) => Promise<void>
}

invalidateContents prefix-matches every sort/page combination cached for a folder via the queryKeys.folders.contents(workspaceId, folderId) prefix, so a single call busts all paginated cache entries for that folder.

Usage Example ​

vue
<script setup lang="ts">
const { fetchPage, invalidateContents } = useFolderContentsQuery()
const workspaceId = getWorkspaceId()

async function loadFolderPage(folderId: number, page: number) {
  const response = await fetchPage({
    workspace_id: workspaceId,
    category_id: folderId,
    sort_value: 'modified_at',
    sort_by: 'DESC',
    page,
  })
  return response.data
}

async function afterMoveToFolder(folderId: number) {
  await invalidateContents(workspaceId, folderId)
}
</script>

Workflows ​

Create Folder Flow ​

User clicks "New Folder" in the folder panel
  → Dialog opens with folder name input
  → User submits
  → useFolderApi.addFolder({ workspace_id, folder_name, category_id: 0 })
      → POST digital-assets/category/add  (root folder)
      OR
      → POST digital-assets/category/add-sub-category  (sub-folder)
  → On success:
      → invalidateFoldersList()
      → Folder list refetches and new folder appears in tree

Rename Folder Flow ​

User double-clicks folder name (inline edit) or selects Rename from context menu
  → Inline text field activates
  → User edits name and presses Enter or clicks away
  → renameFolderMutation.mutate({ folderId, data: { folder_name: newName } })
      → POST digital-assets/category/rename/:folderId
  → onSuccess: patchFolderName(folderId, data)
      → Synchronously update queryKeys.folders.list cache
      → Synchronously update queryKeys.dashboard.recentFolders cache
      → Mark both as stale (refetchType: 'none')
  → UI updates immediately, no loading flash

Move Files to Folder Flow ​

User selects assets → drags to a folder OR uses Move dialog
  → useFolderApi.moveMultiple({ file_ids, folder_ids, target_category_id })
      → POST digital-assets/multiple-file-folder-move
  → On success:
      → invalidateContents(workspaceId, sourceFolderId)
      → invalidateContents(workspaceId, targetFolderId)
  → Files disappear from source folder view
  → Files appear in target folder on next load

Sub-Folder Lazy Load Flow ​

User expands a folder node in the tree
  → useFolderApi.getSubFolder(workspace_id, category_id)
      → GET digital-assets/category/sub-category-list?workspace_id=...&category_id=...
  → Sub-folders returned as Folder[]
  → Tree node expands and renders children
  → Each child's asset count fetched via fetchAssetsWithThrottle()
      → POST digital-assets/category/get-count/:folderId
      → Updates folder.isLoading → false and folder.total_assets

Delete Folder Flow ​

User selects Delete from folder context menu
  → ConfirmDialog shown
  → User confirms
  → deleteFoldersMutation.mutate({ category_id: folder.id })
      → POST digital-assets/category/delete-category-with-files
      → Backend soft-deletes folder and all contents
  → onSuccess:
      → invalidate folders.all
      → invalidate dashboard.recentFolders
      → invalidate trash.all
  → Folder disappears from tree
  → Folder and its assets appear in trash

API Integration ​

Endpoints ​

EndpointMethodDescription
digital-assets/category/all-category-listGETFetch all top-level folders for the workspace
digital-assets/category/addPOSTCreate a new root folder
digital-assets/category/add-sub-categoryPOSTCreate a sub-folder inside an existing folder
digital-assets/category/rename/:idPOSTRename a folder
digital-assets/category/delete-category-with-filesPOSTSoft-delete a folder and all its contents
digital-assets/folder/delete/:idDELETEHard-delete a folder by ID
digital-assets/category/sub-category-listGETList sub-folders of a folder
digital-assets/category/view-files-with-categoryGETList assets and sub-folders within a folder
digital-assets/category/get-count/:idPOSTGet total asset and sub-folder counts for a folder
digital-assets/multiple-file-move-to-folderPOSTMove multiple files to a folder
digital-assets/multiple-file-copy-to-multiple-folderPOSTCopy files to multiple target folders
digital-assets/multiple-file-folder-movePOSTMove files and folders to a target folder
digital-assets/new-dashboard/recently-foldersGETRecently accessed folders (dashboard widget)

Create Root Folder Request ​

json
{
  "workspace_id": "123",
  "folder_name": "Campaign Assets"
}

Create Root Folder Response ​

json
{
  "data": {
    "id": 215,
    "folder_name": "Campaign Assets",
    "description": null,
    "total_assets": 0,
    "total_category": 0,
    "visibility": "workspace",
    "created_at": "2025-08-10T12:00:00Z",
    "modified_at": "2025-08-10T12:00:00Z"
  }
}

Rename Folder Request ​

json
{
  "folder_name": "Q3 Campaign Assets",
  "description": "All assets for Q3 campaigns"
}

Fetch Folder List Response (normalized) ​

json
{
  "data": [
    {
      "id": 215,
      "folder_name": "Q3 Campaign Assets",
      "description": "All assets for Q3 campaigns",
      "total_assets": 142,
      "total_category": 5,
      "visibility": "workspace",
      "instance_visibility": "workspace",
      "permission": null,
      "sub_category_exist": true,
      "sub_category_count": 5,
      "parent_id": null,
      "modified_at": "2025-08-10T12:00:00Z",
      "created_at": "2025-08-10T12:00:00Z"
    }
  ]
}

Page Details ​

Folder List — pages/[workspace_id]/dam/folders/index.vue ​

Route: /:workspace_id/dam/folders
Layout: collage-layout
Middleware: auth-check, check-workspace, can-access-dam-module, check-if-suspended

All data fetching, selection state, sort, and dialog control is delegated to useFolders(). Permission checks and per-row context menu items come from useFolderMenu().

Features ​

  • "New Folder" button — shown only when canCreateFoldersComputed is true; calls addFolderDialog() which opens a CreateOrRenameDialog in create mode.
  • View mode toggle — ViewModeSwitcher switches between grid (FolderCard) and list (DamLibraryListView).
  • Grid view — FolderCard per folder; asset/subfolder counts are hidden while folder.isLoading is true (populated by fetchAssetsWithThrottle running in the background). The "Uncategorised" folder (is_uncategorised: true) is distinguished by its own display string and routes to /:workspace_id/dam/uploaded on click.
  • List view — DamLibraryListView with five columns: Name, Description, Date Modified, Date Added, and a # summary column that formats as "X Subfolder(s), Y Asset(s)" or "Empty Folder".
  • Infinite scroll — handleScroll listens on the scrollContainer ref; additional pages are loaded as the user scrolls. While loading more, CardSkeleton rows appear below the existing list.
  • Empty state — "You don't have any folders yet. Let's fix that." shown in both grid and list modes; includes a "Create your first folder" button gated by canCreateFoldersComputed.
  • Sort — list view sort is handled by onFolderSortChange, which sets page_sort_by and calls sortFolders; grid view sort is controlled by the same page_sort_by state.

Columns (list mode) ​

FieldLabelNotes
folder_nameNameSortable
descriptionDescriptionMin-width 250px, tooltip on overflow, not sortable
modified_atDate ModifiedMin-width 150px, formatted as date
created_atDate AddedMin-width 150px, formatted as date
_summary_display#Min-width 200px; "X Subfolder(s), Y Asset(s)" / "X Asset(s)" (uncategorised) / "Empty Folder"

Context Menu (per row) ​

Resolved by resolveFolderMenu(folder) from useFolderMenu(). Dispatches to folderActions.toggleFolderDialog or openMoveFolder.

ActionDialog
ShareShareAssetDialog
DownloaddownloadFolder()
RenameCreateOrRenameDialog (update mode)
PermissionPermissionDialog
MoveFolderDialog (move-folder)
DeleteConfirmationDialog

Folder Detail — pages/[workspace_id]/dam/folders/[_id]/index.vue ​

Route: /:workspace_id/dam/folders/:_id
Layout: collage-layout with embedsHeader: true
Middleware: auth-check, check-workspace, can-access-dam-module, check-if-suspended

The most complex page in the DAM. All core folder data, paginated asset/subfolder list, selection state, and dialog orchestration come from useFolderDetail(). The page adds Typesense search/filter wiring, bulk-action bar, marquee selection, and viewport-fill auto-loading on top.

Header area ​

  • Breadcrumbs — BreadCrumbs with parent "Folders" and the current folder path.
  • Folder title — <h1> with a tooltip for long names. Visibility icon: globe (available) or lock (restricted), driven by isVisible(folder.instance_visibility).
  • Title menu — DamItemMenu rendered from mainFolderMenu (from useFolderDetail); items include Add Subfolder, Share, Download, Rename, Portals, Move, Delete. The Portals item is disabled with an upgrade tooltip when canUpdatePermission is false.
  • Description — shown when folder.description is non-empty; truncated at 250 characters with a "Show more / Show less" toggle.

Search & filter bar ​

  • In-folder search — useSearchBar input with placeholder "Search in {folder name}". Active when bar.debouncedQuery is non-empty or a filter chip is applied. Shows a result count badge when active. Hidden when canUseInnerSearch is false for the workspace.
  • Filter chips — SearchFilter + AddedFilterOption via useSearchFilter({ context: 'folder' }). Asset-specific filters (tags, file type, uploaded-by, etc.) automatically exclude the folder collection from the Typesense search.
  • Display panel — DamDisplayPanel for sort field/direction, view mode (grid/list), and thumbnail size (grid only).

View modes ​

ModeSubfolder renderingAsset rendering
GridFolderCardAssetCard
ListDamDetailListView with unified rowsSame table; parent_id truthy = folder row

Grid thumbnail size is persisted via useThumbnailSize('folders').

Data sources ​

The page unifies two data sources behind unifiedFolders / unifiedAssets computed refs:

  • Browse mode (isSearchActive = false) — data comes from displayFiles (from useFolderDetail), split by parent_id into folders and assets. Paginated via fetchFolder(), infinite-scroll triggered at 85% scroll depth.
  • Search mode (isSearchActive = true) — data comes from Typesense multi-search (useSearchApi.getSearchResults). COMBINED_PER_PAGE = 36; folders always precede assets in the unified page. Each "load more" scrolls the folder page forward and windows the matching asset slice independently.

Search pagination logic ​

Typesense hits for folders and assets are paginated independently. On load more:

  1. Fetch next folder page.
  2. Calculate how many asset slots remain in the combined window (COMBINED_PER_PAGE − new folders).
  3. Fetch the asset window by computing the page(s) that cover the current searchAssetOffset, then slice to the exact needed count.

When asset-specific filters are active, includeCategoriesInSearch is false and only digital_assets is queried.

Selection ​

  • Checkbox click — single-item toggle via onItemSelect; updates selectedItems and is_selected on the source item.
  • Shift+click — range selection across unifiedDisplayFiles using lastSelectedListIndex.
  • Select all (header checkbox) — selects current page items; shows a "Select all N" banner when total exceeds the current page.
  • "Select all N" — calls confirmSelectAll(), which fetches all asset and folder IDs from the API (fetchAllSelectableIds) and sets internalSelectAll = true. Maximum manual selection cap: 50 000 items.
  • Marquee (rubber-band) — useMarqueeSelection() wired to both grid (mousedown on .grid-lists) and list view (mousedown on DamDetailListView).

Bulk-action bar ​

DamBulkActionsBar (floating) appears when selectedItemsCount > 0. Actions are gated by workspace permissions and disabled (with tooltip) when folders are in the selection for asset-only operations.

ActionPermission gateDisabled when folders selected
Add to CollagecanCreateCollagesYes
SharecanShareDownloadNo
DownloadcanShareDownloadNo
Add TagscanAddTagsYes
Insert Custom FieldcanManageCustomFieldsYes
MovecanMoveContentNo
DeletecanDeleteContentNo

Dialogs ​

DialogTrigger
ShareAssetDialogShare action on folder or asset, or bulk share
PermissionDialogPortals action on folder or asset
FolderDialogMove folder / move file / move multiple / copy file / duplicate asset — dialog-type resolved at runtime from dialogStore state
CreateOrRenameDialogRename folder, create subfolder, rename asset
ConfirmationDialogDelete folder or asset
SaveToCollageAdd to Collage (single or bulk asset)
AddMultipleTagsAdd Tags (bulk asset)
ManageCustomFieldsInsert Custom Field (single or bulk asset)

Key composables consumed ​

ComposablePurpose
useFolderDetailCore data: folder object, paginated displayFiles, dialogs, selection helpers, mutations
useSearchBarQuery input state and debounced value
useSearchFilterFilter chips: categories, applied filters, clear-all
useSearchApiTypesense multi-search execution
useSearchMethodsbuildFilterBy for Typesense filter strings
useMarqueeSelectionRubber-band drag-select for grid and list
useViewportFillAuto-triggers loadMore until the viewport is full on initial load
useViewModePersisted grid/list view preference (key: 'folders')
useThumbnailSizePersisted thumbnail size preference (key: 'folders')
useHelpers / useAuthPermission checks (canShareDownload, canDeleteContent, etc.)