Visual Table

Visual Table is a highly flexible and powerful component for displaying and manipulating tabular data. Allowing you to create tables with sorting, filtering, pagination, and more. Table is really lightweight and doesn't require any external dependencies.

To add the VisualTable to your project, copy the useVisualTable composable with the Types Definitions and inspire yourself with the VisualTable component template.

Table is made with Vue 3. All the features are flexible and can be customized to fit your needs.

Installation

To use the VisualTable component in your project, you can install it via npm.

npx rosalana-dev@latest add VisualTable

Features

  • Multi-layout support
  • Full control over data rendering and use
  • Sorting
  • Filtering
  • Search
  • Selecting
  • Pagination
  • Row styling (not fully implemented yet but you can use it for checked state)

Usage

In this section I will provide some common use cases and best practices for the VisualTable component.

Displaying Data

The useVisualTable composable was created to display data in more layouts then just a simple table. You can display data in a grid or in a table or in any other layout you want.That is why the VisualTable component has a displayType prop. Here is an example how to render data in any way you want.

More simple way is display data in a table. You can use a basic iteration and then render the content of the column.

<template>
  <tr v-for="row in table.data.paginated" :key="row.id">
    <th v-for="column in table.columns.visible" :key="column.key">
      <VTRender :content="column.content()" :item="row" />
    </th>
  </tr>
</template>
Always use content() function to render the content of the column.

When you want to display data in a grid you can use the same approach. Just with a slight change.

<template>
  <div v-for="row in table.data.paginated" :key="row.id">
    <!-- You can render specific column here -->
    <VTRender :content="table.columns.bind('name').content()" :item="row" />

    <!-- And then render other columns -->
    <div v-for="column in table.columns.unbinded" :key="column.key">
      <VTRender :content="column.content()" :item="row" />
    </div>
  </div>
</template>

Filtering Data

Making filters for the table is easy. Filter function is a simple function that returns boolean for each item in the data array.

Do not forget to provide filters to the useVisualTable instance.

Simple Select Filter

For a simple filter select you can do it like this:

<script setup lang="ts">
const filters = ref<{ [key: string]: string }>({});

const applySelectFilter = (item: string) => {
  return Object.keys(filters.value).every(([key, value]) => {
    // Its better to use `String()` to compare values
    return value === "" || String(item[key]) === String(value); // || String(item[key]).includes(String(value)); -> if you data is array
  })
}

// Get unique values at the start if it is possible
let columnsFilters = getFiltersForCols();
</script>
<template>
  <Dropdowm>
    <!-- trigger  -->
    <DropdownContent>
      <DropdownGroup 
        v-for="column in table.columns.filterable" 
        :key="column.key" 
        v-model="filters[column.key]"
        @update:modelValue="applySelectFilter"  
      >
        <DropdownItem
          v-for="option in columnsFilter[column.key]"
          :key="option"
          :value="String(option)"
          :title="String(option)"
        />
      </DropdownGroup>
    </DropdownContent>
  </Dropdown>
</template>

Advanced Filter

There is unlimited ways how to filter data. Remember, just return boolean after all logic you put in the filter function. Here is an example of how to create filter similar to SQL clauses.

<script setup lang="ts">

  const filters = ref<VTFilter[]>([{ column: "", operator: "===", value: "" }]);

  // Current filters
  const activeFilters = computed(() => {
    filters.value.filter((f) => f.column && f.operator && f.value);
  });

  const addFilter = () => {
    if (filters.value.length >= 5) return; // max 5 filters at once
    filters.value.push({ column: "", operator: "===", value: "" });
  };

  const updateFilterOptions = (index: number) => {
    filter.value[index].value = "";
  };

  const removeFilter = (index: number) => {
    filters.value.splice(index, 1);
  };

  const clearAllFilters = () => {
    filters.value = [{ column: "", operator: "===", value: "" }];
  };

  // Filter logic
  const applySelectFilter = (item: any): boolean => {
    return activeFilters.value.every((filter) => {
      const itemValue = item[filter.column];
      const filterValue = filter.value;

      switch (filter.operator) {
        case "===":
          return (
            itemValue == filterValue ||
            String(itemValue).includes(String(filterValue))
          );
        case "!==":
          return (
            itemValue != filterValue &&
            !String(itemValue).includes(String(filterValue))
          );
        case ">":
          return itemValue >= filterValue;
        case "<":
          return itemValue <= filterValue;
        default:
          return true;
      }
    });
  };
</script>
<template>
  The logic is very similar just select the column and operator and value.

  <SelectColumn v-for="column in table.columns.filterable" :key="column.key" name="column" />
  <SelectOperator name="operator" />

  <SelectValue v-for="value in columnsFilters[filter.column]" :key="value" name="value" />
  <!-- or use input for value -->
  <InputValue v-model="filter.value" name="value" />
</template>

Search Filter

Search filter is a same thing as others. Just create a function that will resive search param and return boolean. Only difference is that we will use searchableColumns. It is up to you what columns you want to search in. But do not forget to add the enableSearch condition in the search function.

<script setup lang="ts">
const searchTerm = ref("");

const applySearchFilter = (item: any) => {
  const searchTermLower = searchTerm.value.toLowerCase();
  const searchableColumns = table.columns.relevant.filter(
    (col) => col.enableSearch
  );

  if (searchableColumns.length === 0) {
    return true;
  }

  return searchableColumns.some((col) => {
    const value = item[col.key];
    return (
      value != null && value.toString().toLowerCase().includes(searchTermLower)
    );
  });
};
</script>

Provide Filters

After you create your filters you need to provide them to the useVisualTable instance. There is many ways how to actually do it. But if we continue with the example above you can do it like this. Simply change the state in the useVisualTable config.

<script setup lang="ts">
  // your code...
  const table = useVisualTable({
    // other options...
    state: {
      get filter() {
        return (item: any) => applySearchFilter(item) && applySelectFilter(item); // combine how many filters you want
      },
    },
    // other options...
  });
</script>
item is the item from the data array we currently iterate over. That is why every filter function must have item as a parameter.

Styling Rows

You can style your rows how you want but if you want to style rows based on the state of the row you can use the getRowStyle function. This function returns a style for the row based on the state. This function is not fully implemented yet but you can use it to style rows based on the select state.

<template>
  <tr
    v-for="row in table.data.paginated"
    :key="row.id"
    :class="table.getRowStyle(row)"
  >
  <!-- columns... -->
  </tr>
</template>
There you have it! You can now modify the VisualTable component to your needs. You can display data in any layout you want and filter data in any way you want. You can also style rows based on the state of the row.

Types

This is a typescript file with types for the VisualTable component.

export type VTDisplayOptions = "table" | "grid";

/**
 * VisualTable configuration
 */
export type VTConfig = {
  /** Your data to display */
  data: any[];
  /** Columns definition */
  columns: VTColumn[];
  /** Initial state of VisualTable */
  initialState?: {
    sort?: { key: string; direction: "asc" | "desc" };
    filter?: (item: any) => boolean;
    pageSize?: number;
    currentPage?: number;
  };
  /** Current state of VisualTable */
  state?: {
    sort?: { key: string; direction: "asc" | "desc" };
    filter?: (item: any) => boolean;
    pageSize?: number;
    currentPage?: number;
  };
  /** Additional options */
  options?: {
    /** Table will automaticly add columns from data object if they are not defined in columns */
    extendedColumns?: boolean;
    /** Data will be flattened from immerse object. Use only for simple objects and when you really need it. */
    flatData?: boolean;
    /** Use prefix to your data keys. */
    dataPrefix?: string;
  };
};

/**
 * Column definition for VisualTable
 * You can define what columns with what data will be displayed in VisualTable
 *
 * Example for a simple column definition:
 * ```ts
 * const columns = [
 *  { key: "name", header: "Name" },
 * ]
 * ```
 */
export type VTColumn = {
  /** Key in your Data Object to bind column to. When data does not exist in this key the key will display */
  key: string;
  /** Header for the column */
  header: string | ((item: any) => any);
  /** Render your data in different format or as a component */
  renderAs?: (item: any) => any;
  /** Bind column to a specific position in different layouts. Table will separate binded items from unbinded. */
  bindToPosition?: "name" | "status" | "id" | "checkbox" | "action" | "none";
  /** Enable the option to automaticly sort by this column (default: true) */
  enableSort?: boolean;
  /** Enable the option to automaticly generate filters for this column. Dont use this when you render `Header` as a component (default: false) */
  enableFilter?: boolean;
  /** Enable the option to search in this column (default: true) */
  enableSearch?: boolean;
  /** Enable the option to toggle the column vissiblity (default: true) */
  enableToggle?: boolean;
  /** Make the column hidden as default */
  hidden?: boolean;
  /** This option is useful when combination of `enableToggle` and `hidden` is activated.
   * When `relevant` is set to `true` data in this column will be still relevant to work with.
   *
   * You can hide the column permanently but still use the data in it.
   * ```ts
   * const columns = [
   * { key: "name", header: "Name", hidden: true, enableToggle: false, relevent: true },
   * ];
   * ```
   */
  relevant?: boolean;
};

export type VTRowStyle =
  | "default"
  | "success"
  | "warning"
  | "destructive"
  | "checked";
/**
 * Processed column definition for VisualTable
 */
export type VTColumnProcessed = VTColumn & {
  /** Function that determines what content should be displayed in colum (data[key] or renderAs()) */
  content: () => string | ((item: any) => any);
  /** AdditionalColumn is column whitch was add by enabling `extendedColumns` config option */
  additionalColumn?: boolean;
};

export type VTFilter = {
  column: string;
  operator: "===" | "!==" | ">" | "<" | "#";
  value: any;
};

VTRender

This is a component provided by the useVisualTable composable. It is used to render the content of the table. It is a simple component that renders the content of the column. It can be used to render a simple string or a component from h().

As you can see VTRender has a long console.warn message. This message is displayed when you try to render an object or null. VTRender can render only types that can be converted to a string (ex. Array is ok).

Warning message is displayed to inform you that you are trying to render something wrong. For example you have item in your data that can be null and you are trying to render it as default data[key]. In this case you should use renderAs function in your column definition.

API

VisualTable has a pretty large API. It is a composable that provides a lot of functions and properties to work with. Let's take a look at the API in a simple way as possible. Section Types contains all types that are used in the API with a description. To understand the API better you should read the Types section first.

By copying the type definitions from the Types section your IDE will provide you with autocompletion, type checking and documentation.

Configuration

First you need to configure the VisualTable to work with your data. You can do this by providing the VTConfig and columns object to the useVisualTable composable.

VTConfig

const table = useVisualTable(config: VTConfig);

This will create an instance of the VisualTable. Now you can work with the table and provide data and columns to it.

  • data - Your data to display in the table
  • columns - Columns definition for the table
  • initialState - Initial state of the table
    • sort - Initial sort state
    • filter - Initial filter function
    • pageSize - Initial page size
    • currentPage - Initial current page
  • state - Current state of the table
    • sort - Current sort state
    • filter - Current filter function
    • pageSize - Current page size
    • currentPage - Current current page
  • options - Additional options
    • extendedColumns - Table will automaticly add columns from data object if they are not defined in columns
    • flatData - Data will be flattened from immerse object. Use only for simple objects and when you really need it.
    • dataPrefix - Use prefix to your data keys.

VTColumn

VisualTable uses columns definition to determine what columns with what data will be displayed in the table. Every column can have a specific configuration. You can define what data will be displayed in the column, how it will be displayed, and what actions will be available for the column.

Be aware that rendering is separated from data. You can render data in different ways but data will not be changed.

Simple column definition:

const columns = [
  { key: "name", header: "Name" },
];
  • key - Key in your Data Object to bind column to. When data does not exist in this key the key will display
  • header - Header for the column
  • renderAs - Render your data in different format or as a component
  • bindToPosition - Bind column to a specific position in different layouts. Table will separate binded items from unbinded.
  • enableSort - Enable the option to automaticly sort by this column (default: true)
  • enableFilter - Enable the option to automaticly generate filters for this column. Dont use this when you render Header as a component (default: false)
  • enableSearch - Enable the option to search in this column (default: true)
  • enableToggle - Enable the option to toggle the column vissiblity (default: true)
  • hidden - Make the column hidden as default
  • relevant - This option is useful when combination of enableToggle and hidden is activated. When relevant is set to true data in this column will be still relevant to work with.

You can hide the column permanently but still use the data in it.

const columns = [
  { key: "name", header: "Name", hidden: true, enableToggle: false, relevent: true },
];
VTColumnProcessed

Every column is processed by the useVisualTable composable. It generates a VTColumnProcessed object from the VTColumn object. This object contains all the properties from the VTColumn object and additional properties that are generated by the composable.

VTColumnProcessed contains two additional properties:

  • content - Function that determines what content should be displayed in colum (datakey or renderAs())
  • additionalColumn - AdditionalColumn is column whitch was add by enabling extendedColumns config option.
Whenever you display a property use content() function. This function will return the content of the column.

Returned properties

Every propertie has a prexix ${variableWhereTableIstanceIsStored}. (ex. table.data).

data

Data are the content of the table. It is an array of objects that you provided to the VisualTable. This property is reactive and you can use it to display data or manipulate with specific format of the data.

  • data.original - Original data that you provided to the VisualTable
  • data.paginated - Data that is displayed in the current page
  • data.filtered - Data that is filtered by the filter function
  • data.sorted - Data that is sorted by the sort function

columns

Columns are the definition of the table. It is an array of objects that you provided to the VisualTable. This property is reactive and by using it you can manipulate with the visibility of the columns or with the content. This properties are used to filer columns by their attributes.

  • columns.all - All columns you provided to the VisualTable
  • columns.visible - Columns that are visible
  • columns.relevant - Columns that are relevant
  • columns.filterable - Columns that are filterable
  • columns.toggleable - Columns that are toggleable
  • columns.unbinded - Columns that are not bind to any position
  • columns.bind(key: string) - Get column by the bindToPosition key

select

This object is used to work with select state of the table.

  • select.selected - Array of selected items
  • select.toggle(id: string|number) - Toggle select state of the item
  • select.toggleAll() - Toggle select state of all items (this is based on data you are working with ex. toggle all paginated items)
  • select.isSelected(id: string|number) - Check if item is selected
  • select.isAllSelected() - Check if all items are selected (all original items)
  • select.isAllOnPageSelected() - Check if all items on the current page are selected
  • select.isIndeterminate() - Check if select state is indeterminate (some items are selected)

state

Returns the current state of the table.

  • state.sort - Current sort state
  • state.filter - Current filter function
  • state.select - Current select state

paginator

Object that provides pagination functionality.

  • paginator.first - Check if current page is the first page
  • paginator.last - Check if current page is the last page
  • paginator.totalPages - Total number of pages
  • (set, get) paginator.currentPage - Setter and getter for the current page
  • (set, get) paginator.pageSize - Setter and getter for the page size
  • paginator.nextPage() - Go to the next page
  • paginator.prevPage() - Go to the previous page

Additional Functions

  • setSort(key: string) - Set sort for the column key
  • setFilter(filterFn: (item: any) => boolean) - Set filter function. Filter function is simple function that returns boolean for each item in the data array.
  • toggleVisibility(key: string) - Toggle visibility of the column
  • getFilterForColumn(key: string) - Returns unique values for the column key (useful for select filter)
  • getRowStyle(row: any) - Returns style for the row based on the state (not fully implemented yet, works only for checked style)

VTRender

VTRender expects two props:

  • content - Content that should be rendered column.content() (datakey or renderAs())
  • item - Item will be passed to renderAs function to reference table data in column definition.
<!-- Rendering column content -->
<VTRender :content="column.content()" :item="row" />
<!-- Rendering header with passed table instance -->
<VTRender :content="column.header" :item="table" />
More information about VTRender can be found in the VTRender section.

Files

useVisualTable.ts

This is a composable that provides all the logic for the table. When you use your table, you create a new instance of this composable and pass it the configuration object. You shouldn't need to modify this file.

import {
  type VTColumnProcessed,
  type VTConfig,
  type VTRowStyle,
} from "~/types";

export function useVisualTable(config: VTConfig) {
  // Inicializace dat
  const originalData = ref(config.data);

  // Funkce pro flatten objektu z hlubokého na plochý
  const flattenObject = (obj: any, path: string = ""): any => {
    let result: any = {};
    for (const key in obj) {
      const newPath = path ? `${path}.${key}` : key;
      if (typeof obj[key] === "object" && obj[key] !== null) {
        if (Array.isArray(obj[key])) {
          // Pokud je hodnota pole, zachováme ji jako pole
          result[newPath] = obj[key].map((item: any) =>
            typeof item === "object" && item !== null
              ? flattenObject(item)
              : item
          );
        } else {
          // Pokud je hodnota objekt (ne pole), provedeme flatten
          Object.assign(result, flattenObject(obj[key], newPath));
        }
      } else {
        result[newPath] = obj[key];
      }
    }
    return result;
  };

  if (config.options?.flatData) {
    const flattenData = originalData.value.map((item) => flattenObject(item));
    originalData.value = flattenData;
  }

  // Funkce pro získání hodnoty z objektu na základě cesty (např. "user.name")
  const getValueByPath = (obj: any, path: string) => {
    return path.split(".").reduce((acc, part) => acc && acc[part], obj);
  };

  // Inicializace a rozšíření sloupců o výchozí hodnoty
  let columns: VTColumnProcessed[] = config.columns.map((column) => ({
    enableSort: true,
    enableSearch: true,
    enableFilter: false,
    enableToggle: true,
    hidden: false,
    relevant: false,
    content() {
      return this.renderAs ? this.renderAs : this.key;
    },
    ...column, // Přepíše výchozí hodnoty pokud jsou definovány v config.columns
  }));

  // Pokud je enabledColumns === true, přidáme další sloupce z originalData[0]
  if (config.options?.extendedColumns && originalData.value.length > 0) {
    const additionalColumns = Object.keys(originalData.value[0])
      .map((key) => {
        // Zkontrolujeme, jestli klíč již neexistuje v definovaných sloupcích
        const existingColumn = columns.find((col) => col.key === key);

        // Zkontrolujeme typ hodnoty, která odpovídá klíči
        const value = originalData.value[0][key];
        const valueType = typeof value;

        // Přidáme sloupec pouze pokud typ je string, number, boolean nebo další běžně zobrazitelné typy
        if (
          !existingColumn &&
          (valueType === "string" ||
            valueType === "number" ||
            valueType === "boolean" ||
            value == null) &&
          !key.includes(".")
        ) {
          return {
            key: key,
            header: key.charAt(0).toUpperCase() + key.slice(1), // Vytvoření základního headeru
            enableSort: true,
            enableFilter: false,
            renderAs() {
              return (item: any) => item[this.key] || "N/A";
            },
            enableSearch: true,
            enableToggle: true,
            hidden: true, // Nově přidané sloupce budou skryté
            additionalColumn: true,
            relevant: false,
            content() {
              return this.renderAs() || this.key;
            },
          };
        }
        return null;
      })
      .filter((col) => col !== null) as VTColumnProcessed[];

    columns = [...columns, ...additionalColumns];
  }

  const columnsRef: Ref<VTColumnProcessed[]> = ref(columns);

  // Stav třídění, filtrování, stránkování
  const sortState = ref(
    config.state?.sort ||
      config.initialState?.sort || { key: null, direction: "asc" }
  );
  const filterState = ref(
    config.state?.filter || config.initialState?.filter || null
  );
  const currentPage = ref(
    config.state?.currentPage || config.initialState?.currentPage || 1
  );
  const pageSize = ref(
    config.state?.pageSize || config.initialState?.pageSize || 10
  );
  const selectState = ref<Set<string | number>>(new Set());

  const getRowStyle = (row: any) => {
    if (selectState.value.has(row.id)) {
      return stylesCase("checked");
    }
    //...
  };

  const stylesCase = (style: VTRowStyle | undefined) => {
    switch (style) {
      case "checked":
        return "bg-slate-200 hover:bg-slate-200";
      case "success":
        return "bg-green-100 hover:bg-green-200";
      case "warning":
        return "bg-yellow-100 hover:bg-yellow-200";
      case "destructive":
        return "bg-red-100 hover:bg-red-200";
      default:
        return "";
    }
  };

  // Třídění dat
  const sortedData = computed(() => {
    if (sortState.value.key) {
      return [...originalData.value].sort((a, b) => {
        const aVal = a[sortState.value.key!];
        const bVal = b[sortState.value.key!];

        // Ošetření null a undefined
        if (aVal == null && bVal == null) return 0;
        if (aVal == null) return 1;
        if (bVal == null) return -1;

        // Porovnání Arrays
        if (Array.isArray(aVal) && Array.isArray(bVal)) {
          // Snažíme se porovnat prvky pole
          for (let i = 0; i < Math.min(aVal.length, bVal.length); i++) {
            if (aVal[i] < bVal[i])
              return sortState.value.direction === "asc" ? -1 : 1;
            if (aVal[i] > bVal[i])
              return sortState.value.direction === "asc" ? 1 : -1;
          }
          return sortState.value.direction === "asc"
            ? aVal.length - bVal.length
            : bVal.length - aVal.length;
        }

        // Porovnávání ostatních typů
        if (aVal < bVal) return sortState.value.direction === "asc" ? -1 : 1;
        if (aVal > bVal) return sortState.value.direction === "asc" ? 1 : -1;
        return 0;
      });
    }
    return originalData.value;
  });

  // Filtrování dat
  const filteredData = computed(() => {
    if (filterState.value) {
      currentPage.value = 1; // Reset stránkování při změně filtru
      return sortedData.value.filter(filterState.value);
    }
    return sortedData.value;
  });

  // Stránkování dat
  const paginatedData = computed(() => {
    const start = (currentPage.value - 1) * pageSize.value;
    return filteredData.value.slice(start, start + pageSize.value);
  });

  // Metody pro manipulaci s daty
  const setSort = (key: string) => {
    const column = columnsRef.value.find((col) => col.key === key);
    if (column && column.enableSort) {
      if (sortState.value.key === key) {
        sortState.value.direction =
          sortState.value.direction === "asc" ? "desc" : "asc";
      } else {
        sortState.value.key = key;
        sortState.value.direction = "asc";
      }
    }
  };

  // Generování filtrů vytváří Set i z Array hodnot
  const getFilterForColumn = (key: string) => {
    return Array.from(
      new Set(
        originalData.value.flatMap((item) => {
          const value = item[key];
          if (Array.isArray(value)) {
            return value.filter(
              (v) => v !== null && v !== "" && v !== undefined
            );
          } else if (value !== null && value !== "" && value !== undefined) {
            return [value];
          }
          return [];
        })
      )
    );
  };

  const setFilter = (filterFn: (item: any) => boolean) => {
    filterState.value = filterFn;
  };

  // Getter pro viditelné sloupce
  const getVisibleColumns = () => {
    return columnsRef.value.filter((col) => col.hidden !== true);
  };

  // Getter pro všechny dostupné sloupce
  const getAllAvailableColumns = () => {
    return columnsRef.value;
  };

  // Getter pro všechny spoupce na toggle viditelnosti
  const getToggleableColumns = () => {
    return columnsRef.value.filter((col) => col.enableToggle);
  };

  const getFilterableColumns = () => {
    return columnsRef.value.filter((col) => col.enableFilter);
  };

  // Toggle visibility sloupce
  const toggleVisibility = (key: string) => {
    const column = columnsRef.value.find((col) => col.key === key);
    if (column && column.enableToggle) {
      column.hidden = !column.hidden;
    }
  };

  return {
    data: {
      get paginated() {
        return paginatedData.value;
      },
      get original() {
        return originalData.value;
      },
      get sorted() {
        return sortedData.value;
      },
      get filtered() {
        return filteredData.value;
      },
    },
    original: {
      columns: columnsRef.value,
    },
    columns: {
      get visible() {
        return getVisibleColumns();
      },
      get relevant() {
        return columnsRef.value.filter(
          (col) => col.hidden !== true || col.relevant === true
        );
      },
      get unbinded() {
        return this.visible.filter((col) => !col.bindToPosition);
      },
      bind(bind: string): VTColumnProcessed | null {
        return this.visible.find((col) => col.bindToPosition === bind) || null;
      },
      toggleable: getToggleableColumns(),
      all: getAllAvailableColumns(),
      filterable: getFilterableColumns(),
    },
    select: {
      selected: selectState.value,
      isIndeterminate() {
        return (
          selectState.value.size > 0 &&
          selectState.value.size < originalData.value.length
        );
      },
      isAllSelected() {
        return selectState.value.size === filteredData.value.length;
      },
      isAllOnPageSelected() {
        return paginatedData.value.every((item: any) =>
          selectState.value.has(item.id)
        );
      },
      isSelected(id: string | number) {
        return selectState.value.has(id);
      },
      toggleAll(): void {
        if (!this.isAllSelected()) {
          if (this.isIndeterminate()) {
            if (this.isAllOnPageSelected()) {
              paginatedData.value.forEach((item: any) =>
                selectState.value.delete(item.id)
              );
            } else {
              paginatedData.value.forEach((item: any) =>
                selectState.value.add(item.id)
              );
            }
          } else {
            selectState.value = new Set(
              paginatedData.value.map((item: any) => item.id)
            );
          }
        } else {
          selectState.value.clear();
        }
      },
      toggle(id: string | number): void {
        if (selectState.value.has(id)) {
          selectState.value.delete(id);
        } else {
          selectState.value.add(id);
        }
      },
    },
    state: {
      get sort() {
        return sortState.value;
      },
      get filter() {
        return filterState.value;
      },
      get select() {
        return selectState.value;
      },
    },
    paginator: {
      get first() {
        return currentPage.value === 1;
      },
      get last() {
        return currentPage.value === this.totalPages;
      },
      get totalPages() {
        return Math.ceil(filteredData.value.length / pageSize.value);
      },
      get currentPage() {
        return currentPage.value;
      },
      set currentPage(value: number) {
        if (value < 1 || value > this.totalPages) {
          return;
        }
        currentPage.value = value;
      },
      get pageSize() {
        return pageSize.value;
      },
      set pageSize(value: number) {
        pageSize.value = value;

        if (this.currentPage > this.totalPages) {
          this.currentPage = this.totalPages;
        }
      },
      nextPage() {
        if (!this.last) {
          currentPage.value++;
        }
      },
      prevPage() {
        if (!this.first) {
          currentPage.value--;
        }
      },
    },
    setSort,
    setFilter,
    sortState,
    toggleVisibility,
    getFilterForColumn,
    getRowStyle,
  };
}

export const VTRender = defineComponent({
  props: ["content", "item"],
  setup(props: { content: any; item?: any }) {
    return () => {
      if (typeof props.content === "function") {
        return h(props.content, props.item);
      }
      if (
        props.item &&
        typeof props.content === "string" &&
        props.content in props.item
      ) {
        const result = props.item[props.content];
        if (typeof result === "object") {
          console.warn(
            ...VTErrors.vtrender.invalidType.object,
            `${props.content} reading`,
            result,
            props.item
          );
          return String(result);
        } else {
          return result;
        }
      }

      return props.content;
    };
  },
});

const VTErrors = {
  vtrender: {
    invalidType: {
      object: [
        "%cVTRender Warning: Trying to render object or null as string\n\n" +
          "%cIf you want to render Object, provide renderAs function as column propertie. Use renderAs also for values that can be null for better user experience.\n" +
          "%cBe aware that rendering is separated from data.\n\n" +
          "%cTip: %cWhen you expect object to be part of data use enableFlatData option in VTConfig.\n\n On:",
        "font-weight: bold; color: orange;",
        "",
        "font-style: italic;",
        "font-weight: bold; color: black;",
        "",
      ],
    },
  },
};

VisualTable.vue

This is a component that uses the composable and provides the UI for the table. Code below is a simplified version for demonstration purposes. Component is using Ui-thing library for UI components. You can replace them with your own components. This file is a template for your own implementation.

<script lang="ts" setup>
import { useVisualTable, VTRender } from "~/composables/useVisualTable";
import type { VTColumn, VTDisplayOptions, VTFilter } from "~/types";

const props = withDefaults(
  defineProps<{
    columns: VTColumn[];
    data: any[] | any;
    showSelect?: boolean;
    displayType?: VTDisplayOptions;
    showDisplayOption?: boolean;
    icon?: string;
    pageSize?: number;
    pageSizes?: number[];
    class?: any;
    extendColumns?: boolean;
    flatData?: boolean;
    filter?: VTFilter[];
  }>(),
  {
    columns: () => [],
    data: () => [],
    showSelect: false,
    displayType: "table",
    showDisplayOption: true,
    icon: "lucide:database",
    pageSize: 10,
    pageSizes: () => [10, 20, 50],
    extendColumns: false,
    flatData: false,
    filter: () => [],
  }
);

const checkBoxSelect: VTColumn = {
  key: "checkbox",
  header: () =>
    h(resolveComponent("UiCheckbox"), {
      checked: table.select.isAllSelected()
        ? true
        : table.select.isIndeterminate()
        ? "indeterminate"
        : false,
      "onUpdate:checked": () => table.select.toggleAll(),
      ariaLabel: "Select all",
    }),
  renderAs: (item: any) =>
    h(resolveComponent("UiCheckbox"), {
      checked: table.select.isSelected(item.id),
      "onUpdate:checked": () => table.select.toggle(item.id),
      ariaLabel: "Select item",
    }),
  bindToPosition: "checkbox",
  enableSort: false,
  enableFilter: false,
  enableToggle: false,
  enableSearch: false,
};

const localColumns = computed(() => {
  const cols = [...props.columns];
  if (props.showSelect) {
    cols.unshift(checkBoxSelect);
  }
  return cols;
});

// Stavy pro vyhledávání a filtrování
const searchTerm = ref("");
const displayType = ref<VTDisplayOptions>(props.displayType);
const filters = ref<VTFilter[]>([{ column: "", operator: "===", value: "" }]);

// Funkce pro třídění
const sortBy = (key: string) => {
  table.setSort(key);
};

// Current filters
const activeFilters = computed(() =>
  filters.value.filter((f) => f.column && f.operator && f.value)
);

const addFilter = () => {
  if (filters.value.length >= 5) {
    // Max 5 filters
    return;
  }
  filters.value.push({ column: "", operator: "===", value: "" });
};

const updateFilterOptions = (index: number) => {
  filters.value[index].value = "";
};

const removeFilter = (index: number) => {
  filters.value.splice(index, 1);
};

const clearAllFilters = () => {
  filters.value = [{ column: "", operator: "===", value: "" }];
};

// Filter logic
const applySelectFilter = (item: any): boolean => {
  return activeFilters.value.every((filter) => {
    const itemValue = item[filter.column];
    const filterValue = filter.value;

    switch (filter.operator) {
      case "===":
        return (
          itemValue == filterValue ||
          String(itemValue).includes(String(filterValue))
        );
      case "!==":
        return (
          itemValue != filterValue &&
          !String(itemValue).includes(String(filterValue))
        );
      case ">":
        return itemValue >= filterValue;
      case "<":
        return itemValue <= filterValue;
      case "#":
        return String(itemValue).includes(String(filterValue.split(" ")[0]));
      default:
        return true;
    }
  });
};

/**
 * Get filters from query string
 * ?filter-column=value
 * @returns void
 */
const getFiltersFromQueryString = () => {
  const searchParams = new URLSearchParams(window.location.search);
  const queryFilters: VTFilter[] = [];
  searchParams.forEach((value, key) => {
    const column = key.split("-")[1];
    // Remove existing filter for the column
    filters.value = filters.value.filter((f) => f.column !== column);
    queryFilters.push({ column, operator: "===", value });
  });
  filters.value.push(...queryFilters);
};

const getFiltersFromProps = () => {
  if (props.filter.length > 0) {
    filters.value.push(...props.filter);
  }
};

getFiltersFromProps();
getFiltersFromQueryString();

const applySearchFilter = (item: any) => {
  const searchTermLower = searchTerm.value.toLowerCase();
  const searchableColumns = table.columns.relevant.filter(
    (col) => col.enableSearch
  );

  if (searchableColumns.length === 0) {
    return true;
  }

  return searchableColumns.some((col) => {
    const value = item[col.key];
    return (
      value != null && value.toString().toLowerCase().includes(searchTermLower)
    );
  });
};

// Funkce pro přepínání viditelnosti sloupce
const toggleColumnVisibility = (key: string) => {
  table.toggleVisibility(key);
};

// Inicializace tabulky
const table = useVisualTable({
  get data() {
    return props.data;
  },
  get columns() {
    return localColumns.value;
  },
  initialState: {
    pageSize: props.pageSize,
    sort: { key: "id", direction: "asc" },
  },
  state: {
    get filter() {
      return (item: any) => applySearchFilter(item) && applySelectFilter(item);
    },
  },
  options: {
    extendedColumns: props.extendColumns,
    flatData: props.flatData,
  },
});

const itemsPerPage = computed({
  get: () => String(table.paginator.pageSize),
  set: (value: string) => {
    table.paginator.pageSize = Number(value);
  },
});

// pre render filters for columns
const getFiltersForCols = () => {
  let filters: any = [];
  table.columns.filterable.forEach((col: any) => {
    filters[col.key] = table.getFilterForColumn(col.key);
  });

  return filters;
};

let columnsFilters = getFiltersForCols();
</script>

<template>
  <div>
    <UiInput
      v-model="searchTerm"
      placeholder="Search..."
      class="mb-2 block w-full bg-slate-200 transition focus:bg-slate-300/60 sm:hidden"
      @input="applySearchFilter"
    />
    <div class="mb-5">
      <div class="flex justify-between">
        <div class="space-x-0 sm:space-x-2">
          <UiInput
            v-model="searchTerm"
            placeholder="Search..."
            class="hidden w-40 bg-slate-200 transition focus:bg-slate-300/60 sm:inline-block"
            @input="applySearchFilter"
          />

          <div class="inline-flex h-10 items-center gap-2 rounded-md p-2">
            <UiButton
              @click="addFilter"
              variant="outline"
              size="icon-sm"
              class="size-6"
              :class="
                filters.length >= 5
                  ? 'cursor-not-allowed text-slate-400 hover:text-slate-400'
                  : 'hover:text-action'
              "
              ><Icon name="lucide:x" class="size-3 rotate-45"
            /></UiButton>
            <span class="text-sm font-medium">Filters</span>
            <UiButton
              @click="clearAllFilters"
              variant="outline"
              size="icon-sm"
              class="size-6 hover:text-red-500"
              ><Icon name="lucide:x" class="size-3"
            /></UiButton>
          </div>
        </div>

        <div class="inline-flex items-center gap-2">
          <!-- toggle columns visibility -->
          <UiDropdownMenu>
            <UiDropdownMenuTrigger as-child>
              <UiButton variant="outline">
                <span>View</span>
                <Icon name="lucide:chevron-down" class="size-4" />
              </UiButton>
            </UiDropdownMenuTrigger>

            <UiDropdownMenuContent
              :side-offset="10"
              align="start"
              class="w-[300px] md:w-[200px]"
            >
              <UiDropdownMenuLabel> Toggle Columns </UiDropdownMenuLabel>
              <UiDropdownMenuSeparator />

              <UiDropdownMenuGroup>
                <UiDropdownMenuCheckboxItem
                  v-for="column in table.columns.toggleable"
                  :key="column.key"
                  :checked="column.hidden !== true"
                  @update:checked="toggleColumnVisibility(column.key)"
                >
                  <span class="text-sm capitalize">{{ column.header }}</span>
                </UiDropdownMenuCheckboxItem>
              </UiDropdownMenuGroup>
            </UiDropdownMenuContent>
          </UiDropdownMenu>

          <UiButton
            v-if="showDisplayOption"
            variant="ghostline"
            size="icon"
            @click="displayType = 'table'"
            ><Icon
              name="solar:list-arrow-down-minimalistic-linear"
              class="size-5"
              :class="{
                'text-action': displayType === 'table',
                'text-muted-foreground': displayType !== 'table',
              }"
          /></UiButton>
          <UiButton
            v-if="showDisplayOption"
            variant="ghostline"
            size="icon"
            @click="displayType = 'grid'"
            ><Icon
              name="solar:posts-carousel-vertical-bold-duotone"
              class="size-5"
              :class="{
                'text-action': displayType === 'grid',
                'text-muted-foreground': displayType !== 'grid',
              }"
          /></UiButton>
        </div>
      </div>

      <!-- Generovana filtrace -->
      <div class="mb-2 mt-2 flex flex-wrap gap-x-2">
        <div v-for="(filter, index) in filters" :key="index">
          <div
            class="mt-1 flex w-min items-center gap-2 rounded bg-slate-200 p-1"
          >
            <UiSelect
              v-model="filter.column"
              name="column"
              @update:modelValue="updateFilterOptions(index)"
            >
              <UiSelectTrigger
                placeholder="Column"
                class="h-6 w-max min-w-16 rounded border-0 bg-transparent py-0 text-xs font-medium transition-colors hover:bg-slate-100"
              />
              <UiSelectContent>
                <UiSelectGroup>
                  <UiSelectItem
                    v-for="column in table.columns.filterable"
                    :key="column.key"
                    :value="column.key"
                  >
                    {{ column.header }}
                  </UiSelectItem>
                </UiSelectGroup>
              </UiSelectContent>
            </UiSelect>

            <UiSelect v-model="filter.operator" name="operator">
              <UiSelectTrigger
                icon="no-icon"
                class="flex h-6 w-6 justify-center rounded border-0 bg-white p-0 font-mono text-xs font-bold text-action transition-colors hover:bg-slate-100"
              />
              <UiSelectContent>
                <UiSelectGroup>
                  <UiSelectItem value="===">==</UiSelectItem>
                  <UiSelectItem value="!==">!=</UiSelectItem>
                  <UiSelectItem value=">">></UiSelectItem>
                  <UiSelectItem value="<"><</UiSelectItem>
                  <UiSelectItem value="#">#</UiSelectItem>
                </UiSelectGroup>
              </UiSelectContent>
            </UiSelect>

            <UiSelect v-model="filter.value" name="value">
              <UiSelectTrigger
                placeholder="Value"
                class="h-6 w-max min-w-16 rounded border-0 bg-transparent py-0 text-xs font-medium text-action transition-colors hover:bg-slate-100"
              />
              <UiSelectContent>
                <UiSelectLabel
                  v-show="!filter.column"
                  class="pl-2 text-xs font-normal italic text-slate-400"
                  >No Column Selected</UiSelectLabel
                >
                <UiSelectGroup>
                  <UiSelectItem
                    v-for="option in columnsFilters[filter.column]"
                    :key="String(option)"
                    :value="String(option)"
                    >{{ option }}</UiSelectItem
                  >
                </UiSelectGroup>
              </UiSelectContent>
            </UiSelect>
            <UiButton
              @click="removeFilter(index)"
              class="size-6 border-slate-300 bg-transparent hover:text-red-500"
              variant="outline"
              size="icon-sm"
              ><Icon name="lucide:x" class="size-3"
            /></UiButton>
          </div>
        </div>
      </div>
    </div>

    <!-- Tabulka -->
    <template v-if="displayType === 'table'">
      <UiScrollArea class="calc-width" orientation="horizontal">
        <UiTable>
          <UiTableHeader>
            <UiTableRow>
              <UiTableHead
                v-for="column in table.columns.visible"
                :key="column.key"
                @click="sortBy(column.key)"
                :class="{
                  'cursor-pointer': column.enableSort,
                  'hover:text-muted-foreground': !column.enableSort,
                }"
              >
                <div class="flex items-center">
                  <VTRender :content="column.header" :item="table" />
                  <Icon
                    v-if="
                      table.sortState.value.key === column.key &&
                      column.enableSort
                    "
                    :name="
                      table.sortState.value.direction === 'asc'
                        ? 'solar:double-alt-arrow-up-line-duotone'
                        : 'solar:double-alt-arrow-down-line-duotone'
                    "
                    class="size-4"
                  />
                </div>
              </UiTableHead>
            </UiTableRow>
          </UiTableHeader>

          <UiTableBody>
            <UiTableRow
              v-for="row in table.data.paginated"
              :key="row.id"
              :class="table.getRowStyle(row)"
            >
              <UiTableCell
                v-for="column in table.columns.visible"
                :key="column.key"
                :class="column.additionalColumn ? 'text-slate-400' : ''"
              >
                <VTRender :content="column.content()" :item="row" />
              </UiTableCell>
            </UiTableRow>

            <UiTableEmpty
              v-if="table.data.paginated.length === 0"
              :colspan="table.columns.visible.length"
            >
              <div
                class="flex w-full flex-col items-center justify-center gap-5 py-5"
              >
                <Icon
                  name="lucide:database"
                  class="h-12 w-12 text-muted-foreground"
                />
                <span class="mt-2">No data available.</span>
              </div>
            </UiTableEmpty>
          </UiTableBody>
        </UiTable>
      </UiScrollArea>
    </template>

    <!-- Grid -->
    <template v-else>
      <div class="mb-3 flex items-center justify-between border-b px-3 py-1.5">
        <VTRender :content="table.columns.bind('checkbox')?.header" />
        <VTRender
          :content="table.columns.bind('action')?.header"
          :item="table"
        />
      </div>

      <div class="grid-container gap-6">
        <div
          v-for="row in table.data.paginated"
          :key="row.id"
          class="grid rounded-lg p-6 transition"
          :class="table.getRowStyle(row) ?? 'bg-white hover:bg-white/50'"
        >
          <div
            class="relative mb-4 flex items-center gap-3 self-start font-semibold text-action"
          >
            <Icon :name="props.icon" class="size-6" />
            <VTRender
              :content="table.columns.bind('name')?.content()"
              :item="row"
              class="max-w-[70%]"
            />

            <VTRender
              :content="table.columns.bind('checkbox')?.content()"
              :item="row"
              class="absolute right-0 top-0"
            />
          </div>

          <div class="divide-y text-sm font-medium text-slate-600">
            <div
              v-for="col in table.columns.unbinded"
              :key="col.key"
              class="flex items-center justify-between px-3 py-2"
            >
              <span class="">{{ col.header }}</span>
              <span class="font-normal text-slate-500">
                <VTRender :content="col.content()" :item="row" />
              </span>
            </div>
          </div>

          <div class="mt-2 flex items-center self-end">
            <VTRender
              :content="table.columns.bind('status')?.content()"
              :item="row"
            />
            <VTRender
              :content="table.columns.bind('action')?.content()"
              :item="row"
              class="ml-auto"
            />
          </div>
        </div>
      </div>
    </template>

    <!-- paginator -->
    <div
      class="mt-4 flex flex-col justify-between gap-1 sm:flex-row sm:items-center"
    >
      <div class="flex items-center gap-3">
        <div class="whitespace-nowrap text-sm font-medium text-foreground">
          Page {{ table.paginator.currentPage }} of
          {{ table.paginator.totalPages }}
        </div>

        <UiSelect v-model="itemsPerPage">
          <UiSelectTrigger class="h-9 w-[70px]">
            {{ table.paginator.pageSize }}
          </UiSelectTrigger>
          <UiSelectContent>
            <UiSelectGroup>
              <UiSelectItem
                v-for="size in props.pageSizes"
                :value="String(size)"
                :text="String(size)"
              />
            </UiSelectGroup>
          </UiSelectContent>
        </UiSelect>

        <div class="space-x-2">
          <UiButton
            variant="outline"
            size="icon"
            title="First page"
            class="h-9 w-9 p-0"
            @click="table.paginator.currentPage = 1"
            :disabled="table.paginator.first"
            ><Icon name="solar:double-alt-arrow-left-linear"
          /></UiButton>

          <UiButton
            variant="outline"
            size="icon"
            class="h-9 w-9 p-0"
            title="Previous page"
            @click="table.paginator.prevPage()"
            :disabled="table.paginator.first"
            ><Icon name="solar:alt-arrow-left-linear"
          /></UiButton>

          <UiButton
            variant="outline"
            size="icon"
            class="h-9 w-9 p-0"
            title="Next page"
            @click="table.paginator.nextPage()"
            :disabled="table.paginator.last"
            ><Icon name="solar:alt-arrow-right-linear"
          /></UiButton>

          <UiButton
            variant="outline"
            size="icon"
            class="h-9 w-9 p-0"
            title="Last page"
            @click="table.paginator.currentPage = table.paginator.totalPages"
            :disabled="table.paginator.last"
            ><Icon name="solar:double-alt-arrow-right-linear"
          /></UiButton>
        </div>
      </div>
      <span
        v-show="table.data.filtered.length !== table.data.original.length"
        class="text-normal text-sm italic text-slate-500"
        >{{ table.data.filtered.length }} items filtered</span
      >
    </div>
  </div>
</template>
<style scoped>
.grid-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
</style>

ParentComponent.vue

This is an example of how to use the VisualTable component in a parent component. Showcasing how to pass data and columns to the table. Example below shows a full implementation of the column object.

<script setup lang="ts">
  import type { VTColumn, VTFilter } from "~/types";

const data = [...someData fetch from API];


  const initFilter: VTFilter[] = [
    {
      column: "status",
      operator: "===",
      value: "ACTIVE",
    },
  ];


  const columns: VTColumn[] = [
    { key: "id", header: "ID", bindToPosition: "id", enableSearch: false, hidden: true },
    {
      key: "name",
      header: "Name",
      bindToPosition: "name",
      renderAs: (item) => {
        return h(
          resolveComponent("NuxtLink"),
          {
            to: `/servers/physical/${item.id}`,
            class: "hover:underline font-medium",
          },
          () => item.name
        );
      },
      enableToggle: false,
    },
    {
      key: "importance",
      header: "Importance",
      enableFilter: true,
      hidden: true,
      renderAs: (item) => {
        if (!item.importance) {
          return h("span", { class: "text-gray-500" }, "N/A");
        }

        const badge = h(
          resolveComponent("UiBadge"),
          {
            type: "tag",
            color: importanceColor(item.importance),
            class: "relative",
          },
          () => item.importance
        );

        return h(
          resolveComponent("UiTooltip"),
          { disableClosingTrigger: true },
          {
            trigger: () => h(resolveComponent("UiTooltipTrigger"), { asChild: true }, () => badge),
            content: () =>
              h(
                resolveComponent("UiTooltipContent"),
                {
                  class: `text-xs font-medium text-slate-800 border-${importanceColor(item.importance)}`,
                },
                () => h("p", importanceText(item.importance))
              ),
          }
        );
      },
    },
    {
      key: "tags",
      header: "Tags",
      enableFilter: true,
      hidden: true,
      renderAs: (item) => {
        return h(
          "div",
          {
            class: "flex flex-wrap gap-1",
          },
          item.tags?.map((tag: any) => {
            return h(
              resolveComponent("UiBadge"),
              {
                type: "label",
                color: tagColor(tag),
              },
              () => {
                return tag;
              }
            );
          })
        );
      },
    },
    {
      key: "provider_id",
      hidden: true,
      header: "Provider ID",
      renderAs: (item) => {
        return item.provider_id || "N/A";
      },
    },
    {
      key: "our_id",
      header: "Our ID",
      hidden: true,
    },
    { key: "team_name", header: "Team", enableFilter: true, hidden: true },
    {
      key: 'power_consumption',
      header: 'Power',
      renderAs: (item) => {
        return item.power_consumption ? `${item.power_consumption} W` : 'N/A';
      }
    },
    {
      key: "temp_cpu_formatted",
      header: "Temp CPU",
      renderAs: (item) => {
        if (!item.temp_cpu_formatted) {
          return h("span", { class: "text-gray-500" }, "N/A");
        }
        const temps = item.temp_cpu_formatted.split(", ");
        return h(
          "div",
          { class: "flex items-center space-x-1" },
          temps.map((temp: string) => h("span", { class: colorViaTemp(temp, 70, 80) }, temp))
        );
      },
    },
    {
      key: "temp_in_out_formatted",
      header: "Temp In/Out",
      renderAs: (item) => {
        if (!item.temp_in_out_formatted) {
          return h("span", { class: "text-gray-500" }, "N/A");
        }

        const temps = item.temp_in_out_formatted.split(" ");
        return h("div", { class: "flex items_center space-x-1" }, [
          h("span", { class: colorViaTemp(temps[0], 30, 35) }, temps[0]),
          h("span", { class: "text-gray-500" }, "/"),
          h("span", { class: colorViaTemp(temps[2], 45, 55) }, temps[2]),
        ]);
      },
    },
    { key: "cpu", header: "CPU", renderAs: (item) => item.cpu || "N/A" },
    { key: "ram", header: "RAM", renderAs: (item) => (item.ram ? `${item.ram}` : "N/A") },
    { key: "disk", header: "Disk", renderAs: (item) => (item.disk ? `${item.disk}` : "N/A") },
    {
      key: "status",
      header: "Status",
      bindToPosition: "status",
      enableFilter: true,
      renderAs: (item) => {
        return h(
          resolveComponent("UiBadge"),
          {
            type: "role",
            isActive: item.status === "ACTIVE" ? true : false,
          },
          () => item.status
        );
      },
    },
    {
      key: "management_url",
      header: "Management",
      enableSort: false,
      enableSearch: false,
      enableToggle: false,
      renderAs: (item) => {
        if (!item.management_url) {
          return h("span", "N/A");
        }
        return h(
          resolveComponent("UiButton"),
          {
            variant: "outline",
            size: "sm",
            onClick: () => window.open(item.management_url, "_blank"),
          },
          () => "Management"
        );
      },
    },
    {
      key: "actions",
      header: (item) => {
        return h(resolveComponent("ActionDropDown"), {
          items: [
            {
              title: "Comparator",
              click: () => useComparator().multiple(Array.from(item.state.select), "server"),
              class: "cursor-pointer",
              icon: "solar:colour-tuneing-line-duotone",
            },
          ],
        });
      },
      renderAs: (item) => {
        let items = [];
        if (item.management_url) {
          items.push({
            title: "Management",
            click: () => window.open(item.management_url, "_blank"),
            class: "cursor-pointer",
            icon: "solar:server-bold-duotone",
          });
        }
        return h(resolveComponent("ActionDropDown"), {
          items: [
            {
              title: "Details",
              click: () => navigateTo(`/servers/${item.id}`),
              class: "cursor-pointer",
              icon: "solar:info-circle-line-duotone",
            },
            {
              title: "Comparator",
              click: () => useComparator().add(item.id, "server"),
              class: "cursor-pointer",
              icon: "solar:colour-tuneing-line-duotone",
            },
            ...items,
          ],
          label: item.name,
        });
      },
      bindToPosition: "action",
      enableToggle: false,
      enableSort: false,
      enableSearch: false,
    },
  ];
</script>
<template>
  <VisualTable2
    :data="physical"
    :columns="columns"
    icon="solar:server-bold-duotone"
    displayType="table"
    :showSelect="true"
    :extendColumns="true"
    :filter="initFilter"
  />
</template>