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>
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.
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>
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.
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 tablecolumns- Columns definition for the table
initialState- Initial state of the tablesort- Initial sort statefilter- Initial filter functionpageSize- Initial page sizecurrentPage- Initial current page
state- Current state of the tablesort- Current sort statefilter- Current filter functionpageSize- Current page sizecurrentPage- Current current page
options- Additional optionsextendedColumns- Table will automaticly add columns from data object if they are not defined in columnsflatData- 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.
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 displayheader- Header for the column
renderAs- Render your data in different format or as a componentbindToPosition- 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 renderHeaderas 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 defaultrelevant- This option is useful when combination ofenableToggleandhiddenis activated. Whenrelevantis set totruedata 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 enablingextendedColumnsconfig option.
content() function. This function will return the content of the column.Returned properties
${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 theVisualTabledata.paginated- Data that is displayed in the current pagedata.filtered- Data that is filtered by the filter functiondata.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 theVisualTablecolumns.visible- Columns that are visiblecolumns.relevant- Columns that are relevantcolumns.filterable- Columns that are filterablecolumns.toggleable- Columns that are toggleablecolumns.unbinded- Columns that are not bind to any positioncolumns.bind(key: string)- Get column by thebindToPositionkey
select
This object is used to work with select state of the table.
select.selected- Array of selected itemsselect.toggle(id: string|number)- Toggle select state of the itemselect.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 selectedselect.isAllSelected()- Check if all items are selected (all original items)select.isAllOnPageSelected()- Check if all items on the current page are selectedselect.isIndeterminate()- Check if select state is indeterminate (some items are selected)
state
Returns the current state of the table.
state.sort- Current sort statestate.filter- Current filter functionstate.select- Current select state
paginator
Object that provides pagination functionality.
paginator.first- Check if current page is the first pagepaginator.last- Check if current page is the last pagepaginator.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 sizepaginator.nextPage()- Go to the next pagepaginator.prevPage()- Go to the previous page
Additional Functions
setSort(key: string)- Set sort for the column keysetFilter(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 columngetFilterForColumn(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 forcheckedstyle)
VTRender
VTRender expects two props:
content- Content that should be renderedcolumn.content()(datakey or renderAs())item- Item will be passed torenderAsfunction 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" />
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>