Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
FilesView.vue 34.38 KiB
<template>
  <div class="DataSource">
    <!-- Files View Header -->
    <FilesViewHeader
      v-model="searchTerm"
      :is-uploading="isUploading"
      @clickFileSelect="clickFileSelect"
    />

    <b-row>
      <!-- Files View Table -->
      <b-table
        id="resourceViewTable"
        ref="adaptTable"
        :fields="headers"
        :items="folderContents"
        :busy="isBusy"
        :locale="$i18n.locale"
        :filter="searchTerm"
        :per-page="perPage"
        :current-page="currentPage"
        :filter-included-fields="filterFields"
        :sort-by.sync="sortBy"
        :sort-desc.sync="sortDesc"
        selectable
        striped
        bordered
        outlined
        hover
        small
        responsive
        head-variant="dark"
        class="adaptTable"
        show-empty
        :empty-text="$t('page.resource.emptyTableText')"
        :empty-filtered-text="$t('page.resource.emptyFilterText')"
        @filtered="filtered"
        @row-selected="onRowSelected"
      >
        <!-- Loading Spinner for Busy State -->
        <div slot="table-busy" class="text-center text-danger my-2">
          <b-spinner class="align-middle" />
          <strong style="margin-left: 1%">
            {{ $t("page.resource.loading") }}
          </strong>
        </div>

        <!-- Column "Name" -->
        <template #head(name)="row">
          <b-form-checkbox v-model="selectAll" @change="allSelect(row)" />
          <span>{{ $t("page.resource.fileName") }}</span>
        </template>

        <!-- Column "Last Modified" -->
        <template #head(lastModified)="row">
          <span>{{ row.label }}</span>
        </template>

        <!-- Column "Size" -->
        <template #head(size)="row">
          <span>{{ row.label }}</span>
        </template>

        <!--  -->
        <template #head()="row">
          <template v-if="visibleColumns.includes(row.field.key)">
            <span>{{ row.label }}</span>
          </template>
          <template v-else>
            <span class="additionalColumnHeader">
              {{ row.label }}
              <b-icon icon="x" @click="removeColumn(row)" />
            </span>
          </template>
        </template>

        <!-- Column "Add Column"/Filter -->
        <template #head(addColumn)>
          <b-dropdown id="addColumnDropDown" size="sm" right :no-caret="true">
            <!-- Button Template + Icon -->
            <template #button-content>
              <b-icon icon="funnel" />
            </template>

            <!-- Checkbox Options -->
            <b-form-checkbox
              v-for="column in columns"
              :key="column.key"
              v-model="column.active"
              stacked
            >
              {{ column.label }}
            </b-form-checkbox>
          </b-dropdown>
        </template>

        <!-- Row "Name" -->
        <template #cell(name)="row">
          <span class="d-flex justify-between fileViewEntryWrapper">
            <span class="d-inline-flex gap-2">
              <!-- File Icon and Checkbox -->
              <span class="checkFile">
                <!-- Show a checkbox for elements that are not read-only -->
                <b-form-checkbox
                  v-if="!row.item.readOnly"
                  v-model="row.rowSelected"
                  class="tableCheck"
                  @change="select(row)"
                />
                <!-- Icon -->
                <b-icon
                  :icon="row.item.type === 'Tree' ? 'folder' : 'file-earmark'"
                  class="ml-1"
                />
              </span>
              <a
                v-if="!editableDataUrl || row.item.type === 'Tree'"
                class="fileViewEntry"
                @click="triggerNavigation(row.item)"
              >
                {{ row.item.name }}
              </a>
              <span v-else class="fileViewEntry"> {{ row.item.name }} </span>
            </span>
            <!-- Row File "..." Menu -->
            <b-dropdown
              class="dotMenu"
              left
              variant="link"
              toggle-class="text-decoration-none"
              size="sm"
              :no-caret="true"
              :disabled="editableDataUrl && resource && resource.archived"
            >
              <!-- Row File "..." Button Template + Icon -->
              <template #button-content>
                <b-icon icon="three-dots-vertical" />
              </template>

              <!-- Option "Download" -->
              <b-dropdown-item
                v-if="!editableDataUrl && row.item.type === 'Leaf'"
                @click="openFile(row.item)"
              >
                {{ $t("page.resource.metadataManagerBtnDownload") }}
              </b-dropdown-item>

              <!-- Option "Delete" -->
              <b-dropdown-item
                v-if="!isGuest"
                :disabled="
                  (resource && resource.archived) ||
                  (resourceTypeInformation &&
                    !resourceTypeInformation.canDelete)
                "
                @click="showModalDeleteFile(row.item)"
              >
                {{ $t("buttons.delete") }}
              </b-dropdown-item>
            </b-dropdown>
          </span>
        </template>
        <!-- Pagination -->
        <template #table-caption>
          <b-row class="text-right" align-v="center" no-gutters>
            <b-col align-self="center" class="p-0" />
            <b-col align-self="center" class="p-0">
              <b-pagination
                id="pagination"
                v-model="currentPage"
                :total-rows="rows"
                :per-page="perPage"
                aria-controls="resourceViewTable"
              />
            </b-col>
            <b-col align-self="center" class="p-0">
              <b-form-select
                v-model="perPage"
                :options="paginationPerPageOptions"
                style="max-width: 5rem"
              />
            </b-col>
          </b-row>
        </template>
      </b-table>
    </b-row>
  </div>
</template>

<script lang="ts">
import { defineComponent, type PropType, reactive } from "vue";
// import the store for current module
import useResourceStore from "../../store";
import useProjectStore from "@/modules/project/store";
import FilesViewHeader from "./FilesViewHeader.vue";
import MetadataManagerUtil from "../../utils/MetadataManagerUtil";
import { FileUtil } from "../../utils/FileUtil";
import { v4 as uuidv4 } from "uuid";
import { parseRDFDefinition } from "../../utils/linkedData";
import factory from "rdf-ext";
import {
  TreeDto2FileInformation,
  TreeDto2FolderInformation,
  treeMapper,
} from "@/mapping/tree";
import type { BFormRow, BTable } from "bootstrap-vue";
import type { Dataset, Literal, Quad } from "@rdfjs/types";
import type {
  ProjectDto,
  ResourceTypeInformationDto,
  FileTreeDto,
  MetadataTreeDto,
} from "@coscine/api-client/dist/types/Coscine.Api";
import type {
  BilingualLabels,
  CustomTableField,
  FileInformation,
  FolderContent,
  FolderInformation,
  ReadOnlyFolderInformation,
  VisitedResourceObject,
} from "../../types";

export default defineComponent({
  components: {
    FilesViewHeader,
  },

  props: {
    folderContents: {
      default: () => {
        return [];
      },
      type: Array as PropType<FolderContent[]>,
    },
    fileListEdit: {
      default: () => {
        return [];
      },
      type: Array as PropType<FolderContent[]>,
    },
    isUploading: {
      default: false,
      type: Boolean,
    },
    dirTrail: {
      required: true,
      type: String,
    },
    dirCrumbs: {
      required: true,
      type: Array as PropType<string[]>,
    },
  },

  emits: {
    clickFileSelect: () => true,
    fileListEdit: (_: FolderContent[]) => true,
    folderContents: (_: FolderContent[]) => true,
    showDetail: (_: boolean) => true,
    showModalDelete: (_: FolderContent[]) => true,
  },

  setup() {
    const resourceStore = useResourceStore();
    const projectStore = useProjectStore();

    return { resourceStore, projectStore };
  },

  data() {
    return {
      currentPage: 1,
      filteredRows: -1,
      isBusy: true,
      isLoadingSearchResults: false,
      perPage: 10,
      selectAll: false,
      searchTerm: "",
      sortBy: "",
      sortDesc: false,
    };
  },

  computed: {
    isGuest(): boolean | undefined {
      return this.projectStore.currentUserRoleIsGuest;
    },
    applicationProfile(): Dataset | null {
      return this.resourceStore.currentFullApplicationProfile;
    },
    project(): null | ProjectDto {
      return this.projectStore.currentProject;
    },
    resource(): null | VisitedResourceObject {
      return this.resourceStore.currentResource;
    },
    classes(): { [className: string]: BilingualLabels } {
      return this.resourceStore.classes;
    },
    resourceTypeInformation(): ResourceTypeInformationDto | undefined {
      return this.resourceStore.enabledResourceTypes?.find(
        (resourceType) => resourceType.id === this.resource?.type?.id,
      );
    },
    editableDataUrl(): boolean {
      return (
        this.resourceTypeInformation?.resourceContent?.metadataView
          ?.editableDataUrl === true
      );
    },
    locale(): string {
      // Define as computed property to translate RCV Table
      return this.$root.$i18n.locale;
    },
    columns(): CustomTableField[] {
      const columns: CustomTableField[] = [];
      const oldColumns = this.loadFromLocalStorage();

      if (!this.applicationProfile) return oldColumns;

      // keys a, b, name, active
      const shaclPath = factory.namedNode("http://www.w3.org/ns/shacl#path");
      const shaclName = factory.namedNode("http://www.w3.org/ns/shacl#name");

      const paths = this.applicationProfile.match(undefined, shaclPath);
      for (const path of paths) {
        // Read the active value from the localStorage
        const identifier = path.object.value;

        // Find the name of the column in the preferred language or a default if not available.
        const names = this.applicationProfile.match(path.subject, shaclName);
        const name = this.getColumnName(names);

        // Determine if the column should be active based on stored settings.
        const activeColumn = this.isColumnActive(identifier, oldColumns);

        if (name) {
          columns.push(this.createColumn(name, identifier, path, activeColumn));
        }
      }

      return columns;
    },
    defaultHeaders(): Array<CustomTableField> {
      // Define as computed property to have table
      // header text react on language changes.
      return [
        {
          label: this.$t("page.resource.fileName").toString(),
          key: "name",
          sortable: true,
          active: true,
        },
        {
          label: this.$t("page.resource.lastModified").toString(),
          key: "lastModified",
          sortable: true,
          active: true,
          formatter: (value, key, item: FolderContent) => {
            return this.renderDate(item);
          },
          filterByFormatted: true,
        },
        {
          label: this.$t("page.resource.size").toString(),
          key: "size",
          sortable: true,
          active: true,
          formatter: (value, key, item: FolderContent) => {
            return this.renderSize(item);
          },
          filterByFormatted: true,
        },
      ];
    },
    filterFields(): string[] {
      const filterFields = this.defaultHeaders.map((header) => header.key);
      filterFields.push(
        ...this.visibleColumns.filter(
          (column) => !filterFields.includes(column),
        ),
      );
      filterFields.push(
        ...this.columns
          .filter((column) => column.active)
          .map((column) => column.key),
      );
      return filterFields;
    },
    headers(): Array<CustomTableField> {
      const headers: CustomTableField[] = [...this.defaultHeaders];
      for (const column of this.visibleColumns) {
        if (!this.filterFields.includes(column)) {
          headers.push({
            label: this.$t(column).toString(),
            key: column,
            sortable: true,
            active: true,
          });
        }
      }
      headers.push(...this.columns.filter((column) => column.active));
      headers.push({
        label: "add",
        key: "addColumn",
        sortable: false,
        active: true,
      });
      return headers;
    },
    paginationPerPageOptions(): { value: number; text: string }[] {
      return [
        { value: 10, text: "10" },
        { value: 50, text: "50" },
        { value: 100, text: "100" },
        { value: this.rows, text: this.$t("page.resource.all").toString() },
      ];
    },
    rows() {
      return this.filteredRows >= 0
        ? this.filteredRows
        : this.folderContents.length;
    },
    visibleColumns(): Array<string> {
      let visibleColumns: Array<string> = [];
      const collectedColumns =
        this.resourceTypeInformation?.resourceContent?.entriesView?.columns
          ?.always;
      if (collectedColumns) {
        visibleColumns = Array.from(collectedColumns);
      }
      return visibleColumns;
    },
  },

  watch: {
    searchTerm() {
      this.saveInLocalStorage();
    },
    headers() {
      this.saveInLocalStorage();
    },
    async resource() {
      await this.navigateTree();
    },
    sortBy() {
      this.saveInLocalStorage();
    },
    sortDesc() {
      this.saveInLocalStorage();
    },
    async dirTrail() {
      // Reacts to route changes from 'triggerNavigation()'
      await this.navigateTree();
    },
  },

  async created() {
    this.isBusy = true;
    if (this.resource) {
      await this.navigateTree();
    }
    this.isBusy = false;
  },

  methods: {
    /**
     * Retrieves the column name from the provided names list.
     * It prioritizes names in the current locale.
     *
     * @param {Dataset<Quad, Quad>} names - The list of potential names.
     * @returns {string | null} - The retrieved name or null.
     */
    getColumnName(names: Dataset<Quad, Quad>): string | null {
      let name: string | null = null;
      for (const nameEntry of names) {
        name = nameEntry.object.value;
        if (
          (nameEntry.object as Literal).language === this.$root.$i18n.locale
        ) {
          break;
        }
      }
      return name;
    },

    /**
     * Determines if a column should be active based on the provided identifier and the stored columns.
     * @param {string} identifier - The identifier of the column to check.
     * @param {CustomTableField[]} oldColumns - The collection of stored old columns.
     * @returns {boolean} - True if the column should be active, false otherwise.
     */
    isColumnActive(
      identifier: string,
      oldColumns: CustomTableField[],
    ): boolean {
      for (const oldColumn of oldColumns) {
        if (oldColumn.key === identifier) {
          return oldColumn.active;
        }
      }
      return false;
    },

    /**
     * Creates a column based on the provided name, identifier and path.
     * @param {string} name - The name of the column.
     * @param {string} identifier - The identifier of the column.
     * @param {Quad} path - The path of the column.
     * @param {boolean} activeColumn - True if the column should be active, false otherwise.
     * @returns {CustomTableField} - The created column.
     */
    createColumn(
      name: string,
      identifier: string,
      path: Quad,
      activeColumn: boolean,
    ): CustomTableField {
      return reactive({
        label: name,
        key: identifier,
        sortable: true,
        active: activeColumn,
        formatter: (value, key, item: FolderContent) => {
          return this.formatColumnValue(
            item.metadata[item.latestVersion],
            path,
          );
        },
        sortByFormatted: true,
        filterByFormatted: true,
      });
    },

    /**
     * Formats the column value based on the provided metadata and path.
     * @param {Dataset<Quad, Quad>} metadata - The metadata containing information to format.
     * @param {Quad} path - The RDF quad defining the property path for the column.
     * @returns {string} - The formatted column value, where multiple values are separated by a line break ("<br>").
     */
    formatColumnValue(
      metadata: Dataset<Quad, Quad> | null,
      path: Quad,
    ): string {
      // Extract all matching entries from the metadata using the specified path.
      const entries = metadata?.match(undefined, path.object);

      // If there are no matching entries, return an empty string.
      if (!entries?.size) return "";

      const returnList: string[] = [];
      for (const entry of entries) {
        const entryObject = entry.object;
        let entryValue = entryObject.value;

        // If the entry object is a literal, further formatting might be needed.
        if (entryObject.termType === "Literal") {
          // If the datatype of the literal indicates a date, format it accordingly.
          if (entryObject.datatype.value.endsWith("date")) {
            const date = entryObject.value;
            const dateObject = new Date(date);
            entryValue = dateObject.toLocaleDateString(this.$i18n.locale);
          }
        }
        // If the entry object is a named node, it might need to be translated into a more readable form.
        else if (entryObject.termType === "NamedNode") {
          // Loop through all available classes to find a match for the named node's value.
          for (const classValue of Object.values(this.classes)) {
            // Depending on the user's locale, choose the appropriate language list.
            const langClassList =
              this.$i18n.locale === "de" ? classValue.de : classValue.en;

            // If a language-specific list is available, search for the named node's value in it.
            if (langClassList) {
              const foundEntry = langClassList.find(
                (langClassEntry) => langClassEntry.value === entryValue,
              );

              // If a match is found, replace the entryValue with the more readable name.
              if (foundEntry && foundEntry.name) {
                entryValue = foundEntry.name;
                break; // Exit the loop once a match is found.
              }
            }
          }
        }

        // Add the processed entry value to the list of results.
        returnList.push(entryValue);
      }

      // Join the list of results with line breaks and return the final formatted value.
      return returnList.join("<br>");
    },

    /**
     * Navigate the folder structure based on the current folder's path.
     * This method serves to identify whether the current folder is actually a file,
     * opens it accordingly, and then navigates the directory structure.
     */
    async navigateTree() {
      // Start with the current folder as the reference
      let currentFolder = this.dirTrail;

      // Check if the current folder might actually be a file and handle it
      if (this.isProbablyFile(currentFolder)) {
        // TODO: Change to open modal
        await this.openFile({
          name: currentFolder.substring(currentFolder.lastIndexOf("/") + 1),
          path: currentFolder,
        } as FileInformation);
        // Remove the file name from the path to get the folder path, then navigate to it
        currentFolder = this.dirCrumbs.join("");
        this.triggerNavigation({ path: currentFolder } as FolderInformation);
      }

      // Finally, open the current folder to display its contents
      await this.openFolder({ path: currentFolder } as FolderInformation);
    },

    /**
     * Navigates to the specified folder or resource based on the given `entry`.
     * If the provided `entry.path` is different from the current `dirTrail`,
     * a new route will be pushed to the Vue router.
     *
     * @param {FolderContent} entry - The folder or file information object to navigate to.
     */
    triggerNavigation(entry: FolderContent) {
      this.$router.push({
        name: "resource-page",
        params: {
          dirTrail: entry.path,
        },
      });
    },

    /**
     * Determines if a given path represents a file based on its structure.
     * Files typically do not end with a slash `/`.
     *
     * @param {string} path - The path to evaluate.
     * @returns {boolean} - True if the path might represent a file, false otherwise.
     */
    isProbablyFile(path: string | undefined): boolean | undefined {
      return path !== "" && !path?.endsWith("/");
    },

    /**
     * Splits a path into its constituent directories.
     *
     * @param {string} path - The path to split.
     * @returns {string[]} - An array representing the folder's hierarchical structure.
     */
    splitPathIntoDirectories(path: string): string[] {
      const directories = [];
      let combinedPath = "";
      const splitFolders = path.split("/");
      for (const folder of splitFolders) {
        if (combinedPath && !folder) continue;
        combinedPath += folder + "/";
        directories.push(combinedPath);
      }
      return directories;
    },

    /**
     * Saves table preferences in local storage.
     */
    async saveInLocalStorage() {
      await this.resourceStore.setStoredColumns({
        columns: this.columns,
        filter: this.searchTerm,
        sortBy: this.sortBy,
        sortDesc: this.sortDesc,
      });
    },

    /**
     * Loads table preferences from local storage.
     * @returns {Array<CustomTableField>} - Array of table fields.
     */
    loadFromLocalStorage(): Array<CustomTableField> {
      let storedColumns: Array<CustomTableField> = [];
      const currentStoredColumns = this.resourceStore.currentStoredColumns;

      if (currentStoredColumns) {
        // If the stored columns are just an array (legacy format)
        if (Array.isArray(currentStoredColumns)) {
          return currentStoredColumns;
        }

        // Retrieve and set stored preferences
        storedColumns = currentStoredColumns.columns;
        this.searchTerm = currentStoredColumns.filter;
        this.sortBy = currentStoredColumns.sortBy;
        this.sortDesc = currentStoredColumns.sortDesc;
      }
      return storedColumns;
    },

    /**
     * Deactivates a column based on the provided row.
     * @param {BFormRow} row - Row which represents a column.
     */
    removeColumn(row: BFormRow) {
      for (const column of this.columns) {
        if (column.key === row.column) {
          column.active = false;
        }
      }
    },

    /**
     * Emits an event to show the delete modal for a specific file.
     * @param {FolderContent} file - File to delete.
     */
    showModalDeleteFile(file: FolderContent) {
      this.$emit("showModalDelete", [file]);
    },

    /**
     * Asynchronously opens the specified file, fetching its blob content and triggering a download.
     * @async
     * @param {FileInformation} file - Information about the file to be opened.
     */
    async openFile(file: FileInformation) {
      if (this.project?.id && this.resource?.id) {
        // Download the file as a blob (binary data)
        await this.resourceStore.download(
          this.project.id,
          this.resource.id,
          file.path,
          file.name,
        );
      }
    },

    /**
     * Asynchronously opens the given folder, fetching its file tree and metadata tree.
     * @async
     * @param {FolderInformation} folder - Information about the folder to be opened.
     */
    async openFolder(folder: FolderInformation) {
      if (!this.project?.id || !this.resource?.id) return;

      this.isBusy = true;

      // Empty file list, since the context changes
      // (Other workaround would be to keep the editing files open)
      this.emitEmptyFileList();

      // Determine the path of the folder and fetch its file and metadata trees
      const path = this.determinePath(folder);
      const [fileTree, metadataTree] = await Promise.all([
        this.resourceStore.getFileTree(this.project.id, this.resource.id, path),
        this.resourceStore.getMetadataTree(
          this.project.id,
          this.resource.id,
          path,
        ),
      ]);

      // Create a temporary folder to store the entries
      const folderContents = await this.constructFolderContents(
        fileTree,
        metadataTree,
      );

      this.isBusy = false;
      this.$emit("folderContents", folderContents);
    },

    /**
     * Emits an event to notify of an empty file list.
     */
    emitEmptyFileList() {
      this.$emit("fileListEdit", []);
    },

    /**
     * Determines the correct path for the folder.
     * @param {FolderInformation} folder - Information about the folder for which path needs to be determined.
     * @returns {string} The determined path.
     */
    determinePath(folder: FolderInformation): string {
      return folder.path; // !== "" ? folder.path : "/";
    },

    /**
     * Constructs the folder content based on the provided file and metadata trees.
     * @async
     * @param {FileTreeDto[] | null | undefined} fileTree - File tree of the folder.
     * @param {MetadataTreeDto[] | null | undefined} metadataTree - Metadata tree of the folder.
     * @returns {FolderContent[]} An array containing folder content.
     */
    async constructFolderContents(
      fileTree: FileTreeDto[] | null | undefined,
      metadataTree: MetadataTreeDto[] | null | undefined,
    ): Promise<FolderContent[]> {
      const folderContents = [];

      if (this.dirCrumbs.length > 0) {
        // Add navigate up folder entry (..)
        folderContents.push(this.createNavigateUpFolder());
      }

      // Iterate over the file tree and map the entries for the files view table
      for (const fileEntry of fileTree || []) {
        const content = this.mapFileEntryToContent(fileEntry);
        // Don't add hidden files/folders
        if (content && !fileEntry.hidden) {
          const metadataEntry = metadataTree?.find(
            (md) => md.path === fileEntry.path,
          );
          if (metadataEntry?.version) {
            const version = Number(metadataEntry.version);
            content.latestVersion = version;
            content.currentVersion = version;
          }
          if (
            metadataEntry?.definition?.type &&
            metadataEntry?.definition?.content
          ) {
            content.metadata[content.latestVersion] = await parseRDFDefinition(
              metadataEntry.definition.content,
              metadataEntry.definition.type,
            );
          }
          if (metadataEntry?.availableVersions) {
            content.versions = metadataEntry.availableVersions.map((x) =>
              Number(x),
            );
          }

          folderContents.push(content);
        }
      }
      return folderContents;
    },

    /**
     * Creates a representation for the "navigate up" folder.
     * @returns {FolderContent} A representation of the "navigate up" folder.
     */
    createNavigateUpFolder(): FolderContent {
      // Make a shallow copy of the array and copy all but the last element as it is the current folder itself
      const currentDirCrumbs = this.dirCrumbs.slice(0, -1);
      const path = currentDirCrumbs.join("");
      const identifier = uuidv4();
      const version = +new Date();
      return {
        id: identifier,
        name: "..",
        path: path,
        readOnly: true,
        type: "Tree",
        parentDirectory: "",
        versions: [version],
        latestVersion: version,
        currentVersion: version,
        metadata: { [version]: factory.dataset() as unknown as Dataset },
      } satisfies ReadOnlyFolderInformation;
    },

    /**
     * Maps a file entry to its corresponding folder content.
     * @param {FileTreeDto} fileEntry - The file data transfer object to map.
     * @returns {FolderContent | undefined} The corresponding folder content or undefined if the type is unknown.
     */
    mapFileEntryToContent(fileEntry: FileTreeDto): FolderContent | undefined {
      let content: FolderContent | undefined = undefined;
      switch (fileEntry.type) {
        case "Tree":
          content = treeMapper.map(TreeDto2FolderInformation, fileEntry);
          break;
        case "Leaf":
          content = treeMapper.map(TreeDto2FileInformation, fileEntry);
          break;
        default:
          console.error("Unknown content type: ", fileEntry.type);
          break;
      }
      return content;
    },

    /**
     * Handles when a row is selected, updating the UI and emitting necessary events.
     * @param {FolderContent[]} items - Selected items.
     */
    onRowSelected(items: FolderContent[]) {
      this.$emit("showDetail", items.length > 0);
      this.selectAll = items.length === this.folderContents.length;
      // Filter out read-only entires like the navigation up folder ".."
      const selectedFiles = items.filter((item) => !item.readOnly);
      this.updateFileListEdit(selectedFiles);
    },

    /**
     * Selects or deselects all rows.
     * @param {BTable} row - Reference to the table.
     */
    allSelect(row: BTable) {
      if (this.selectAll) {
        row.selectAllRows();
      } else {
        row.clearSelected();
      }
    },

    /**
     * Handler for the filtered elements
     * @param elements filtered elements
     */
    filtered(elements: FolderContent[]) {
      this.currentPage = 1;
      this.filteredRows = elements.length;
    },

    /**
     * Toggles the selection of a specific row.
     * @param {{ rowSelected: boolean; index: number }} row - Info about the row's status and its index.
     */
    select(row: { rowSelected: boolean; index: number }) {
      const tableRef = this.$refs.adaptTable as BTable;
      row.rowSelected
        ? tableRef.selectRow(row.index)
        : tableRef.unselectRow(row.index);
    },

    /**
     * Emits an event for file selection.
     */
    clickFileSelect() {
      this.$emit("clickFileSelect");
    },

    /**
     * Updates the file list for editing based on current selections.
     */
    async updateFileListEdit(selectedFiles: FolderContent[]) {
      const existingFilesSet = new Set(this.fileListEdit.map((f) => f.name));
      const newFileListEdit: FileInformation[] = [];

      for (const file of selectedFiles) {
        // Check if file already exists in the list.
        // Keep already entered metadata values, if another entry selected.
        if (
          existingFilesSet.has(file.name) &&
          this.dirTrail === file.parentDirectory
        ) {
          const existingFile = this.fileListEdit.find(
            (f) => f.name === file.name,
          );
          if (existingFile) {
            newFileListEdit.push(existingFile as FileInformation);
          }
          continue;
        }

        // If file doesn't exist, fetch metadata and add
        const loadedMetadata = await MetadataManagerUtil.loadMetadataForFile(
          file,
          this.project,
          this.resource,
        );

        const version = file.latestVersion;
        newFileListEdit.push({
          ...file,
          uploading: false,
          metadata: { [version]: loadedMetadata },
          info: file.type === "Tree" ? undefined : file.info,
          size: file.type === "Tree" ? 0 : file.size,
        } as FileInformation);
      }

      this.$emit("fileListEdit", newFileListEdit);
      this.$emit("showDetail", selectedFiles.length > 0);
    },

    /**
     * Converts last modified date into a locale string format.
     * @param {FolderContent} item - Item to retrieve the date from.
     * @returns {string} - Formatted date string.
     */
    renderDate(item: FolderContent): string {
      return item.lastModified
        ? new Date(item.lastModified).toLocaleDateString(this.$i18n.locale)
        : "";
    },

    /**
     * Converts size into a readable string format.
     * @param {FolderContent} item - Item to retrieve the size from.
     * @returns {string} - Formatted size string.
     */
    renderSize(item: FolderContent): string {
      return item.type === "Leaf" ? FileUtil.formatBytes(item.size) : "";
    },
  },
});
</script>

<style>
#resourceViewTable thead th:first-of-type div {
  display: inline;
  position: relative;
  margin-left: 5px;
}

#resourceViewTable thead th:first-of-type div label {
  cursor: pointer;
}

#resourceViewTable thead th:last-of-type {
  text-align: center;
}

#resourceViewTable td,
#resourceViewTable th {
  vertical-align: middle;
  cursor: initial;
  white-space: nowrap;
}

#resourceViewTable td,
#resourceViewTable tr td:not(:first-child):not(:last-child),
#resourceViewTable tr th:not(:first-child):not(:last-child) {
  position: relative;
}

#resourceViewTable td a {
  padding-left: 14px;
  padding-right: 30px;
  vertical-align: middle;
}

#resourceViewTable tr td:nth-child(2),
#resourceViewTable tr th:nth-child(2) {
  max-width: 170px !important;
  min-width: 170px !important;
  width: 170px !important;
}

#resourceViewTable tr td:last-child,
#resourceViewTable tr th:last-child {
  max-width: 55px !important;
  min-width: 55px !important;
  width: 55px !important;
}

#resourceViewTable tr td:not(:first-child):not(:last-child),
#resourceViewTable tr th:not(:first-child):not(:last-child) {
  max-width: 200px;
  text-overflow: ellipsis;
  overflow: hidden;
}

#resourceViewTable i,
#resourceViewTable input,
#resourceViewTable div.tableCheck.custom-checkbox label,
.checkFile {
  vertical-align: top;
}
</style>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
#resourceViewTable {
  overflow: visible;
}

#resourceViewTable :deep(.btn-link) {
  padding-left: 5px !important;
  padding-right: 5px !important;
  background: none !important;
  min-width: auto;
}

#resourceViewTable th:last-of-type,
.center-align {
  text-align: center;
}

#resourceViewTable th > i {
  position: absolute;
  right: 10px;
  top: 8px;
}

#resourceViewTable th .additionalColumnHeader {
  margin-right: 20px;
}

.adaptTable {
  overflow: auto;
  position: absolute;
  top: 65px;
  bottom: 0px;
  left: 10px;
  right: 10px;
  width: auto;
}

.fileViewEntryWrapper {
  align-items: center;
}

.fileViewEntry {
  padding: 0.05rem 0.5rem;
}

a.fileViewEntry {
  cursor: pointer;
}

.DataSource {
  height: inherit;
  margin-top: 7px;
}
.tableCheck {
  text-align: center;
  margin-right: -0.5em;
  position: absolute;
  top: 15px;
  left: 17px;
  cursor: pointer;
}
#pagination {
  margin-top: 0.5rem;
  margin-bottom: 0.5rem;
}
#pagination :deep(li.page-item.active button) {
  z-index: 0;
}
#resourceViewTable .tableCheck,
#resourceViewTable .dotMenu {
  visibility: hidden;
}

#resourceViewTable tr:hover .tableCheck,
#resourceViewTable tr:hover .dotMenu,
#resourceViewTable .b-table-row-selected .tableCheck {
  visibility: visible;
}

.leftButton {
  height: calc(1.4em + 0.75rem + 2px);
}
</style>