chore(lite): merge old repo to XO

This commit is contained in:
Thierry Goettelmann 2022-08-01 17:49:37 +02:00 committed by Julien Fontanet
parent 44ff5d0e4d
commit b8c9770d43
53 changed files with 1302 additions and 791 deletions

View File

@ -27,3 +27,4 @@ coverage
*.sln
*.sw?
.env
.npmrc

View File

@ -0,0 +1,2 @@
@fortawesome:registry=https://npm.fontawesome.com/
//npm.fontawesome.com/:_authToken=INSERT_FONT_AWESOME_PRO_TOKEN_HERE

View File

@ -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`)

View File

@ -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",

View File

@ -5,7 +5,6 @@
body {
min-height: 100vh;
font-family: Poppins, sans-serif;
font-size: 1.3rem;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;

View File

@ -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 {

View File

@ -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;

View 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>

View File

@ -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 = () => {

View File

@ -36,7 +36,7 @@ async function handleSubmit() {
}
</script>
<style scoped>
<style lang="postcss" scoped>
.form-container {
display: flex;
align-items: center;

View File

@ -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"
<div
v-if="newFilters.some((filter) => filter.isAdvanced)"
class="available-properties"
>
<select v-model="items[index].filterPattern">
<option
v-for="comparison in getComparisonsForIndex(index)"
:key="comparison.pattern"
:value="comparison.pattern"
Available properties for advanced filter:
<div class="properties">
<UiBadge
v-for="(filter, property) in availableFilters"
:key="property"
:icon="getFilterIcon(filter)"
>
{{ 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 />
{{ 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 result = items.value
.map((item) => {
return item.filterPattern
.replace("%p", item.selectedFilter?.property)
.replace(
"%v",
currentComparison.value?.escape
? escapeRegExp(item.filterValue)
: item.filterValue
const filters = newFilters.value.filter(
(newFilter) => newFilter.content !== ""
);
})
.join(" ");
if (items.value.length === 1) {
return result;
if (filters.length === 0) {
return "";
}
return `|(${result})`;
if (filters.length === 1) {
return filters[0].content;
}
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>

View 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>

View 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>

View File

@ -1,4 +1,5 @@
<template>
<div class="filter-and-sort">
<CollectionFilter
v-if="availableFilters"
:active-filters="filters"
@ -7,6 +8,15 @@
@remove-filter="removeFilter"
/>
<CollectionSorter
:active-sorts="sorts"
:available-sorts="availableSorts"
@add-sort="addSort"
@remove-sort="removeSort"
@toggle-sort-direction="toggleSortDirection"
/>
</div>
<UiTable>
<template #header>
<td v-if="isSelectable">
@ -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>

View 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>

View File

@ -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>

View File

@ -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);
}
}

View File

@ -1,6 +1,8 @@
<template>
<div class="infra-action">
<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>

View File

@ -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";

View File

@ -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 {

View File

@ -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";

View File

@ -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>

View File

@ -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";

View File

@ -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";

View File

@ -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);

View File

@ -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" />
<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);
&:not([disabled]):hover {
&.disabled {
color: var(--color-blue-scale-400);
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);
}
&[disabled] {
opacity: 0.5;
&: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);
}
}
}
}
.icon {
font-size: 1.6rem;
}
</style>

View File

@ -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;

View File

@ -1,35 +1,61 @@
<template>
<span class="ui-filter">
<span class="label" @click="emit('edit')">
<slot />
<FontAwesomeIcon :icon="faRemove" class="remove" @click="emit('remove')" />
</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>

View File

@ -6,7 +6,7 @@
<script lang="ts" setup></script>
<style scoped>
<style lang="postcss" scoped>
.ui-filter-group {
display: flex;
padding: 1rem;

View File

@ -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>

View File

@ -13,7 +13,7 @@
<script lang="ts" setup></script>
<style scoped>
<style lang="postcss" scoped>
.ui-table {
border-spacing: 0;
}

View File

@ -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);

View File

@ -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() {
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>
```

View File

@ -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 = [
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[] = [
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>
```

View File

@ -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()]);
}
}

View File

@ -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"];
})
);
}

View File

@ -2,17 +2,14 @@
```vue
<script lang="ts" setup>
import useFilteredCollection from "./filtered-collection.composable";
import useFilteredCollection from './filtered-collection.composable';
const players = [
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>
```

View File

@ -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;
}

View File

@ -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 {
const {
payload: removeModalPayload,
isOpen: isRemoveModalOpen,
open: openRemoveModal,
close: closeRemoveModal,
} = useModal();
} = useModal()
async function handleRemove() {
async function handleRemove() {
await removeItem(removeModalPayload.id);
closeRemoveModal();
}
closeRemoveModal()
}
</script>
```

View File

@ -6,7 +6,7 @@
<thead>
<tr>
<th>
<input type="checkbox" v-model="areAllSelected" />
<input type="checkbox" v-model="areAllSelected">
</th>
<th>Name</th>
</tr>
@ -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>
```

View File

@ -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,
};
}

View File

@ -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));
});
}

View 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);
}
}

View File

@ -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];
}

View File

@ -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",

View File

@ -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;
}

View File

@ -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;
};
}

View 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>;

View File

@ -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>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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/*"]