chore(lite): merge old repo to XO
This commit is contained in:
parent
44ff5d0e4d
commit
b8c9770d43
1
@xen-orchestra/lite/.gitignore
vendored
1
@xen-orchestra/lite/.gitignore
vendored
@ -27,3 +27,4 @@ coverage
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
.npmrc
|
||||
|
2
@xen-orchestra/lite/.npmrc.dist
Normal file
2
@xen-orchestra/lite/.npmrc.dist
Normal file
@ -0,0 +1,2 @@
|
||||
@fortawesome:registry=https://npm.fontawesome.com/
|
||||
//npm.fontawesome.com/:_authToken=INSERT_FONT_AWESOME_PRO_TOKEN_HERE
|
@ -89,6 +89,8 @@ const fontSize = ref("2rem");
|
||||
|
||||
### Icons
|
||||
|
||||
This project is using Font Awesome Pro 6.
|
||||
|
||||
Here is how to use an icon in your template.
|
||||
|
||||
Note: `FontAwesomeIcon` is a global component that does not need to be imported.
|
||||
@ -105,6 +107,17 @@ import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||
</script>
|
||||
```
|
||||
|
||||
#### Font weight <=> Style name
|
||||
|
||||
Here is the equivalent between font weight and style name.
|
||||
|
||||
| Style name | Font weight |
|
||||
|------------|-------------|
|
||||
| Solid | 900 |
|
||||
| Regular | 400 |
|
||||
| Light | 300 |
|
||||
| Thin | 100 |
|
||||
|
||||
### CSS
|
||||
|
||||
Always use `rem` unit (`1rem` = `10px`)
|
||||
|
@ -11,9 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/pro-light-svg-icons": "^6.1.2",
|
||||
"@fortawesome/pro-regular-svg-icons": "^6.1.2",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.1.2",
|
||||
"@fortawesome/pro-thin-svg-icons": "^6.1.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.1",
|
||||
"@novnc/novnc": "^1.3.0",
|
||||
"@vueuse/core": "^8.7.5",
|
||||
|
@ -5,7 +5,6 @@
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
font-family: Poppins, sans-serif;
|
||||
font-size: 1.3rem;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
@ -9,6 +9,7 @@ html {
|
||||
box-sizing: inherit;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
font-family: Poppins, sans-serif;
|
||||
}
|
||||
|
||||
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
|
||||
|
@ -1,4 +1,4 @@
|
||||
body {
|
||||
:root {
|
||||
--color-blue-scale-000: #000000;
|
||||
--color-blue-scale-100: #1A1B38;
|
||||
--color-blue-scale-200: #595A6F;
|
||||
@ -30,13 +30,13 @@ body {
|
||||
--color-orange-world-d40: #554F94;
|
||||
--color-orange-world-d60: #383563;
|
||||
|
||||
--color-red-vates-l60: #D1CEFB;
|
||||
--color-red-vates-l40: #BBB5F9;
|
||||
--color-red-vates-l20: #A39DF8;
|
||||
--color-red-vates-base: #8F84FF;
|
||||
--color-red-vates-d20: #716AC6;
|
||||
--color-red-vates-d40: #554F94;
|
||||
--color-red-vates-d60: #383563;
|
||||
--color-red-vates-l60: #DDA5A7;
|
||||
--color-red-vates-l40: #CE787C;
|
||||
--color-red-vates-l20: #BF4F51;
|
||||
--color-red-vates-base: #BE1621;
|
||||
--color-red-vates-d20: #8E2221;
|
||||
--color-red-vates-d40: #6A1919;
|
||||
--color-red-vates-d60: #471010;
|
||||
|
||||
--color-grayscale-200: #585757;
|
||||
|
||||
@ -51,13 +51,9 @@ body {
|
||||
--shadow-200: 0 0.1rem 0.3rem rgba(20, 20, 30, 0.1), 0 0.2rem 0.1rem rgba(20, 20, 30, 0.06), 0 0.1rem 0.1rem rgba(20, 20, 30, 0.08);
|
||||
--shadow-300: 0 0.3rem 0.5rem rgba(20, 20, 30, 0.1), 0 0.1rem 1.8rem rgba(20, 20, 30, 0.06), 0 0.6rem 1.0rem rgba(20, 20, 30, 0.08);
|
||||
--shadow-400: 0 1.1rem 1.5rem rgba(20, 20, 30, 0.1), 0 0.9rem 4.6rem rgba(20, 20, 30, 0.06), 0 2.4rem 3.8rem rgba(20, 20, 30, 0.04);
|
||||
|
||||
--form-control-disabled: var(--color-blue-scale-200);
|
||||
--form-background: var(--color-blue-scale-500);
|
||||
--form-control-color: var(--color-blue-scale-100);
|
||||
}
|
||||
|
||||
body.dark {
|
||||
:root.dark {
|
||||
--color-blue-scale-000: #FFFFFF;
|
||||
--color-blue-scale-100: #E5E5E7;
|
||||
--color-blue-scale-200: #9899A5;
|
||||
|
48
@xen-orchestra/lite/src/components/AccountButton.vue
Normal file
48
@xen-orchestra/lite/src/components/AccountButton.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<button class="account-button">
|
||||
<FontAwesomeIcon class="user-icon" :icon="faCircleUser" />
|
||||
<FontAwesomeIcon class="dropdown-icon" :icon="faAngleDown" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faAngleDown } from "@fortawesome/pro-light-svg-icons";
|
||||
import { faCircleUser } from "@fortawesome/pro-solid-svg-icons";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.account-button {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border-radius: 0.8rem;
|
||||
border: none;
|
||||
color: var(--color-blue-scale-100);
|
||||
background-color: var(--background-color-secondary);
|
||||
|
||||
&:disabled {
|
||||
color: var(--color-blue-scale-400);
|
||||
}
|
||||
|
||||
&:not(:disabled) {
|
||||
cursor: pointer;
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: var(--background-color-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-extra-blue-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-icon {
|
||||
font-size: 2.4rem;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
</style>
|
@ -19,7 +19,7 @@ import { useXenApiStore } from "@/stores/xen-api.store";
|
||||
const router = useRouter();
|
||||
|
||||
const toggleTheme = () => {
|
||||
document.body.classList.toggle("dark");
|
||||
document.documentElement.classList.toggle("dark");
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
|
@ -36,7 +36,7 @@ async function handleSubmit() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.form-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -3,118 +3,74 @@
|
||||
<UiFilter
|
||||
v-for="filter in activeFilters"
|
||||
:key="filter"
|
||||
@edit="editFilter(filter)"
|
||||
@remove="emit('removeFilter', filter)"
|
||||
>
|
||||
{{ filter }}
|
||||
</UiFilter>
|
||||
|
||||
<UiButton
|
||||
:icon-left="faPlus"
|
||||
class="add-filter"
|
||||
color="action"
|
||||
@click="open"
|
||||
>
|
||||
<UiButton :icon="faPlus" class="add-filter" color="secondary" @click="open">
|
||||
Add filter
|
||||
</UiButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-if="isOpen">
|
||||
Add a filter
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="form-row" v-for="(item, index) in items" :key="index">
|
||||
<span v-if="items.length > 1" style="width: 2rem">{{
|
||||
index > 0 ? "OR" : ""
|
||||
}}</span>
|
||||
<FormWidget
|
||||
v-if="!isAdvancedModeEnabled"
|
||||
:before="filterIconForIndex(index)"
|
||||
>
|
||||
<select v-model="items[index].selectedFilter">
|
||||
<option v-if="!selectedFilter"></option>
|
||||
<option
|
||||
v-for="availableFilter in availableFilters"
|
||||
:key="availableFilter.property"
|
||||
:value="availableFilter"
|
||||
>
|
||||
{{ availableFilter.label ?? availableFilter.property }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<div class="rows">
|
||||
<CollectionFilterRow
|
||||
v-for="(newFilter, index) in newFilters"
|
||||
:key="newFilter.id"
|
||||
v-model="newFilters[index]"
|
||||
:available-filters="availableFilters"
|
||||
@remove="removeNewFilter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormWidget
|
||||
v-if="!isAdvancedModeEnabled && items[index].selectedFilter"
|
||||
>
|
||||
<select v-model="items[index].filterPattern">
|
||||
<option
|
||||
v-for="comparison in getComparisonsForIndex(index)"
|
||||
:key="comparison.pattern"
|
||||
:value="comparison.pattern"
|
||||
>
|
||||
{{ comparison.label }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
|
||||
<FormWidget
|
||||
v-if="isFilterTakingValue(index)"
|
||||
:after="getComparisonForIndex(index)?.after"
|
||||
:before="getComparisonForIndex(index)?.before"
|
||||
>
|
||||
<input v-model="items[index].filterValue" />
|
||||
</FormWidget>
|
||||
<br />
|
||||
<div
|
||||
v-if="newFilters.some((filter) => filter.isAdvanced)"
|
||||
class="available-properties"
|
||||
>
|
||||
Available properties for advanced filter:
|
||||
<div class="properties">
|
||||
<UiBadge
|
||||
v-for="(filter, property) in availableFilters"
|
||||
:key="property"
|
||||
:icon="getFilterIcon(filter)"
|
||||
>
|
||||
{{ property }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiButtonGroup>
|
||||
<UiButton color="action" @click="addItem">+OR</UiButton>
|
||||
|
||||
<UiButton
|
||||
type="submit"
|
||||
:disabled="!generatedFilter || !isGeneratedFilterValid"
|
||||
>
|
||||
Add
|
||||
</UiButton>
|
||||
|
||||
<UiButton color="action" type="button" @click="handleCancel">
|
||||
Cancel
|
||||
<UiButton color="secondary" @click="addNewFilter">+OR</UiButton>
|
||||
<UiButton :disabled="!isFilterValid" type="submit">
|
||||
{{ editedFilter ? "Update" : "Add" }}
|
||||
</UiButton>
|
||||
<UiButton color="secondary" @click="handleCancel">Cancel</UiButton>
|
||||
</UiButtonGroup>
|
||||
</form>
|
||||
|
||||
<div style="margin-top: 1rem">
|
||||
<UiButton
|
||||
v-if="!isAdvancedModeEnabled"
|
||||
@click="activateAdvancedMode"
|
||||
color="action"
|
||||
>
|
||||
Switch to advanced mode...
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as CM from "complex-matcher";
|
||||
import { Or, parse } from "complex-matcher";
|
||||
import { computed, ref } from "vue";
|
||||
import type {
|
||||
AvailableFilter,
|
||||
FilterComparisons,
|
||||
FilterType,
|
||||
} from "@/types/filter";
|
||||
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faFont, faHashtag, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import type { Filters, NewFilter } from "@/types/filter";
|
||||
import { faPlus } from "@fortawesome/pro-solid-svg-icons";
|
||||
import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
|
||||
import UiBadge from "@/components/ui/UiBadge.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import UiFilter from "@/components/ui/UiFilter.vue";
|
||||
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import { escapeRegExp } from "@/libs/utils";
|
||||
import { getFilterIcon } from "@/libs/utils";
|
||||
|
||||
defineProps<{
|
||||
availableFilters: AvailableFilter[];
|
||||
activeFilters: string[];
|
||||
availableFilters: Filters;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -122,291 +78,113 @@ const emit = defineEmits<{
|
||||
(event: "removeFilter", filter: string): void;
|
||||
}>();
|
||||
|
||||
const { open, close, isOpen } = useModal();
|
||||
const { isOpen, open, close } = useModal();
|
||||
const newFilters = ref<NewFilter[]>([]);
|
||||
let newFilterId = 0;
|
||||
|
||||
const getComparisonsForIndex = (index: number) => {
|
||||
const selectedFilter = items.value[index]?.selectedFilter;
|
||||
|
||||
if (!selectedFilter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (selectedFilter.type) {
|
||||
case "string":
|
||||
return [
|
||||
{ label: "contains", pattern: "%p:%v", default: true },
|
||||
{ label: "equals", pattern: "%p:/^%v$/i", escape: true },
|
||||
{ label: "starts with", pattern: "%p:/^%v/i", escape: true },
|
||||
{ label: "ends with", pattern: "%p:/%v$/i", escape: true },
|
||||
{
|
||||
label: "matches regex",
|
||||
pattern: "%p:/%v/i",
|
||||
before: "/",
|
||||
after: "/i",
|
||||
},
|
||||
];
|
||||
case "number":
|
||||
return [
|
||||
{ label: "<", pattern: "%p:<%v" },
|
||||
{ label: "<=", pattern: "%p:<=%v" },
|
||||
{ label: "=", pattern: "%p:%v", default: true },
|
||||
{ label: ">=", pattern: "%p:>=%v" },
|
||||
{ label: ">", pattern: "%p:>%v" },
|
||||
];
|
||||
case "boolean":
|
||||
return [
|
||||
{ label: "is true", pattern: "%p?", default: true },
|
||||
{ label: "is false", pattern: "!%p?" },
|
||||
];
|
||||
case "enum":
|
||||
return selectedFilter.choices.map((choice, index) => ({
|
||||
label: choice,
|
||||
pattern: `%p:/^${escapeRegExp(choice)}$/`,
|
||||
default: index === 0,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const comparisons = computed<FilterComparisons>(() => ({
|
||||
string: [
|
||||
{ label: "contains", pattern: "%p:%v", default: true },
|
||||
{ label: "equals", pattern: "%p:/^%v$/i", escape: true },
|
||||
{ label: "starts with", pattern: "%p:/^%v/i", escape: true },
|
||||
{ label: "ends with", pattern: "%p:/%v$/i", escape: true },
|
||||
{
|
||||
label: "matches regex",
|
||||
pattern: "%p:/%v/i",
|
||||
before: "/",
|
||||
after: "/i",
|
||||
},
|
||||
],
|
||||
number: [
|
||||
{ label: "<", pattern: "%p:<%v" },
|
||||
{ label: "<=", pattern: "%p:<=%v" },
|
||||
{ label: "=", pattern: "%p:%v", default: true },
|
||||
{ label: ">=", pattern: "%p:>=%v" },
|
||||
{ label: ">", pattern: "%p:>%v" },
|
||||
],
|
||||
boolean: [
|
||||
{ label: "is true", pattern: "%p?", default: true },
|
||||
{ label: "is false", pattern: "!%p?" },
|
||||
],
|
||||
enum:
|
||||
selectedFilter.value?.type !== "enum"
|
||||
? []
|
||||
: selectedFilter.value.choices.map((choice, index) => ({
|
||||
label: choice,
|
||||
pattern: `%p:/^${escapeRegExp(choice)}$/`,
|
||||
default: index === 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
const isAdvancedModeEnabled = ref(false);
|
||||
|
||||
const selectedFilter = ref<AvailableFilter>();
|
||||
|
||||
const filterPattern = ref<string>();
|
||||
|
||||
const currentComparison = computed(() => {
|
||||
if (!selectedFilter.value) {
|
||||
return;
|
||||
}
|
||||
return comparisons.value[selectedFilter.value.type].find(
|
||||
(comparison) => comparison.pattern === filterPattern.value
|
||||
);
|
||||
});
|
||||
|
||||
const getComparisonForIndex = (index: number) => {
|
||||
const type = items.value[index]?.selectedFilter?.type;
|
||||
const pattern = items.value[index]?.filterPattern;
|
||||
|
||||
if (!type || !pattern) {
|
||||
return;
|
||||
}
|
||||
|
||||
return comparisons.value[type].find(
|
||||
(comparison) => comparison.pattern === pattern
|
||||
);
|
||||
};
|
||||
|
||||
const getDefaultPatternForFilterType = (filterType?: FilterType) => {
|
||||
if (!filterType) {
|
||||
return;
|
||||
}
|
||||
|
||||
return comparisons.value[filterType].find(({ default: def }) => def)?.pattern;
|
||||
};
|
||||
|
||||
const items = ref<
|
||||
{
|
||||
selectedFilter: AvailableFilter | undefined;
|
||||
filterPattern: string;
|
||||
filterValue: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const filterIconForIndex = (index: number) => {
|
||||
const selectedFilter = items.value[index]?.selectedFilter;
|
||||
|
||||
if (!selectedFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFilter.icon) {
|
||||
return selectedFilter.icon;
|
||||
}
|
||||
|
||||
switch (selectedFilter.type) {
|
||||
case "string":
|
||||
return faFont;
|
||||
case "number":
|
||||
return faHashtag;
|
||||
case "boolean":
|
||||
return faSquareCheck;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// watch(selectedFilter, (newSelectedFilter) => {
|
||||
// if (newSelectedFilter && !items.value.length) {
|
||||
// items.value.push({
|
||||
// filterPattern:
|
||||
// getDefaultPatternForFilterType(newSelectedFilter.type) || "",
|
||||
// filterValue: "",
|
||||
// });
|
||||
// }
|
||||
// filterPattern.value = getDefaultPatternForFilterType(newSelectedFilter?.type);
|
||||
// });
|
||||
|
||||
const addItem = () => {
|
||||
items.value.push({
|
||||
selectedFilter: undefined,
|
||||
filterPattern: "",
|
||||
filterValue: "",
|
||||
const addNewFilter = () =>
|
||||
newFilters.value.push({
|
||||
id: newFilterId++,
|
||||
content: "",
|
||||
isAdvanced: false,
|
||||
builder: { property: "", comparison: "", value: "", negate: false },
|
||||
});
|
||||
|
||||
const removeNewFilter = (id: number) => {
|
||||
const index = newFilters.value.findIndex((newFilter) => newFilter.id === id);
|
||||
if (index >= 0) {
|
||||
newFilters.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
addItem();
|
||||
|
||||
const isFilterTakingValue = (index: number) => {
|
||||
const comparison = getComparisonForIndex(index);
|
||||
|
||||
return comparison?.pattern.includes("%v") || isAdvancedModeEnabled.value;
|
||||
};
|
||||
|
||||
// const isFilterTakingValue = computed(() => {
|
||||
// return (
|
||||
// currentComparison.value?.pattern.includes("%v") ||
|
||||
// isAdvancedModeEnabled.value
|
||||
// );
|
||||
// });
|
||||
|
||||
const filterValue = ref<string>();
|
||||
addNewFilter();
|
||||
|
||||
const generatedFilter = computed(() => {
|
||||
if (items.value.length === 0) {
|
||||
return;
|
||||
const filters = newFilters.value.filter(
|
||||
(newFilter) => newFilter.content !== ""
|
||||
);
|
||||
|
||||
if (filters.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const result = items.value
|
||||
.map((item) => {
|
||||
return item.filterPattern
|
||||
.replace("%p", item.selectedFilter?.property)
|
||||
.replace(
|
||||
"%v",
|
||||
currentComparison.value?.escape
|
||||
? escapeRegExp(item.filterValue)
|
||||
: item.filterValue
|
||||
);
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
if (items.value.length === 1) {
|
||||
return result;
|
||||
if (filters.length === 1) {
|
||||
return filters[0].content;
|
||||
}
|
||||
|
||||
return `|(${result})`;
|
||||
return `|(${filters.map((filter) => filter.content).join(" ")})`;
|
||||
});
|
||||
|
||||
// const generatedFilter2 = computed(() => {
|
||||
// if (isAdvancedModeEnabled.value) {
|
||||
// return filterValue.value;
|
||||
// }
|
||||
//
|
||||
// if (!selectedFilter.value || !filterPattern.value) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// if (isFilterTakingValue.value) {
|
||||
// if (!filterValue.value) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// return filterPattern.value
|
||||
// .replace("%p", selectedFilter.value.property)
|
||||
// .replace(
|
||||
// "%v",
|
||||
// currentComparison.value?.escape
|
||||
// ? escapeRegExp(filterValue.value)
|
||||
// : filterValue.value
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// return filterPattern.value.replace("%p", selectedFilter.value.property);
|
||||
// });
|
||||
const isFilterValid = computed(() => generatedFilter.value !== "");
|
||||
|
||||
const isGeneratedFilterValid = computed(() => {
|
||||
if (!generatedFilter.value) {
|
||||
return true;
|
||||
}
|
||||
const editedFilter = ref();
|
||||
|
||||
try {
|
||||
if (generatedFilter.value) {
|
||||
CM.parse(generatedFilter.value);
|
||||
}
|
||||
const editFilter = (filter: string) => {
|
||||
const parsedFilter = parse(filter);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const nodes =
|
||||
parsedFilter instanceof Or ? parsedFilter.children : [parsedFilter];
|
||||
|
||||
const activateAdvancedMode = () => {
|
||||
filterValue.value = generatedFilter.value;
|
||||
isAdvancedModeEnabled.value = true;
|
||||
newFilters.value = nodes.map((node) => ({
|
||||
id: newFilterId++,
|
||||
content: node.toString(),
|
||||
isAdvanced: true,
|
||||
builder: { property: "", comparison: "", value: "", negate: false },
|
||||
}));
|
||||
editedFilter.value = filter;
|
||||
open();
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
items.value = [];
|
||||
addItem();
|
||||
selectedFilter.value = undefined;
|
||||
filterValue.value = undefined;
|
||||
isAdvancedModeEnabled.value = false;
|
||||
close();
|
||||
const reset = () => {
|
||||
editedFilter.value = "";
|
||||
newFilters.value = [];
|
||||
addNewFilter();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (generatedFilter.value) {
|
||||
emit("addFilter", generatedFilter.value);
|
||||
resetAndClose();
|
||||
if (editedFilter.value) {
|
||||
emit("removeFilter", editedFilter.value);
|
||||
}
|
||||
emit("addFilter", generatedFilter.value);
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
resetAndClose();
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.add-filter {
|
||||
height: 3.4rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
.properties {
|
||||
font-size: 1.6rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
ul {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.available-properties {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.properties {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0.6rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
260
@xen-orchestra/lite/src/components/CollectionFilterRow.vue
Normal file
260
@xen-orchestra/lite/src/components/CollectionFilterRow.vue
Normal file
@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="collection-filter-row">
|
||||
<span class="or">OR</span>
|
||||
<FormWidget v-if="newFilter.isAdvanced" style="flex: 1">
|
||||
<input v-model="newFilter.content" />
|
||||
</FormWidget>
|
||||
<template v-else>
|
||||
<FormWidget :before="currentFilterIcon">
|
||||
<select v-model="newFilter.builder.property">
|
||||
<option v-if="!newFilter.builder.property" value="">
|
||||
- Property -
|
||||
</option>
|
||||
<option
|
||||
v-for="(filter, property) in availableFilters"
|
||||
:key="property"
|
||||
:value="property"
|
||||
>
|
||||
{{ filter.label ?? property }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<template v-if="hasComparisonSelect">
|
||||
<FormWidget v-if="currentFilter?.type === 'string'">
|
||||
<select v-model="newFilter.builder.negate">
|
||||
<option :value="false">does</option>
|
||||
<option :value="true">does not</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<FormWidget v-if="hasComparisonSelect">
|
||||
<select v-model="newFilter.builder.comparison">
|
||||
<option
|
||||
v-for="(label, type) in comparisons"
|
||||
:key="type"
|
||||
:value="type"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
</template>
|
||||
<FormWidget
|
||||
v-if="hasValueInput"
|
||||
:after="valueInputAfter"
|
||||
:before="valueInputBefore"
|
||||
>
|
||||
<input v-model="newFilter.builder.value" />
|
||||
</FormWidget>
|
||||
<template v-else-if="currentFilter?.type === 'enum'">
|
||||
<FormWidget>
|
||||
<select v-model="newFilter.builder.negate">
|
||||
<option :value="false">is</option>
|
||||
<option :value="true">is not</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<FormWidget>
|
||||
<select v-model="newFilter.builder.value">
|
||||
<option v-if="!newFilter.builder.value" value="" />
|
||||
<option v-for="choice in enumChoices" :key="choice" :value="choice">
|
||||
{{ choice }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
</template>
|
||||
</template>
|
||||
<UiButton
|
||||
v-if="!newFilter.isAdvanced"
|
||||
color="secondary"
|
||||
@click="enableAdvancedMode"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPencil" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
class="remove"
|
||||
color="secondary"
|
||||
@click="emit('remove', newFilter.id)"
|
||||
>
|
||||
<FontAwesomeIcon :icon="faRemove" class="remove-icon" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from "vue";
|
||||
import type {
|
||||
Filter,
|
||||
FilterComparisonType,
|
||||
FilterComparisons,
|
||||
FilterType,
|
||||
Filters,
|
||||
NewFilter,
|
||||
} from "@/types/filter";
|
||||
import { faPencil, faRemove } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import { buildComplexMatcherNode } from "@/libs/complex-matcher.utils";
|
||||
import { getFilterIcon } from "@/libs/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
availableFilters: Filters;
|
||||
modelValue: NewFilter;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: NewFilter): void;
|
||||
(event: "remove", filterId: number): void;
|
||||
}>();
|
||||
|
||||
const newFilter = useVModel(props, "modelValue", emit);
|
||||
|
||||
const getDefaultComparisonType = () => {
|
||||
const defaultTypes: { [key in FilterType]: FilterComparisonType } = {
|
||||
string: "stringContains",
|
||||
boolean: "booleanTrue",
|
||||
number: "numberEquals",
|
||||
enum: "stringEquals",
|
||||
};
|
||||
|
||||
return defaultTypes[
|
||||
props.availableFilters[newFilter.value.builder.property].type
|
||||
];
|
||||
};
|
||||
|
||||
watch(
|
||||
() => newFilter.value.builder.property,
|
||||
() => {
|
||||
newFilter.value.builder.comparison = getDefaultComparisonType();
|
||||
newFilter.value.builder.value = "";
|
||||
newFilter.value.builder.negate = false;
|
||||
}
|
||||
);
|
||||
|
||||
const currentFilter = computed<Filter>(
|
||||
() => props.availableFilters[newFilter.value.builder.property]
|
||||
);
|
||||
|
||||
const currentFilterIcon = computed(() => getFilterIcon(currentFilter.value));
|
||||
|
||||
const hasValueInput = computed(() =>
|
||||
["string", "number"].includes(currentFilter.value?.type)
|
||||
);
|
||||
|
||||
const hasComparisonSelect = computed(
|
||||
() => newFilter.value.builder.property && currentFilter.value?.type !== "enum"
|
||||
);
|
||||
|
||||
const enumChoices = computed(() => {
|
||||
if (!newFilter.value.builder.property) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const availableFilter =
|
||||
props.availableFilters[newFilter.value.builder.property];
|
||||
|
||||
if (availableFilter.type !== "enum") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return availableFilter.choices;
|
||||
});
|
||||
|
||||
const generatedFilter = computed(() => {
|
||||
if (newFilter.value.isAdvanced) {
|
||||
return newFilter.value.content;
|
||||
}
|
||||
|
||||
if (!newFilter.value.builder.comparison) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const node = buildComplexMatcherNode(
|
||||
newFilter.value.builder.comparison,
|
||||
newFilter.value.builder.property,
|
||||
newFilter.value.builder.value,
|
||||
newFilter.value.builder.negate
|
||||
);
|
||||
|
||||
if (node) {
|
||||
return node.toString();
|
||||
}
|
||||
|
||||
return "";
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const enableAdvancedMode = () => {
|
||||
newFilter.value.content = generatedFilter.value;
|
||||
newFilter.value.isAdvanced = true;
|
||||
};
|
||||
|
||||
watch(generatedFilter, (value) => {
|
||||
newFilter.value.content = value;
|
||||
});
|
||||
|
||||
const comparisons = computed<FilterComparisons>(() => {
|
||||
const comparisonsByType = {
|
||||
string: {
|
||||
stringContains: "contain",
|
||||
stringEquals: "equal",
|
||||
stringStartsWith: "start with",
|
||||
stringEndsWith: "end with",
|
||||
stringMatchesRegex: "match regex",
|
||||
},
|
||||
boolean: {
|
||||
booleanTrue: "is true",
|
||||
booleanFalse: "is false",
|
||||
},
|
||||
number: {
|
||||
numberLessThan: "<",
|
||||
numberLessThanOrEquals: "<=",
|
||||
numberEquals: "=",
|
||||
numberGreaterThanOrEquals: ">=",
|
||||
numberGreaterThan: ">",
|
||||
},
|
||||
enum: {},
|
||||
};
|
||||
|
||||
return comparisonsByType[currentFilter.value.type];
|
||||
});
|
||||
|
||||
const valueInputBefore = computed(() => {
|
||||
return newFilter.value.builder.comparison === "stringMatchesRegex"
|
||||
? "/"
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const valueInputAfter = computed(() => {
|
||||
return newFilter.value.builder.comparison === "stringMatchesRegex"
|
||||
? "/i"
|
||||
: undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.collection-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--background-color-secondary);
|
||||
gap: 1rem;
|
||||
|
||||
&:only-child {
|
||||
.or,
|
||||
.remove {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child .or {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-icon {
|
||||
color: var(--color-red-vates-base);
|
||||
}
|
||||
</style>
|
104
@xen-orchestra/lite/src/components/CollectionSorter.vue
Normal file
104
@xen-orchestra/lite/src/components/CollectionSorter.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<UiFilterGroup class="collection-sorter">
|
||||
<UiFilter
|
||||
v-for="[property, isAscending] in activeSorts"
|
||||
:key="property"
|
||||
@edit="emit('toggleSortDirection', property)"
|
||||
@remove="emit('removeSort', property)"
|
||||
>
|
||||
<span style="display: inline-flex; align-items: center; gap: 0.7rem">
|
||||
<FontAwesomeIcon :icon="isAscending ? faCaretUp : faCaretDown" />
|
||||
{{ property }}
|
||||
</span>
|
||||
</UiFilter>
|
||||
|
||||
<UiButton :icon="faPlus" class="add-sort" color="secondary" @click="open">
|
||||
Add sort
|
||||
</UiButton>
|
||||
</UiFilterGroup>
|
||||
|
||||
<UiModal v-if="isOpen">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div style="display: flex; gap: 1rem">
|
||||
<FormWidget label="Sort by">
|
||||
<select v-model="newSortProperty">
|
||||
<option v-if="!newSortProperty"></option>
|
||||
<option
|
||||
v-for="(sort, property) in availableSorts"
|
||||
:key="property"
|
||||
:value="property"
|
||||
>
|
||||
{{ sort.label ?? property }}
|
||||
</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
<FormWidget>
|
||||
<select v-model="newSortIsAscending">
|
||||
<option :value="true">ascending</option>
|
||||
<option :value="false">descending</option>
|
||||
</select>
|
||||
</FormWidget>
|
||||
</div>
|
||||
<UiButtonGroup>
|
||||
<UiButton type="submit"> Add</UiButton>
|
||||
<UiButton color="secondary" @click="handleCancel">Cancel</UiButton>
|
||||
</UiButtonGroup>
|
||||
</form>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import type { ActiveSorts, Sorts } from "@/types/sort";
|
||||
import {
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faPlus,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
import UiFilter from "@/components/ui/UiFilter.vue";
|
||||
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
|
||||
import UiModal from "@/components/ui/UiModal.vue";
|
||||
import useModal from "@/composables/modal.composable";
|
||||
|
||||
defineProps<{
|
||||
availableSorts: Sorts;
|
||||
activeSorts: ActiveSorts;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "toggleSortDirection", property: string): void;
|
||||
(event: "addSort", property: string, isAscending: boolean): void;
|
||||
(event: "removeSort", property: string): void;
|
||||
}>();
|
||||
|
||||
const { isOpen, open, close } = useModal();
|
||||
|
||||
const newSortProperty = ref();
|
||||
const newSortIsAscending = ref<boolean>(true);
|
||||
|
||||
const reset = () => {
|
||||
// editedFilter.value = "";
|
||||
// newFilters.value = [];
|
||||
// addNewFilter();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("addSort", newSortProperty.value, newSortIsAscending.value);
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
reset();
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.add-sort {
|
||||
height: 3.4rem;
|
||||
}
|
||||
</style>
|
@ -1,11 +1,21 @@
|
||||
<template>
|
||||
<CollectionFilter
|
||||
v-if="availableFilters"
|
||||
:active-filters="filters"
|
||||
:available-filters="availableFilters"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
/>
|
||||
<div class="filter-and-sort">
|
||||
<CollectionFilter
|
||||
v-if="availableFilters"
|
||||
:active-filters="filters"
|
||||
:available-filters="availableFilters"
|
||||
@add-filter="addFilter"
|
||||
@remove-filter="removeFilter"
|
||||
/>
|
||||
|
||||
<CollectionSorter
|
||||
:active-sorts="sorts"
|
||||
:available-sorts="availableSorts"
|
||||
@add-sort="addSort"
|
||||
@remove-sort="removeSort"
|
||||
@toggle-sort-direction="toggleSortDirection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UiTable>
|
||||
<template #header>
|
||||
@ -15,9 +25,13 @@
|
||||
<slot name="header" />
|
||||
</template>
|
||||
|
||||
<tr v-for="item in filteredCollection" :key="item.$ref">
|
||||
<tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
|
||||
<td v-if="isSelectable">
|
||||
<input v-model="selected" :value="item.$ref" type="checkbox" />
|
||||
<input
|
||||
v-model="selected"
|
||||
:value="item[props.idProperty]"
|
||||
type="checkbox"
|
||||
/>
|
||||
</td>
|
||||
<slot :item="item" name="row" />
|
||||
</tr>
|
||||
@ -26,18 +40,23 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, toRef, watch } from "vue";
|
||||
import type { AvailableFilter } from "@/types/filter";
|
||||
import type { Filters } from "@/types/filter";
|
||||
import type { Sorts } from "@/types/sort";
|
||||
import CollectionFilter from "@/components/CollectionFilter.vue";
|
||||
import CollectionSorter from "@/components/CollectionSorter.vue";
|
||||
import UiTable from "@/components/ui/UiTable.vue";
|
||||
import useCollectionFilter from "@/composables/collection-filter.composable";
|
||||
import useCollectionSorter from "@/composables/collection-sorter.composable";
|
||||
import useFilteredCollection from "@/composables/filtered-collection.composable";
|
||||
import useMultiSelect from "@/composables/multi-select.composable";
|
||||
import type { XenApiRecord } from "@/libs/xen-api";
|
||||
import useSortedCollection from "@/composables/sorted-collection.composable";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string[];
|
||||
availableFilters?: AvailableFilter[];
|
||||
collection: XenApiRecord[];
|
||||
availableFilters?: Filters;
|
||||
availableSorts?: Sorts;
|
||||
collection: Record<string, any>[];
|
||||
idProperty: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -47,16 +66,25 @@ const emit = defineEmits<{
|
||||
const isSelectable = computed(() => props.modelValue !== undefined);
|
||||
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
|
||||
const { sorts, addSort, removeSort, toggleSortDirection, compareFn } =
|
||||
useCollectionSorter();
|
||||
|
||||
const filteredCollection = useFilteredCollection(
|
||||
toRef(props, "collection"),
|
||||
predicate
|
||||
);
|
||||
|
||||
const usableRefs = computed(() => props.collection.map((item) => item.$ref));
|
||||
const filteredAndSortedCollection = useSortedCollection(
|
||||
filteredCollection,
|
||||
compareFn
|
||||
);
|
||||
|
||||
const usableRefs = computed(() =>
|
||||
props.collection.map((item) => item[props.idProperty])
|
||||
);
|
||||
|
||||
const selectableRefs = computed(() =>
|
||||
filteredCollection.value.map((item) => item.$ref)
|
||||
filteredAndSortedCollection.value.map((item) => item[props.idProperty])
|
||||
);
|
||||
|
||||
const { selected, areAllSelected } = useMultiSelect(usableRefs, selectableRefs);
|
||||
@ -66,4 +94,8 @@ watch(selected, (selected) => emit("update:modelValue", selected), {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style lang="postcss" scoped>
|
||||
.filter-and-sort {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
|
37
@xen-orchestra/lite/src/components/ColumnHeader.vue
Normal file
37
@xen-orchestra/lite/src/components/ColumnHeader.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<th>
|
||||
<div class="content">
|
||||
<span class="label">
|
||||
<FontAwesomeIcon v-if="icon" :icon="icon" />
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon?: IconDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<label :class="{ inline }" class="form-widget">
|
||||
<label class="form-widget">
|
||||
<span v-if="label || $slots.label" class="label">
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
@ -29,8 +29,8 @@
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
before?: IconDefinition | string;
|
||||
after?: IconDefinition | string;
|
||||
before?: IconDefinition | string | object; // "object" added as workaround
|
||||
after?: IconDefinition | string | object; // See https://github.com/vuejs/core/issues/4294
|
||||
label?: string;
|
||||
inline?: boolean;
|
||||
}>();
|
||||
@ -39,9 +39,9 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
|
||||
typeof maybeIcon === "object";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.form-widget {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
font-size: 1.6rem;
|
||||
@ -50,12 +50,13 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
|
||||
|
||||
.widget {
|
||||
display: inline-flex;
|
||||
flex: 1;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--color-blue-scale-400);
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--form-background);
|
||||
background-color: var(--color-blue-scale-500);
|
||||
box-shadow: var(--shadow-100);
|
||||
gap: 0.1rem;
|
||||
|
||||
@ -64,29 +65,41 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-widget:hover .widget {
|
||||
border-color: var(--color-extra-blue-l60);
|
||||
}
|
||||
|
||||
.element {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.before,
|
||||
.after {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
:slotted(input),
|
||||
:slotted(select),
|
||||
:slotted(textarea) {
|
||||
font-family: Poppins, sans-serif;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--color-blue-scale-100);
|
||||
background-color: var(--form-background);
|
||||
background-color: var(--color-blue-scale-500);
|
||||
flex: 1;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
:slotted(input[type="checkbox"]) {
|
||||
@ -121,8 +134,7 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--form-control-disabled);
|
||||
--form-control-color: var(--form-control-disabled);
|
||||
color: var(--color-blue-scale-200);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -10,7 +10,8 @@ import {
|
||||
faPlay,
|
||||
faQuestion,
|
||||
faStop,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import FormWidget from "@/components/FormWidget.vue";
|
||||
import type { PowerState } from "@/libs/xen-api";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -18,36 +19,33 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const icon = computed(() => {
|
||||
switch (props.state) {
|
||||
case "Running":
|
||||
return faPlay;
|
||||
case "Paused":
|
||||
return faPause;
|
||||
case "Suspended":
|
||||
return faMoon;
|
||||
case "Unknown":
|
||||
return faQuestion;
|
||||
default:
|
||||
return faStop;
|
||||
}
|
||||
const icons = {
|
||||
Running: faPlay,
|
||||
Paused: faPause,
|
||||
Suspended: faMoon,
|
||||
Unknown: faQuestion,
|
||||
Halted: faStop,
|
||||
};
|
||||
|
||||
return icons[props.state];
|
||||
});
|
||||
|
||||
const className = computed(() => props.state.toLocaleLowerCase());
|
||||
const className = computed(() => `state-${props.state.toLocaleLowerCase()}`);
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.power-state-icon {
|
||||
color: var(--color-extra-blue-d60);
|
||||
|
||||
&.running {
|
||||
&.state-running {
|
||||
color: var(--color-green-infra-base);
|
||||
}
|
||||
|
||||
&.paused {
|
||||
&.state-paused {
|
||||
color: var(--color-blue-scale-300);
|
||||
}
|
||||
|
||||
&.suspended {
|
||||
&.state-suspended {
|
||||
color: var(--color-extra-blue-d20);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="infra-action">
|
||||
<FontAwesomeIcon :icon="icon" fixed-width />
|
||||
<slot>
|
||||
<FontAwesomeIcon :icon="icon" fixed-width />
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -8,7 +10,7 @@
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon: IconDefinition;
|
||||
icon?: IconDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
@ -20,11 +20,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
faAngleDown,
|
||||
faAngleUp,
|
||||
faServer,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faAngleDown, faAngleUp } from "@fortawesome/pro-light-svg-icons";
|
||||
import { faServer } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { useToggle } from "@vueuse/core";
|
||||
import InfraAction from "@/components/infra/InfraAction.vue";
|
||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||
|
@ -33,8 +33,6 @@ defineProps<{
|
||||
.infra-item-label {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
height: 6rem;
|
||||
margin-bottom: 0.2rem;
|
||||
color: var(--color-blue-scale-100);
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--background-color-primary);
|
||||
@ -64,10 +62,12 @@ defineProps<{
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
gap: 1rem;
|
||||
font-weight: 500;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<ul class="infra-pool-list">
|
||||
<InfraLoadingItem v-if="!isReady" :icon="faBuilding" />
|
||||
<InfraLoadingItem v-if="!isReady" :icon="faBuildings" />
|
||||
<li v-else class="infra-pool-item">
|
||||
<InfraItemLabel
|
||||
:icon="faBuilding"
|
||||
:icon="faBuildings"
|
||||
:route="{ name: 'pool.dashboard', params: { uuid: pool.uuid } }"
|
||||
current
|
||||
>
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faBuildings } from "@fortawesome/pro-regular-svg-icons";
|
||||
import InfraHostList from "@/components/infra/InfraHostList.vue";
|
||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
|
||||
|
@ -7,7 +7,9 @@
|
||||
>
|
||||
{{ vm.name_label }}
|
||||
<template #actions>
|
||||
<InfraAction :class="powerStateClass" :icon="powerStateIcon" />
|
||||
<InfraAction>
|
||||
<PowerStateIcon :state="vm?.power_state" />
|
||||
</InfraAction>
|
||||
</template>
|
||||
</InfraItemLabel>
|
||||
</li>
|
||||
@ -15,14 +17,9 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
faDisplay,
|
||||
faMoon,
|
||||
faPause,
|
||||
faPlay,
|
||||
faStop,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faDisplay } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { useIntersectionObserver } from "@vueuse/core";
|
||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import InfraAction from "@/components/infra/InfraAction.vue";
|
||||
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
@ -44,23 +41,6 @@ const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
|
||||
const vmStore = useVmStore();
|
||||
|
||||
const vm = computed(() => vmStore.getRecord(props.vmOpaqueRef));
|
||||
|
||||
const powerStateIcon = computed(() => {
|
||||
switch (vm.value?.power_state) {
|
||||
case "Running":
|
||||
return faPlay;
|
||||
case "Paused":
|
||||
return faPause;
|
||||
case "Suspended":
|
||||
return faMoon;
|
||||
default:
|
||||
return faStop;
|
||||
}
|
||||
});
|
||||
|
||||
const powerStateClass = computed(() =>
|
||||
vm.value?.power_state.toLocaleLowerCase()
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
|
@ -14,7 +14,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { faDisplay } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faDisplay } from "@fortawesome/pro-solid-svg-icons";
|
||||
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
|
||||
import InfraVmItem from "@/components/infra/InfraVmItem.vue";
|
||||
import { useVmStore } from "@/stores/vm.store";
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { faBuilding } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faBuilding } from "@fortawesome/pro-regular-svg-icons";
|
||||
import TitleBar from "@/components/TitleBar.vue";
|
||||
import { usePoolStore } from "@/stores/pool.store";
|
||||
|
||||
|
@ -1,15 +1,27 @@
|
||||
<template>
|
||||
<span class="ui-badge">
|
||||
<FontAwesomeIcon :icon="icon" />
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
defineProps<{
|
||||
icon?: IconDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.ui-badge {
|
||||
font-size: 1.2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
padding: 0.1rem 0.5rem;
|
||||
padding: 0 0.8rem;
|
||||
height: 2.4rem;
|
||||
color: var(--color-blue-scale-500);
|
||||
border-radius: 9.6rem;
|
||||
background-color: var(--color-blue-scale-300);
|
||||
|
@ -1,70 +1,103 @@
|
||||
<template>
|
||||
<button
|
||||
:class="`color-${buttonColor}`"
|
||||
:class="[
|
||||
`color-${buttonColor}`,
|
||||
{ busy: isBusy, disabled: isDisabled, 'has-icon': icon !== undefined },
|
||||
]"
|
||||
:disabled="isBusy || isDisabled"
|
||||
:type="type || 'button'"
|
||||
class="ui-button"
|
||||
>
|
||||
<FontAwesomeIcon v-if="isBusy" :icon="faSpinner" spin />
|
||||
<FontAwesomeIcon v-else-if="iconLeft" :icon="iconLeft" />
|
||||
<slot />
|
||||
<FontAwesomeIcon v-if="isBusy" :icon="faSpinner" class="icon" spin />
|
||||
<template v-else>
|
||||
<FontAwesomeIcon v-if="icon" :icon="icon" class="icon" />
|
||||
<slot />
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, unref } from "vue";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faSpinner } from "@fortawesome/pro-regular-svg-icons";
|
||||
|
||||
const props = defineProps<{
|
||||
type?: "button" | "reset" | "submit";
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
iconLeft?: IconDefinition;
|
||||
color?: "default" | "action";
|
||||
icon?: IconDefinition;
|
||||
color?: "primary" | "secondary";
|
||||
}>();
|
||||
|
||||
const isGroupBusy = inject("isButtonGroupBusy", false);
|
||||
const isBusy = computed(() => props.busy || unref(isGroupBusy));
|
||||
const isBusy = computed(() => props.busy ?? unref(isGroupBusy));
|
||||
|
||||
const isGroupDisabled = inject("isButtonGroupDisabled", false);
|
||||
const isDisabled = computed(() => props.disabled || unref(isGroupDisabled));
|
||||
const isDisabled = computed(() => props.disabled ?? unref(isGroupDisabled));
|
||||
|
||||
const buttonGroupColor = inject("buttonGroupColor", "default");
|
||||
const buttonColor = computed(() => props.color || unref(buttonGroupColor));
|
||||
const buttonGroupColor = inject("buttonGroupColor", "primary");
|
||||
const buttonColor = computed(() => props.color ?? unref(buttonGroupColor));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.ui-button {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 3.8rem;
|
||||
margin: 0;
|
||||
padding: 0 1rem;
|
||||
color: var(--color-blue-scale-500);
|
||||
min-width: 10rem;
|
||||
height: 5rem;
|
||||
padding: 0 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.8rem;
|
||||
background-color: var(--color-extra-blue-base);
|
||||
gap: 1rem;
|
||||
box-shadow: var(--shadow-100);
|
||||
gap: 1.5rem;
|
||||
|
||||
&:not([disabled]) {
|
||||
cursor: pointer;
|
||||
&.has-icon {
|
||||
min-width: 13rem;
|
||||
}
|
||||
|
||||
&.color-action {
|
||||
color: var(--color-grayscale-200);
|
||||
background-color: var(--color-white);
|
||||
&.disabled {
|
||||
color: var(--color-blue-scale-400);
|
||||
background-color: var(--background-color-secondary);
|
||||
}
|
||||
|
||||
&:not([disabled]):hover {
|
||||
background-color: var(--background-color-secondary);
|
||||
&:not(.disabled) {
|
||||
&.color-primary {
|
||||
color: var(--color-blue-scale-500);
|
||||
background-color: var(--color-extra-blue-base);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-extra-blue-d20);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.busy {
|
||||
background-color: var(--color-extra-blue-d40);
|
||||
}
|
||||
}
|
||||
|
||||
&.color-secondary {
|
||||
color: var(--color-extra-blue-base);
|
||||
border: 1px solid var(--color-extra-blue-base);
|
||||
background-color: var(--color-blue-scale-500);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-extra-blue-d20);
|
||||
border-color: var(--color-extra-blue-d20);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.busy {
|
||||
color: var(--color-extra-blue-d40);
|
||||
border-color: var(--color-extra-blue-d40);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
</style>
|
||||
|
@ -5,19 +5,28 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { provide, toRef } from "vue";
|
||||
import { computed, provide } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
color?: "default" | "action";
|
||||
color?: "primary" | "secondary";
|
||||
}>();
|
||||
provide("isButtonGroupBusy", toRef(props, "busy"));
|
||||
provide("isButtonGroupDisabled", toRef(props, "disabled"));
|
||||
provide("buttonGroupColor", toRef(props, "color"));
|
||||
provide(
|
||||
"isButtonGroupBusy",
|
||||
computed(() => props.busy ?? false)
|
||||
);
|
||||
provide(
|
||||
"isButtonGroupDisabled",
|
||||
computed(() => props.disabled ?? false)
|
||||
);
|
||||
provide(
|
||||
"buttonGroupColor",
|
||||
computed(() => props.color ?? "primary")
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.ui-button-group {
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
|
@ -1,35 +1,61 @@
|
||||
<template>
|
||||
<span class="ui-filter">
|
||||
<slot />
|
||||
<FontAwesomeIcon :icon="faRemove" class="remove" @click="emit('remove')" />
|
||||
<span class="label" @click="emit('edit')">
|
||||
<slot />
|
||||
</span>
|
||||
<span class="remove" @click.stop="emit('remove')">
|
||||
<FontAwesomeIcon :icon="faRemove" class="icon" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { faRemove } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faRemove } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "edit"): void;
|
||||
(event: "remove"): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.ui-filter {
|
||||
overflow: hidden;
|
||||
font-size: 1.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
height: 3.4rem;
|
||||
padding: 0 1rem;
|
||||
color: var(--color-extra-blue-base);
|
||||
border: 1px solid var(--color-extra-blue-base);
|
||||
border-radius: 1.7rem;
|
||||
background-color: var(--background-color-extra-blue);
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--color-extra-blue-base);
|
||||
}
|
||||
|
||||
.label,
|
||||
.remove {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.remove {
|
||||
cursor: pointer;
|
||||
color: red;
|
||||
color: white;
|
||||
border-radius: 1.4rem;
|
||||
width: 2.8rem;
|
||||
margin: 0.2rem;
|
||||
background-color: var(--color-extra-blue-l40);
|
||||
&:hover {
|
||||
background-color: var(--color-red-vates-l20);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.ui-filter-group {
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
|
@ -10,7 +10,7 @@
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.ui-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: white;
|
||||
background-color: var(--background-color-primary);
|
||||
min-width: 40rem;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
@ -33,6 +33,6 @@
|
||||
|
||||
:slotted(.ui-button-group) {
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
@ -13,7 +13,7 @@
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.ui-table {
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
@ -2,32 +2,28 @@
|
||||
<UiButtonGroup
|
||||
:disabled="selectedRefs.length === 0"
|
||||
class="vms-actions-bar"
|
||||
color="action"
|
||||
color="secondary"
|
||||
>
|
||||
<UiButton :icon-left="faPowerOff">Change power state</UiButton>
|
||||
<UiButton :icon-left="faRoute">Migrate</UiButton>
|
||||
<UiButton :icon-left="faCopy">Copy</UiButton>
|
||||
<UiButton :icon-left="faEdit">Edit config</UiButton>
|
||||
<UiButton :icon-left="faCamera">Snapshot</UiButton>
|
||||
<UiButton :icon-left="faBox">Backup</UiButton>
|
||||
<UiButton :icon-left="faTrashCan">Delete</UiButton>
|
||||
<UiButton :icon-left="faFileExport">Export</UiButton>
|
||||
<UiButton :icon="faPowerOff">Change power state</UiButton>
|
||||
<UiButton :icon="faRoute">Migrate</UiButton>
|
||||
<UiButton :icon="faCopy">Copy</UiButton>
|
||||
<UiButton :icon="faEdit">Edit config</UiButton>
|
||||
<UiButton :icon="faCamera">Snapshot</UiButton>
|
||||
<UiButton :icon="faBox">Backup</UiButton>
|
||||
<UiButton :icon="faTrashCan">Delete</UiButton>
|
||||
<UiButton :icon="faFileExport">Export</UiButton>
|
||||
</UiButtonGroup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
faCopy,
|
||||
faEdit,
|
||||
faTrashCan,
|
||||
} from "@fortawesome/free-regular-svg-icons";
|
||||
import { faCopy, faEdit, faTrashCan } from "@fortawesome/pro-regular-svg-icons";
|
||||
import {
|
||||
faBox,
|
||||
faCamera,
|
||||
faFileExport,
|
||||
faPowerOff,
|
||||
faRoute,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import UiButton from "@/components/ui/UiButton.vue";
|
||||
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
|
||||
|
||||
@ -37,7 +33,7 @@ defineProps<{
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.vms-actions-bar {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-blue-scale-400);
|
||||
|
@ -1,22 +1,23 @@
|
||||
# useBusy composable
|
||||
|
||||
```vue
|
||||
|
||||
<template>
|
||||
<span class="error" v-if="error">{{ error }}</span>
|
||||
<button @click="run" :disabled="isBusy">Do something</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import useBusy from "@/composables/busy.composable";
|
||||
import useBusy from '@/composables/busy.composable';
|
||||
|
||||
async function doSomething() {
|
||||
try {
|
||||
// Doing some async work
|
||||
} catch (e) {
|
||||
throw "Something bad happened";
|
||||
async function doSomething() {
|
||||
try {
|
||||
// Doing some async work
|
||||
} catch (e) {
|
||||
throw "Something bad happened";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { isBusy, error, run } = useBusy(doSomething);
|
||||
const { isBusy, error, run } = useBusy(doSomething)
|
||||
</script>
|
||||
```
|
||||
|
@ -1,5 +1,35 @@
|
||||
# useCollectionFilter composable
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
|
||||
|
||||
const filteredCollection = myCollection.filter(predicate);
|
||||
```
|
||||
|
||||
## URL Query String
|
||||
|
||||
By default, when adding/removing filters, the URL will update automatically.
|
||||
|
||||
```typescript
|
||||
addFilter('name:/^foo/i'); // Will update the URL with ?filter=name:/^foo/i
|
||||
```
|
||||
|
||||
### Change the URL query string parameter name
|
||||
|
||||
```typescript
|
||||
const { /* ... */ } = useCollectionFilter({ queryStringParam: 'f' }); // ?f=name:/^foo/i
|
||||
```
|
||||
|
||||
### Disable the usage of URL query string
|
||||
|
||||
```typescript
|
||||
const { /* ... */ } = useCollectionFilter({ queryStringParam: undefined });
|
||||
```
|
||||
|
||||
## Example of using the composable with the `CollectionFilter` component
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<CollectionFilter
|
||||
@ -13,27 +43,27 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import CollectionFilter from "@/components/CollectionFilter.vue";
|
||||
import useCollectionFilter from "@/composables/collection-filter.composable";
|
||||
import CollectionFilter from "@/components/CollectionFilter.vue";
|
||||
import useCollectionFilter from "@/composables/collection-filter.composable";
|
||||
import { computed } from "vue";
|
||||
|
||||
const collection = [
|
||||
{ name: "Foo", age: 5, registered: true },
|
||||
{ name: "Bar", age: 12, registered: false },
|
||||
{ name: "Foo Bar", age: 2, registered: true },
|
||||
{ name: "Bar Baz", age: 45, registered: false },
|
||||
{ name: "Foo Baz", age: 32, registered: false },
|
||||
{ name: "Foo Bar Baz", age: 32, registered: true },
|
||||
];
|
||||
const collection = [
|
||||
{ name: "Foo", age: 5, registered: true },
|
||||
{ name: "Bar", age: 12, registered: false },
|
||||
{ name: "Foo Bar", age: 2, registered: true },
|
||||
{ name: "Bar Baz", age: 45, registered: false },
|
||||
{ name: "Foo Baz", age: 32, registered: false },
|
||||
{ name: "Foo Bar Baz", age: 32, registered: true },
|
||||
];
|
||||
|
||||
const availableFilters: AvailableFilter[] = [
|
||||
{ property: "name", label: "Name", type: "string" },
|
||||
{ property: "age", label: "Age", type: "number" },
|
||||
{ property: "registered", label: "Registered", type: "boolean", icon: faKey },
|
||||
];
|
||||
const availableFilters: AvailableFilter[] = [
|
||||
{ property: "name", label: "Name", type: "string" },
|
||||
{ property: "age", label: "Age", type: "number" },
|
||||
{ property: "registered", label: "Registered", type: "boolean", icon: faKey },
|
||||
];
|
||||
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
|
||||
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
|
||||
|
||||
const filteredCollection = computed(() => collection.filter(predicate));
|
||||
const filteredCollection = computed(() => collection.filter(predicate));
|
||||
</script>
|
||||
```
|
||||
|
@ -20,9 +20,11 @@ export default function useCollectionFilter(
|
||||
|
||||
if (config.queryStringParam) {
|
||||
const queryStringParam = config.queryStringParam;
|
||||
watch(filters, (value) => {
|
||||
router.replace({ query: { [queryStringParam]: value.join(" ") } });
|
||||
});
|
||||
watch(filters, (value) =>
|
||||
router.replace({
|
||||
query: { ...route.query, [queryStringParam]: value.join(" ") },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const addFilter = (filter: string) => {
|
||||
@ -47,10 +49,16 @@ export default function useCollectionFilter(
|
||||
};
|
||||
}
|
||||
|
||||
function queryToSet(filter: LocationQueryValue): Set<string> {
|
||||
if (filter) {
|
||||
return new Set(filter.split(" "));
|
||||
function queryToSet(query: LocationQueryValue): Set<string> {
|
||||
if (!query) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set();
|
||||
const rootNode = CM.parse(query);
|
||||
|
||||
if (rootNode instanceof CM.And) {
|
||||
return new Set(rootNode.children.map((child) => child.toString()));
|
||||
} else {
|
||||
return new Set([rootNode.toString()]);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,96 @@
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { type LocationQueryValue, useRoute, useRouter } from "vue-router";
|
||||
import type { ActiveSorts } from "@/types/sort";
|
||||
|
||||
interface Config {
|
||||
queryStringParam?: string;
|
||||
}
|
||||
|
||||
export default function useCollectionSorter(
|
||||
config: Config = { queryStringParam: "sort" }
|
||||
) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const sorts = ref<ActiveSorts>(
|
||||
config.queryStringParam
|
||||
? queryToMap(route.query[config.queryStringParam] as LocationQueryValue)
|
||||
: new Map()
|
||||
);
|
||||
|
||||
if (config.queryStringParam) {
|
||||
const queryStringParam = config.queryStringParam;
|
||||
watch(
|
||||
sorts,
|
||||
(value) =>
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
[queryStringParam]: Array.from(value)
|
||||
.map(
|
||||
([property, isAscending]) =>
|
||||
`${property}:${isAscending ? "1" : "0"}`
|
||||
)
|
||||
.join(","),
|
||||
},
|
||||
}),
|
||||
{ deep: true }
|
||||
);
|
||||
}
|
||||
|
||||
const addSort = (property: string, isAscending: boolean) => {
|
||||
sorts.value.set(property, isAscending);
|
||||
};
|
||||
|
||||
const removeSort = (property: string) => {
|
||||
sorts.value.delete(property);
|
||||
};
|
||||
|
||||
const toggleSortDirection = (property: string) => {
|
||||
if (!sorts.value.has(property)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sorts.value.set(property, !sorts.value.get(property));
|
||||
};
|
||||
|
||||
const compareFn = computed(() => {
|
||||
return (record1: any, record2: any) => {
|
||||
for (const [property, isAscending] of sorts.value) {
|
||||
const value1 = record1[property];
|
||||
const value2 = record2[property];
|
||||
|
||||
if (value1 < value2) {
|
||||
return isAscending ? -1 : 1;
|
||||
}
|
||||
|
||||
if (value1 > value2) {
|
||||
return isAscending ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sorts,
|
||||
addSort,
|
||||
removeSort,
|
||||
toggleSortDirection,
|
||||
compareFn,
|
||||
};
|
||||
}
|
||||
|
||||
function queryToMap(query: LocationQueryValue) {
|
||||
if (!query) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
return new Map(
|
||||
query.split(",").map((sortRaw): [string, boolean] => {
|
||||
const [property, isAscending] = sortRaw.split(":");
|
||||
return [property, isAscending === "1"];
|
||||
})
|
||||
);
|
||||
}
|
@ -2,17 +2,14 @@
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import useFilteredCollection from "./filtered-collection.composable";
|
||||
import useFilteredCollection from './filtered-collection.composable';
|
||||
|
||||
const players = [
|
||||
{ name: "Foo", team: "Blue" },
|
||||
{ name: "Bar", team: "Red" },
|
||||
{ name: "Baz", team: "Blue" },
|
||||
];
|
||||
const players = [
|
||||
{ name: "Foo", team: "Blue" },
|
||||
{ name: "Bar", team: "Red" },
|
||||
{ name: "Baz", team: "Blue" },
|
||||
]
|
||||
|
||||
const bluePlayers = useFilteredCollection(
|
||||
players,
|
||||
(player) => player.team === "Blue"
|
||||
);
|
||||
const bluePlayers = useFilteredCollection(players, (player) => player.team === "Blue");
|
||||
</script>
|
||||
```
|
||||
|
@ -5,9 +5,7 @@ export default function useFilteredCollection<T>(
|
||||
collection: MaybeRef<T[]>,
|
||||
predicate: MaybeRef<(value: T) => boolean>
|
||||
) {
|
||||
const filteredCollection = computed(() => {
|
||||
return computed(() => {
|
||||
return unref(collection).filter(unref(predicate));
|
||||
});
|
||||
|
||||
return filteredCollection;
|
||||
}
|
||||
|
@ -9,24 +9,23 @@
|
||||
<UiModal v-if="isRemoveModalOpen">
|
||||
Are you sure you want to delete {{ removeModalPayload.name }}
|
||||
|
||||
<button @click="handleRemove">Yes</button>
|
||||
<button @click="closeRemoveModal">No</button>
|
||||
<button @click="handleRemove">Yes</button> <button @click="closeRemoveModal">No</button>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import useModal from "@/composables/modal.composable";
|
||||
import useModal from '@/composables/modal.composable';
|
||||
|
||||
const {
|
||||
payload: removeModalPayload,
|
||||
isOpen: isRemoveModalOpen,
|
||||
open: openRemoveModal,
|
||||
close: closeRemoveModal,
|
||||
} = useModal();
|
||||
const {
|
||||
payload: removeModalPayload,
|
||||
isOpen: isRemoveModalOpen,
|
||||
open: openRemoveModal,
|
||||
close: closeRemoveModal,
|
||||
} = useModal()
|
||||
|
||||
async function handleRemove() {
|
||||
await removeItem(removeModalPayload.id);
|
||||
closeRemoveModal();
|
||||
}
|
||||
async function handleRemove() {
|
||||
await removeItem(removeModalPayload.id);
|
||||
closeRemoveModal()
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
@ -4,20 +4,20 @@
|
||||
<template>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" v-model="areAllSelected" />
|
||||
</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" v-model="areAllSelected">
|
||||
</th>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items">
|
||||
<td>
|
||||
<input type="checkbox" :value="item.id" v-model="selected" />
|
||||
</td>
|
||||
<td>{{ item.name }}</td>
|
||||
</tr>
|
||||
<tr v-for="item in items">
|
||||
<td>
|
||||
<input type="checkbox" :value="item.id" v-model="selected" />
|
||||
</td>
|
||||
<td>{{ item.name }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -25,9 +25,13 @@
|
||||
<button @click="areAllSelected = !areAllSelected">Toggle all selected</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import useMultiSelect from "./multi-select.composable";
|
||||
|
||||
const { selected, areAllSelected } = useMultiSelect();
|
||||
<script lang="ts" setup>
|
||||
import useMultiSelect from './multi-select.composable';
|
||||
|
||||
const {
|
||||
selected,
|
||||
areAllSelected,
|
||||
} = useMultiSelect()
|
||||
</script>
|
||||
```
|
||||
|
@ -1,49 +0,0 @@
|
||||
import { computed, ref, unref } from "vue";
|
||||
import type { MaybeRef } from "@vueuse/core";
|
||||
|
||||
export default function useSortable<T>(data: MaybeRef<T[]>) {
|
||||
const sortProperty = ref<keyof T>();
|
||||
const sortAscending = ref(true);
|
||||
const sortBy = (property: keyof T) => {
|
||||
if (sortProperty.value !== property) {
|
||||
sortProperty.value = property;
|
||||
sortAscending.value = true;
|
||||
} else if (sortAscending.value) {
|
||||
sortAscending.value = !sortAscending.value;
|
||||
} else {
|
||||
sortAscending.value = true;
|
||||
sortProperty.value = undefined;
|
||||
}
|
||||
};
|
||||
const isSortedBy = (property: keyof T) => {
|
||||
return sortProperty.value === property;
|
||||
};
|
||||
const isAscending = computed(() => sortAscending.value === true);
|
||||
const records = computed(() => {
|
||||
let records = [...unref(data)];
|
||||
|
||||
if (sortProperty.value) {
|
||||
records = records.sort((record1, record2) => {
|
||||
const value1 = record1[sortProperty.value!];
|
||||
const value2 = record2[sortProperty.value!];
|
||||
switch (true) {
|
||||
case value1 < value2:
|
||||
return sortAscending.value ? -1 : 1;
|
||||
case value1 > value2:
|
||||
return sortAscending.value ? 1 : -1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
});
|
||||
|
||||
return {
|
||||
sortBy,
|
||||
records,
|
||||
isAscending,
|
||||
isSortedBy,
|
||||
};
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
import { computed, unref } from "vue";
|
||||
import type { MaybeRef } from "@vueuse/core";
|
||||
|
||||
export default function useSortedCollection<T>(
|
||||
collection: MaybeRef<T[]>,
|
||||
compareFn: MaybeRef<(value1: T, value2: T) => -1 | 1 | 0>
|
||||
) {
|
||||
return computed(() => {
|
||||
return [...unref(collection)].sort(unref(compareFn));
|
||||
});
|
||||
}
|
113
@xen-orchestra/lite/src/libs/complex-matcher.utils.ts
Normal file
113
@xen-orchestra/lite/src/libs/complex-matcher.utils.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import type { ComparisonOperator, ComplexMatcherNode } from "complex-matcher";
|
||||
import * as CM from "complex-matcher";
|
||||
import type { FilterComparisonType } from "@/types/filter";
|
||||
import { escapeRegExp } from "@/libs/utils";
|
||||
|
||||
function buildStringNode(property: string, value: string, negate = false) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = new CM.Property(property, new CM.StringNode(value));
|
||||
|
||||
if (negate) {
|
||||
return new CM.Not(node);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildRegexNode(
|
||||
property: string,
|
||||
value: string,
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
escape = false,
|
||||
negate = false
|
||||
) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (escape) {
|
||||
value = escapeRegExp(value);
|
||||
}
|
||||
|
||||
const node = new CM.Property(
|
||||
property,
|
||||
new CM.RegExpNode(`${prefix}${value}${suffix}`, "i")
|
||||
);
|
||||
|
||||
if (negate) {
|
||||
return new CM.Not(node);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildNumberComparisonNode(
|
||||
property: string,
|
||||
value: string | number,
|
||||
comparisonOperator: ComparisonOperator | "="
|
||||
) {
|
||||
if (typeof value === "string") {
|
||||
value = parseInt(value, 10);
|
||||
}
|
||||
|
||||
if (isNaN(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparisonOperator === "=") {
|
||||
return new CM.Property(property, new CM.Number(value));
|
||||
}
|
||||
|
||||
return new CM.Property(
|
||||
property,
|
||||
new CM.Comparison(comparisonOperator, value)
|
||||
);
|
||||
}
|
||||
|
||||
function buildBooleanNode(property: string, value: boolean) {
|
||||
const node = new CM.TruthyProperty(property);
|
||||
|
||||
if (!value) {
|
||||
return new CM.Not(node);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export function buildComplexMatcherNode(
|
||||
comparisonType: FilterComparisonType,
|
||||
property: string,
|
||||
value: string,
|
||||
negate: boolean
|
||||
): ComplexMatcherNode | undefined {
|
||||
switch (comparisonType) {
|
||||
case "stringContains":
|
||||
return buildStringNode(property, value, negate);
|
||||
case "stringStartsWith":
|
||||
return buildRegexNode(property, value, "^", "", true, negate);
|
||||
case "stringEndsWith":
|
||||
return buildRegexNode(property, value, "", "$", true, negate);
|
||||
case "stringEquals":
|
||||
return buildRegexNode(property, value, "^", "$", true, negate);
|
||||
case "stringMatchesRegex":
|
||||
return buildRegexNode(property, value, "", "", false, negate);
|
||||
case "numberLessThan":
|
||||
return buildNumberComparisonNode(property, value, "<");
|
||||
case "numberLessThanOrEquals":
|
||||
return buildNumberComparisonNode(property, value, "<=");
|
||||
case "numberEquals":
|
||||
return buildNumberComparisonNode(property, value, "=");
|
||||
case "numberGreaterThanOrEquals":
|
||||
return buildNumberComparisonNode(property, value, ">=");
|
||||
case "numberGreaterThan":
|
||||
return buildNumberComparisonNode(property, value, ">");
|
||||
case "booleanTrue":
|
||||
return buildBooleanNode(property, true);
|
||||
case "booleanFalse":
|
||||
return buildBooleanNode(property, false);
|
||||
}
|
||||
}
|
@ -1,3 +1,7 @@
|
||||
import type { Filter } from "@/types/filter";
|
||||
import { faSquareCheck } from "@fortawesome/pro-regular-svg-icons";
|
||||
import { faFont, faHashtag, faList } from "@fortawesome/pro-solid-svg-icons";
|
||||
|
||||
export function sortRecordsByNameLabel(
|
||||
record1: { name_label: string },
|
||||
record2: { name_label: string }
|
||||
@ -18,3 +22,22 @@ export function sortRecordsByNameLabel(
|
||||
export function escapeRegExp(string: string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function getFilterIcon(filter: Filter | undefined) {
|
||||
if (!filter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter.icon) {
|
||||
return filter.icon;
|
||||
}
|
||||
|
||||
const iconsByType = {
|
||||
string: faFont,
|
||||
number: faHashtag,
|
||||
boolean: faSquareCheck,
|
||||
enum: faList,
|
||||
};
|
||||
|
||||
return iconsByType[filter.type];
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import pool from "@/router/pool";
|
||||
import DemoView from "@/views/DemoView.vue";
|
||||
import HomeView from "@/views/HomeView.vue";
|
||||
import HostDashboardView from "@/views/host/HostDashboardView.vue";
|
||||
import HostRootView from "@/views/host/HostRootView.vue";
|
||||
@ -15,11 +14,6 @@ const router = createRouter({
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: "/demo",
|
||||
name: "demo",
|
||||
component: DemoView,
|
||||
},
|
||||
pool,
|
||||
{
|
||||
path: "/host/:uuid",
|
||||
|
@ -1,23 +1,23 @@
|
||||
declare module "complex-matcher" {
|
||||
type ComparisonOperator = ">" | ">=" | "<" | "<=";
|
||||
|
||||
class Node {
|
||||
class ComplexMatcherNode {
|
||||
createPredicate(): (value) => boolean;
|
||||
}
|
||||
|
||||
class Null extends Node {
|
||||
class Null extends ComplexMatcherNode {
|
||||
match(): true;
|
||||
toString(): "";
|
||||
}
|
||||
|
||||
class And extends Node {
|
||||
constructor(children: Node[]);
|
||||
class And extends ComplexMatcherNode {
|
||||
constructor(children: ComplexMatcherNode[]);
|
||||
match(value: any): boolean;
|
||||
toString(isNested?: boolean): string;
|
||||
children: Node[];
|
||||
children: ComplexMatcherNode[];
|
||||
}
|
||||
|
||||
class Comparison extends Node {
|
||||
class Comparison extends ComplexMatcherNode {
|
||||
constructor(operator: ComparisonOperator, value: number);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
@ -25,61 +25,65 @@ declare module "complex-matcher" {
|
||||
_value: number;
|
||||
}
|
||||
|
||||
class Or extends Node {
|
||||
constructor(children: Node[]);
|
||||
class Or extends ComplexMatcherNode {
|
||||
constructor(children: ComplexMatcherNode[]);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
children: Node[];
|
||||
children: ComplexMatcherNode[];
|
||||
}
|
||||
|
||||
class Not extends Node {
|
||||
constructor(child: Node);
|
||||
class Not extends ComplexMatcherNode {
|
||||
constructor(child: ComplexMatcherNode);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
child: Node;
|
||||
child: ComplexMatcherNode;
|
||||
}
|
||||
|
||||
class Number extends Node {
|
||||
class Number extends ComplexMatcherNode {
|
||||
constructor(value: number);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
class Property extends Node {
|
||||
constructor(name: string, child: Node);
|
||||
class Property extends ComplexMatcherNode {
|
||||
constructor(name: string, child: ComplexMatcherNode);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
name: string;
|
||||
child: Node;
|
||||
child: ComplexMatcherNode;
|
||||
}
|
||||
|
||||
class GlobPattern extends Node {
|
||||
class GlobPattern extends ComplexMatcherNode {
|
||||
constructor(value: string);
|
||||
match(re: RegExp, value: any): boolean;
|
||||
toString(): string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
class RegExpNode extends Node {
|
||||
class RegExpNode extends ComplexMatcherNode {
|
||||
constructor(pattern: string, flags: string);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
re: RegExp;
|
||||
}
|
||||
|
||||
class StringNode extends Node {
|
||||
class StringNode extends ComplexMatcherNode {
|
||||
constructor(value: string);
|
||||
match(lcValue: string, value: any): boolean;
|
||||
toString(): string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
class TruthyProperty extends Node {
|
||||
class TruthyProperty extends ComplexMatcherNode {
|
||||
constructor(name: string);
|
||||
match(value: any): boolean;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
function parse(input: string, pos? = 0, end?: input.length): Node;
|
||||
function parse(
|
||||
input: string,
|
||||
pos? = 0,
|
||||
end?: input.length
|
||||
): ComplexMatcherNode;
|
||||
}
|
||||
|
@ -2,30 +2,50 @@ import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
export type FilterType = "string" | "boolean" | "number" | "enum";
|
||||
|
||||
export interface FilterComparison {
|
||||
label: string;
|
||||
pattern: string;
|
||||
default?: boolean;
|
||||
before?: string | IconDefinition;
|
||||
after?: string | IconDefinition;
|
||||
escape?: boolean;
|
||||
}
|
||||
export type FilterComparisonType =
|
||||
| "stringContains"
|
||||
| "stringEquals"
|
||||
| "stringStartsWith"
|
||||
| "stringEndsWith"
|
||||
| "stringMatchesRegex"
|
||||
| "numberLessThan"
|
||||
| "numberLessThanOrEquals"
|
||||
| "numberEquals"
|
||||
| "numberGreaterThanOrEquals"
|
||||
| "numberGreaterThan"
|
||||
| "booleanTrue"
|
||||
| "booleanFalse";
|
||||
|
||||
export type FilterComparisons = Record<FilterType, FilterComparison[]>;
|
||||
export type FilterComparisons = {
|
||||
[key in FilterComparisonType]?: string;
|
||||
};
|
||||
|
||||
interface AvailableFilterCommon {
|
||||
property: string;
|
||||
interface FilterCommon {
|
||||
label?: string;
|
||||
icon?: IconDefinition;
|
||||
}
|
||||
|
||||
export interface AvailableFilterEnum extends AvailableFilterCommon {
|
||||
export interface FilterEnum extends FilterCommon {
|
||||
type: "enum";
|
||||
choices: string[];
|
||||
}
|
||||
|
||||
interface AvailableFilterOther extends AvailableFilterCommon {
|
||||
interface FilterOther extends FilterCommon {
|
||||
type: Exclude<FilterType, "enum">;
|
||||
}
|
||||
|
||||
export type AvailableFilter = AvailableFilterEnum | AvailableFilterOther;
|
||||
export type Filter = FilterEnum | FilterOther;
|
||||
|
||||
export type Filters = { [key: string]: Filter };
|
||||
|
||||
export interface NewFilter {
|
||||
id: number;
|
||||
content: string;
|
||||
isAdvanced: boolean;
|
||||
builder: {
|
||||
property: string;
|
||||
comparison: FilterComparisonType | "";
|
||||
value: string;
|
||||
negate: boolean;
|
||||
};
|
||||
}
|
||||
|
10
@xen-orchestra/lite/src/types/sort.ts
Normal file
10
@xen-orchestra/lite/src/types/sort.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
|
||||
interface Sort {
|
||||
label?: string;
|
||||
icon?: IconDefinition;
|
||||
}
|
||||
|
||||
export type Sorts = { [key: string]: Sort };
|
||||
|
||||
export type ActiveSorts = Map<string, boolean>;
|
@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div class="foo">
|
||||
<select>
|
||||
<option></option>
|
||||
<option value="foo">Foo</option>
|
||||
<option value="foo">Foo</option>
|
||||
<option value="foo">Foo</option>
|
||||
<option value="foo">Foo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="foo">
|
||||
<input placeholder="Bar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped>
|
||||
.foo:has(option[value]:checked) {
|
||||
border: 1px solid blue;
|
||||
}
|
||||
</style>
|
@ -1,98 +1,32 @@
|
||||
<template>
|
||||
<div class="pool-dashboard-view">
|
||||
<PoolDashboardStatus class="item" />
|
||||
|
||||
<UiCard style="min-width: 40rem">
|
||||
<UiTitle type="h4">RAM usage</UiTitle>
|
||||
<UsageBar title="Hosts" :data="data.slice(0, 5)">
|
||||
<template #header>
|
||||
<span>Host</span>
|
||||
<span>Top 5</span>
|
||||
</template>
|
||||
</UsageBar>
|
||||
<UiTitle type="h4">Storage usage</UiTitle>
|
||||
<ProgressBar :value="65" style="margin: 1rem 0" />
|
||||
<ProgressBar :value="50" style="margin: 1rem 0" />
|
||||
<ProgressBar :value="40" style="margin: 1rem 0" />
|
||||
<ProgressBar :value="22" style="margin: 1rem 0" />
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<UiBadge>38/64 GB</UiBadge>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UsageBar from "@/components/UsageBar.vue";
|
||||
import ProgressBar from "@/components/ProgressBar.vue";
|
||||
import PoolDashboardStatus from "@/components/pool/dashboard/PoolDashboardStatus.vue";
|
||||
import UiBadge from "@/components/ui/UiBadge.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import UiTitle from "@/components/ui/UiTitle.vue";
|
||||
|
||||
const data = [
|
||||
{
|
||||
value: 61,
|
||||
maxValue: 128,
|
||||
label: "R620-L2",
|
||||
badgeLabel: "61/128 GB",
|
||||
},
|
||||
{
|
||||
value: 38,
|
||||
maxValue: 64,
|
||||
label: "R620-L1",
|
||||
badgeLabel: "38/64 GB",
|
||||
},
|
||||
{
|
||||
value: 118,
|
||||
maxValue: 512,
|
||||
label: "R620-L3",
|
||||
badgeLabel: "118/512 GB",
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
maxValue: 1000,
|
||||
label: "R620-L5",
|
||||
badgeLabel: "60/1000 GB",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
maxValue: 32,
|
||||
label: "R620-L4",
|
||||
badgeLabel: "4/32 GB",
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
maxValue: 1000,
|
||||
label: "R620-L5",
|
||||
badgeLabel: "60/1000 GB",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
maxValue: 32,
|
||||
label: "R620-L4",
|
||||
badgeLabel: "4/32 GB",
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
maxValue: 1000,
|
||||
label: "R620-L5",
|
||||
badgeLabel: "60/1000 GB",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
maxValue: 32,
|
||||
label: "R620-L4",
|
||||
badgeLabel: "4/32 GB",
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
maxValue: 1000,
|
||||
label: "R620-L5",
|
||||
badgeLabel: "60/1000 GB",
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
maxValue: 32,
|
||||
label: "R620-L4",
|
||||
badgeLabel: "4/32 GB",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
.pool-dashboard-view {
|
||||
display: flex;
|
||||
padding: 2rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
|
@ -13,7 +13,7 @@ import PoolHeader from "@/components/pool/PoolHeader.vue";
|
||||
import PoolTabBar from "@/components/pool/PoolTabBar.vue";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="postcss" scoped>
|
||||
.view {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
@ -2,21 +2,23 @@
|
||||
<UiCard class="pool-vms-view">
|
||||
<VmsActionsBar :selected-refs="selectedVmsRefs" />
|
||||
<CollectionTable
|
||||
:available-filters="filters"
|
||||
:collection="vms"
|
||||
v-model="selectedVmsRefs"
|
||||
:available-filters="filters"
|
||||
:available-sorts="filters"
|
||||
:collection="vms"
|
||||
id-property="$ref"
|
||||
>
|
||||
<template #header>
|
||||
<th>
|
||||
<FontAwesomeIcon :icon="faPowerOff" />
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<ColumnHeader :icon="faPowerOff" />
|
||||
<ColumnHeader>Name</ColumnHeader>
|
||||
<ColumnHeader>Description</ColumnHeader>
|
||||
</template>
|
||||
<template #row="{ item: vm }">
|
||||
<td>
|
||||
<PowerStateIcon :state="vm.power_state" />
|
||||
</td>
|
||||
<td>{{ vm.name_label }}</td>
|
||||
<td>{{ vm.name_description }}</td>
|
||||
</template>
|
||||
</CollectionTable>
|
||||
</UiCard>
|
||||
@ -25,9 +27,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import type { AvailableFilter } from "@/types/filter";
|
||||
import { faPowerOff } from "@fortawesome/free-solid-svg-icons";
|
||||
import type { Filters } from "@/types/filter";
|
||||
import { faPowerOff } from "@fortawesome/pro-solid-svg-icons";
|
||||
import CollectionTable from "@/components/CollectionTable.vue";
|
||||
import ColumnHeader from "@/components/ColumnHeader.vue";
|
||||
import PowerStateIcon from "@/components/PowerStateIcon.vue";
|
||||
import UiCard from "@/components/ui/UiCard.vue";
|
||||
import VmsActionsBar from "@/components/vm/VmsActionsBar.vue";
|
||||
@ -36,18 +39,18 @@ import { useVmStore } from "@/stores/vm.store";
|
||||
const vmStore = useVmStore();
|
||||
const { allRecords: vms } = storeToRefs(vmStore);
|
||||
|
||||
const filters: AvailableFilter[] = [
|
||||
{ property: "name_label", label: "VM Name", type: "string" },
|
||||
{
|
||||
property: "power_state",
|
||||
const filters: Filters = {
|
||||
name_label: { label: "VM Name", type: "string" },
|
||||
name_description: { label: "VM Description", type: "string" },
|
||||
power_state: {
|
||||
label: "VM State",
|
||||
icon: faPowerOff,
|
||||
type: "enum",
|
||||
choices: ["Running", "Halted", "Paused"],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const selectedVmsRefs = ref([]);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<style lang="postcss" scoped></style>
|
||||
|
@ -2,7 +2,7 @@
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2017"],
|
||||
"lib": ["ES2019"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
|
Loading…
Reference in New Issue
Block a user