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 *.sln
*.sw? *.sw?
.env .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 ### Icons
This project is using Font Awesome Pro 6.
Here is how to use an icon in your template. Here is how to use an icon in your template.
Note: `FontAwesomeIcon` is a global component that does not need to be imported. 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> </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 ### CSS
Always use `rem` unit (`1rem` = `10px`) Always use `rem` unit (`1rem` = `10px`)

View File

@ -11,9 +11,10 @@
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-brands-svg-icons": "^6.1.1", "@fortawesome/pro-light-svg-icons": "^6.1.2",
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/pro-regular-svg-icons": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/pro-solid-svg-icons": "^6.1.2",
"@fortawesome/pro-thin-svg-icons": "^6.1.2",
"@fortawesome/vue-fontawesome": "^3.0.1", "@fortawesome/vue-fontawesome": "^3.0.1",
"@novnc/novnc": "^1.3.0", "@novnc/novnc": "^1.3.0",
"@vueuse/core": "^8.7.5", "@vueuse/core": "^8.7.5",

View File

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

View File

@ -9,6 +9,7 @@ html {
box-sizing: inherit; box-sizing: inherit;
margin: 0; margin: 0;
position: relative; position: relative;
font-family: Poppins, sans-serif;
} }
body, h1, h2, h3, h4, h5, h6, p, ol, ul { 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-000: #000000;
--color-blue-scale-100: #1A1B38; --color-blue-scale-100: #1A1B38;
--color-blue-scale-200: #595A6F; --color-blue-scale-200: #595A6F;
@ -30,13 +30,13 @@ body {
--color-orange-world-d40: #554F94; --color-orange-world-d40: #554F94;
--color-orange-world-d60: #383563; --color-orange-world-d60: #383563;
--color-red-vates-l60: #D1CEFB; --color-red-vates-l60: #DDA5A7;
--color-red-vates-l40: #BBB5F9; --color-red-vates-l40: #CE787C;
--color-red-vates-l20: #A39DF8; --color-red-vates-l20: #BF4F51;
--color-red-vates-base: #8F84FF; --color-red-vates-base: #BE1621;
--color-red-vates-d20: #716AC6; --color-red-vates-d20: #8E2221;
--color-red-vates-d40: #554F94; --color-red-vates-d40: #6A1919;
--color-red-vates-d60: #383563; --color-red-vates-d60: #471010;
--color-grayscale-200: #585757; --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-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-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); --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-000: #FFFFFF;
--color-blue-scale-100: #E5E5E7; --color-blue-scale-100: #E5E5E7;
--color-blue-scale-200: #9899A5; --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 router = useRouter();
const toggleTheme = () => { const toggleTheme = () => {
document.body.classList.toggle("dark"); document.documentElement.classList.toggle("dark");
}; };
const logout = () => { const logout = () => {

View File

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

View File

@ -3,118 +3,74 @@
<UiFilter <UiFilter
v-for="filter in activeFilters" v-for="filter in activeFilters"
:key="filter" :key="filter"
@edit="editFilter(filter)"
@remove="emit('removeFilter', filter)" @remove="emit('removeFilter', filter)"
> >
{{ filter }} {{ filter }}
</UiFilter> </UiFilter>
<UiButton <UiButton :icon="faPlus" class="add-filter" color="secondary" @click="open">
:icon-left="faPlus"
class="add-filter"
color="action"
@click="open"
>
Add filter Add filter
</UiButton> </UiButton>
</UiFilterGroup> </UiFilterGroup>
<UiModal v-if="isOpen"> <UiModal v-if="isOpen">
Add a filter
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div class="form-row" v-for="(item, index) in items" :key="index"> <div class="rows">
<span v-if="items.length > 1" style="width: 2rem">{{ <CollectionFilterRow
index > 0 ? "OR" : "" v-for="(newFilter, index) in newFilters"
}}</span> :key="newFilter.id"
<FormWidget v-model="newFilters[index]"
v-if="!isAdvancedModeEnabled" :available-filters="availableFilters"
:before="filterIconForIndex(index)" @remove="removeNewFilter"
> />
<select v-model="items[index].selectedFilter"> </div>
<option v-if="!selectedFilter"></option>
<option
v-for="availableFilter in availableFilters"
:key="availableFilter.property"
:value="availableFilter"
>
{{ availableFilter.label ?? availableFilter.property }}
</option>
</select>
</FormWidget>
<FormWidget <div
v-if="!isAdvancedModeEnabled && items[index].selectedFilter" v-if="newFilters.some((filter) => filter.isAdvanced)"
> class="available-properties"
<select v-model="items[index].filterPattern"> >
<option Available properties for advanced filter:
v-for="comparison in getComparisonsForIndex(index)" <div class="properties">
:key="comparison.pattern" <UiBadge
:value="comparison.pattern" v-for="(filter, property) in availableFilters"
> :key="property"
{{ comparison.label }} :icon="getFilterIcon(filter)"
</option> >
</select> {{ property }}
</FormWidget> </UiBadge>
</div>
<FormWidget
v-if="isFilterTakingValue(index)"
:after="getComparisonForIndex(index)?.after"
:before="getComparisonForIndex(index)?.before"
>
<input v-model="items[index].filterValue" />
</FormWidget>
<br />
</div> </div>
<UiButtonGroup> <UiButtonGroup>
<UiButton color="action" @click="addItem">+OR</UiButton> <UiButton color="secondary" @click="addNewFilter">+OR</UiButton>
<UiButton :disabled="!isFilterValid" type="submit">
<UiButton {{ editedFilter ? "Update" : "Add" }}
type="submit"
:disabled="!generatedFilter || !isGeneratedFilterValid"
>
Add
</UiButton>
<UiButton color="action" type="button" @click="handleCancel">
Cancel
</UiButton> </UiButton>
<UiButton color="secondary" @click="handleCancel">Cancel</UiButton>
</UiButtonGroup> </UiButtonGroup>
</form> </form>
<div style="margin-top: 1rem">
<UiButton
v-if="!isAdvancedModeEnabled"
@click="activateAdvancedMode"
color="action"
>
Switch to advanced mode...
</UiButton>
</div>
</UiModal> </UiModal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import * as CM from "complex-matcher"; import { Or, parse } from "complex-matcher";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import type { import type { Filters, NewFilter } from "@/types/filter";
AvailableFilter, import { faPlus } from "@fortawesome/pro-solid-svg-icons";
FilterComparisons, import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
FilterType, import UiBadge from "@/components/ui/UiBadge.vue";
} 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 UiButton from "@/components/ui/UiButton.vue"; import UiButton from "@/components/ui/UiButton.vue";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue"; import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import UiFilter from "@/components/ui/UiFilter.vue"; import UiFilter from "@/components/ui/UiFilter.vue";
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue"; import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
import UiModal from "@/components/ui/UiModal.vue"; import UiModal from "@/components/ui/UiModal.vue";
import useModal from "@/composables/modal.composable"; import useModal from "@/composables/modal.composable";
import { escapeRegExp } from "@/libs/utils"; import { getFilterIcon } from "@/libs/utils";
defineProps<{ defineProps<{
availableFilters: AvailableFilter[];
activeFilters: string[]; activeFilters: string[];
availableFilters: Filters;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -122,291 +78,113 @@ const emit = defineEmits<{
(event: "removeFilter", filter: string): void; (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 addNewFilter = () =>
const selectedFilter = items.value[index]?.selectedFilter; newFilters.value.push({
id: newFilterId++,
if (!selectedFilter) { content: "",
return []; isAdvanced: false,
} builder: { property: "", comparison: "", value: "", negate: false },
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 removeNewFilter = (id: number) => {
const index = newFilters.value.findIndex((newFilter) => newFilter.id === id);
if (index >= 0) {
newFilters.value.splice(index, 1);
}
}; };
addItem(); addNewFilter();
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>();
const generatedFilter = computed(() => { const generatedFilter = computed(() => {
if (items.value.length === 0) { const filters = newFilters.value.filter(
return; (newFilter) => newFilter.content !== ""
);
if (filters.length === 0) {
return "";
} }
const result = items.value if (filters.length === 1) {
.map((item) => { return filters[0].content;
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;
} }
return `|(${result})`; return `|(${filters.map((filter) => filter.content).join(" ")})`;
}); });
// const generatedFilter2 = computed(() => { const isFilterValid = computed(() => generatedFilter.value !== "");
// 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 isGeneratedFilterValid = computed(() => { const editedFilter = ref();
if (!generatedFilter.value) {
return true;
}
try { const editFilter = (filter: string) => {
if (generatedFilter.value) { const parsedFilter = parse(filter);
CM.parse(generatedFilter.value);
}
return true; const nodes =
} catch (e) { parsedFilter instanceof Or ? parsedFilter.children : [parsedFilter];
return false;
}
});
const activateAdvancedMode = () => { newFilters.value = nodes.map((node) => ({
filterValue.value = generatedFilter.value; id: newFilterId++,
isAdvancedModeEnabled.value = true; content: node.toString(),
isAdvanced: true,
builder: { property: "", comparison: "", value: "", negate: false },
}));
editedFilter.value = filter;
open();
}; };
const resetAndClose = () => { const reset = () => {
items.value = []; editedFilter.value = "";
addItem(); newFilters.value = [];
selectedFilter.value = undefined; addNewFilter();
filterValue.value = undefined;
isAdvancedModeEnabled.value = false;
close();
}; };
const handleSubmit = () => { const handleSubmit = () => {
if (generatedFilter.value) { if (editedFilter.value) {
emit("addFilter", generatedFilter.value); emit("removeFilter", editedFilter.value);
resetAndClose();
} }
emit("addFilter", generatedFilter.value);
reset();
close();
}; };
const handleCancel = () => { const handleCancel = () => {
resetAndClose(); reset();
close();
}; };
</script> </script>
<style scoped> <style lang="postcss" scoped>
.add-filter { .add-filter {
height: 3.4rem; 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; display: flex;
gap: 1rem; margin-top: 0.6rem;
align-items: center; gap: 0.5rem;
margin-bottom: 1rem;
} }
</style> </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,11 +1,21 @@
<template> <template>
<CollectionFilter <div class="filter-and-sort">
v-if="availableFilters" <CollectionFilter
:active-filters="filters" v-if="availableFilters"
:available-filters="availableFilters" :active-filters="filters"
@add-filter="addFilter" :available-filters="availableFilters"
@remove-filter="removeFilter" @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> <UiTable>
<template #header> <template #header>
@ -15,9 +25,13 @@
<slot name="header" /> <slot name="header" />
</template> </template>
<tr v-for="item in filteredCollection" :key="item.$ref"> <tr v-for="item in filteredAndSortedCollection" :key="item[idProperty]">
<td v-if="isSelectable"> <td v-if="isSelectable">
<input v-model="selected" :value="item.$ref" type="checkbox" /> <input
v-model="selected"
:value="item[props.idProperty]"
type="checkbox"
/>
</td> </td>
<slot :item="item" name="row" /> <slot :item="item" name="row" />
</tr> </tr>
@ -26,18 +40,23 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, toRef, watch } from "vue"; 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 CollectionFilter from "@/components/CollectionFilter.vue";
import CollectionSorter from "@/components/CollectionSorter.vue";
import UiTable from "@/components/ui/UiTable.vue"; import UiTable from "@/components/ui/UiTable.vue";
import useCollectionFilter from "@/composables/collection-filter.composable"; import useCollectionFilter from "@/composables/collection-filter.composable";
import useCollectionSorter from "@/composables/collection-sorter.composable";
import useFilteredCollection from "@/composables/filtered-collection.composable"; import useFilteredCollection from "@/composables/filtered-collection.composable";
import useMultiSelect from "@/composables/multi-select.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<{ const props = defineProps<{
modelValue?: string[]; modelValue?: string[];
availableFilters?: AvailableFilter[]; availableFilters?: Filters;
collection: XenApiRecord[]; availableSorts?: Sorts;
collection: Record<string, any>[];
idProperty: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -47,16 +66,25 @@ const emit = defineEmits<{
const isSelectable = computed(() => props.modelValue !== undefined); const isSelectable = computed(() => props.modelValue !== undefined);
const { filters, addFilter, removeFilter, predicate } = useCollectionFilter(); const { filters, addFilter, removeFilter, predicate } = useCollectionFilter();
const { sorts, addSort, removeSort, toggleSortDirection, compareFn } =
useCollectionSorter();
const filteredCollection = useFilteredCollection( const filteredCollection = useFilteredCollection(
toRef(props, "collection"), toRef(props, "collection"),
predicate 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(() => const selectableRefs = computed(() =>
filteredCollection.value.map((item) => item.$ref) filteredAndSortedCollection.value.map((item) => item[props.idProperty])
); );
const { selected, areAllSelected } = useMultiSelect(usableRefs, selectableRefs); const { selected, areAllSelected } = useMultiSelect(usableRefs, selectableRefs);
@ -66,4 +94,8 @@ watch(selected, (selected) => emit("update:modelValue", selected), {
}); });
</script> </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> <template>
<label :class="{ inline }" class="form-widget"> <label class="form-widget">
<span v-if="label || $slots.label" class="label"> <span v-if="label || $slots.label" class="label">
<slot name="label"> <slot name="label">
{{ label }} {{ label }}
@ -29,8 +29,8 @@
import type { IconDefinition } from "@fortawesome/fontawesome-common-types"; import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineProps<{ defineProps<{
before?: IconDefinition | string; before?: IconDefinition | string | object; // "object" added as workaround
after?: IconDefinition | string; after?: IconDefinition | string | object; // See https://github.com/vuejs/core/issues/4294
label?: string; label?: string;
inline?: boolean; inline?: boolean;
}>(); }>();
@ -39,9 +39,9 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
typeof maybeIcon === "object"; typeof maybeIcon === "object";
</script> </script>
<style scoped> <style lang="postcss" scoped>
.form-widget { .form-widget {
display: inline-flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 1rem; gap: 1rem;
font-size: 1.6rem; font-size: 1.6rem;
@ -50,12 +50,13 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
.widget { .widget {
display: inline-flex; display: inline-flex;
flex: 1;
align-items: stretch; align-items: stretch;
overflow: hidden; overflow: hidden;
padding: 0 0.7rem; padding: 0 0.7rem;
border: 1px solid var(--color-blue-scale-400); border: 1px solid var(--color-blue-scale-400);
border-radius: 0.8rem; border-radius: 0.8rem;
background-color: var(--form-background); background-color: var(--color-blue-scale-500);
box-shadow: var(--shadow-100); box-shadow: var(--shadow-100);
gap: 0.1rem; gap: 0.1rem;
@ -64,29 +65,41 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
} }
} }
.label {
display: flex;
align-items: center;
}
.form-widget:hover .widget { .form-widget:hover .widget {
border-color: var(--color-extra-blue-l60); border-color: var(--color-extra-blue-l60);
} }
.element { .element {
display: flex; display: flex;
flex: 1;
align-items: center;
} }
.before, .before,
.after { .after {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 0.3rem;
} }
:slotted(input), :slotted(input),
:slotted(select), :slotted(select),
:slotted(textarea) { :slotted(textarea) {
font-family: Poppins, sans-serif;
font-size: inherit; font-size: inherit;
border: none; border: none;
outline: none; outline: none;
color: var(--color-blue-scale-100); 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"]) { :slotted(input[type="checkbox"]) {
@ -121,8 +134,7 @@ const isIcon = (maybeIcon: any): maybeIcon is IconDefinition =>
&:disabled { &:disabled {
cursor: not-allowed; cursor: not-allowed;
color: var(--form-control-disabled); color: var(--color-blue-scale-200);
--form-control-color: var(--form-control-disabled);
} }
} }
</style> </style>

View File

@ -10,7 +10,8 @@ import {
faPlay, faPlay,
faQuestion, faQuestion,
faStop, 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"; import type { PowerState } from "@/libs/xen-api";
const props = defineProps<{ const props = defineProps<{
@ -18,36 +19,33 @@ const props = defineProps<{
}>(); }>();
const icon = computed(() => { const icon = computed(() => {
switch (props.state) { const icons = {
case "Running": Running: faPlay,
return faPlay; Paused: faPause,
case "Paused": Suspended: faMoon,
return faPause; Unknown: faQuestion,
case "Suspended": Halted: faStop,
return faMoon; };
case "Unknown":
return faQuestion; return icons[props.state];
default:
return faStop;
}
}); });
const className = computed(() => props.state.toLocaleLowerCase()); const className = computed(() => `state-${props.state.toLocaleLowerCase()}`);
</script> </script>
<style scoped lang="postcss"> <style scoped lang="postcss">
.power-state-icon { .power-state-icon {
color: var(--color-extra-blue-d60); color: var(--color-extra-blue-d60);
&.running { &.state-running {
color: var(--color-green-infra-base); color: var(--color-green-infra-base);
} }
&.paused { &.state-paused {
color: var(--color-blue-scale-300); color: var(--color-blue-scale-300);
} }
&.suspended { &.state-suspended {
color: var(--color-extra-blue-d20); color: var(--color-extra-blue-d20);
} }
} }

View File

@ -1,6 +1,8 @@
<template> <template>
<div class="infra-action"> <div class="infra-action">
<FontAwesomeIcon :icon="icon" fixed-width /> <slot>
<FontAwesomeIcon :icon="icon" fixed-width />
</slot>
</div> </div>
</template> </template>
@ -8,7 +10,7 @@
import type { IconDefinition } from "@fortawesome/fontawesome-common-types"; import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineProps<{ defineProps<{
icon: IconDefinition; icon?: IconDefinition;
}>(); }>();
</script> </script>

View File

@ -20,11 +20,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue"; import { computed } from "vue";
import { import { faAngleDown, faAngleUp } from "@fortawesome/pro-light-svg-icons";
faAngleDown, import { faServer } from "@fortawesome/pro-regular-svg-icons";
faAngleUp,
faServer,
} from "@fortawesome/free-solid-svg-icons";
import { useToggle } from "@vueuse/core"; import { useToggle } from "@vueuse/core";
import InfraAction from "@/components/infra/InfraAction.vue"; import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue"; import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";

View File

@ -33,8 +33,6 @@ defineProps<{
.infra-item-label { .infra-item-label {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
height: 6rem;
margin-bottom: 0.2rem;
color: var(--color-blue-scale-100); color: var(--color-blue-scale-100);
border-radius: 0.8rem; border-radius: 0.8rem;
background-color: var(--background-color-primary); background-color: var(--background-color-primary);
@ -64,10 +62,12 @@ defineProps<{
align-items: center; align-items: center;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding-left: 1.5rem; padding: 1.5rem;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
gap: 1rem; gap: 1rem;
font-weight: 500;
font-size: 2rem;
} }
.text { .text {

View File

@ -1,9 +1,9 @@
<template> <template>
<ul class="infra-pool-list"> <ul class="infra-pool-list">
<InfraLoadingItem v-if="!isReady" :icon="faBuilding" /> <InfraLoadingItem v-if="!isReady" :icon="faBuildings" />
<li v-else class="infra-pool-item"> <li v-else class="infra-pool-item">
<InfraItemLabel <InfraItemLabel
:icon="faBuilding" :icon="faBuildings"
:route="{ name: 'pool.dashboard', params: { uuid: pool.uuid } }" :route="{ name: 'pool.dashboard', params: { uuid: pool.uuid } }"
current current
> >
@ -19,7 +19,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { storeToRefs } from "pinia"; 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 InfraHostList from "@/components/infra/InfraHostList.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue"; import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue"; import InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";

View File

@ -7,7 +7,9 @@
> >
{{ vm.name_label }} {{ vm.name_label }}
<template #actions> <template #actions>
<InfraAction :class="powerStateClass" :icon="powerStateIcon" /> <InfraAction>
<PowerStateIcon :state="vm?.power_state" />
</InfraAction>
</template> </template>
</InfraItemLabel> </InfraItemLabel>
</li> </li>
@ -15,14 +17,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { import { faDisplay } from "@fortawesome/pro-solid-svg-icons";
faDisplay,
faMoon,
faPause,
faPlay,
faStop,
} from "@fortawesome/free-solid-svg-icons";
import { useIntersectionObserver } from "@vueuse/core"; import { useIntersectionObserver } from "@vueuse/core";
import PowerStateIcon from "@/components/PowerStateIcon.vue";
import InfraAction from "@/components/infra/InfraAction.vue"; import InfraAction from "@/components/infra/InfraAction.vue";
import InfraItemLabel from "@/components/infra/InfraItemLabel.vue"; import InfraItemLabel from "@/components/infra/InfraItemLabel.vue";
import { useVmStore } from "@/stores/vm.store"; import { useVmStore } from "@/stores/vm.store";
@ -44,23 +41,6 @@ const { stop } = useIntersectionObserver(rootElement, ([entry]) => {
const vmStore = useVmStore(); const vmStore = useVmStore();
const vm = computed(() => vmStore.getRecord(props.vmOpaqueRef)); 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> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>

View File

@ -14,7 +14,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { computed } from "vue"; 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 InfraLoadingItem from "@/components/infra/InfraLoadingItem.vue";
import InfraVmItem from "@/components/infra/InfraVmItem.vue"; import InfraVmItem from "@/components/infra/InfraVmItem.vue";
import { useVmStore } from "@/stores/vm.store"; import { useVmStore } from "@/stores/vm.store";

View File

@ -6,7 +6,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue"; 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 TitleBar from "@/components/TitleBar.vue";
import { usePoolStore } from "@/stores/pool.store"; import { usePoolStore } from "@/stores/pool.store";

View File

@ -1,15 +1,27 @@
<template> <template>
<span class="ui-badge"> <span class="ui-badge">
<FontAwesomeIcon :icon="icon" />
<slot /> <slot />
</span> </span>
</template> </template>
<script lang="ts" setup>
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineProps<{
icon?: IconDefinition;
}>();
</script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
.ui-badge { .ui-badge {
font-size: 1.2rem; display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 1.4rem;
font-weight: 500; font-weight: 500;
line-height: 100%; padding: 0 0.8rem;
padding: 0.1rem 0.5rem; height: 2.4rem;
color: var(--color-blue-scale-500); color: var(--color-blue-scale-500);
border-radius: 9.6rem; border-radius: 9.6rem;
background-color: var(--color-blue-scale-300); background-color: var(--color-blue-scale-300);

View File

@ -1,70 +1,103 @@
<template> <template>
<button <button
:class="`color-${buttonColor}`" :class="[
`color-${buttonColor}`,
{ busy: isBusy, disabled: isDisabled, 'has-icon': icon !== undefined },
]"
:disabled="isBusy || isDisabled" :disabled="isBusy || isDisabled"
:type="type || 'button'" :type="type || 'button'"
class="ui-button" class="ui-button"
> >
<FontAwesomeIcon v-if="isBusy" :icon="faSpinner" spin /> <FontAwesomeIcon v-if="isBusy" :icon="faSpinner" class="icon" spin />
<FontAwesomeIcon v-else-if="iconLeft" :icon="iconLeft" /> <template v-else>
<slot /> <FontAwesomeIcon v-if="icon" :icon="icon" class="icon" />
<slot />
</template>
</button> </button>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, unref } from "vue"; import { computed, inject, unref } from "vue";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types"; 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<{ const props = defineProps<{
type?: "button" | "reset" | "submit"; type?: "button" | "reset" | "submit";
busy?: boolean; busy?: boolean;
disabled?: boolean; disabled?: boolean;
iconLeft?: IconDefinition; icon?: IconDefinition;
color?: "default" | "action"; color?: "primary" | "secondary";
}>(); }>();
const isGroupBusy = inject("isButtonGroupBusy", false); 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 isGroupDisabled = inject("isButtonGroupDisabled", false);
const isDisabled = computed(() => props.disabled || unref(isGroupDisabled)); const isDisabled = computed(() => props.disabled ?? unref(isGroupDisabled));
const buttonGroupColor = inject("buttonGroupColor", "default"); const buttonGroupColor = inject("buttonGroupColor", "primary");
const buttonColor = computed(() => props.color || unref(buttonGroupColor)); const buttonColor = computed(() => props.color ?? unref(buttonGroupColor));
</script> </script>
<style scoped> <style lang="postcss" scoped>
.ui-button { .ui-button {
font-size: 1.6rem; font-size: 2rem;
font-weight: 400; font-weight: 500;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 3.8rem; min-width: 10rem;
margin: 0; height: 5rem;
padding: 0 1rem; padding: 0 1.5rem;
color: var(--color-blue-scale-500);
border: none; border: none;
border-radius: 0.8rem; border-radius: 0.8rem;
background-color: var(--color-extra-blue-base); box-shadow: var(--shadow-100);
gap: 1rem; gap: 1.5rem;
&:not([disabled]) { &.has-icon {
cursor: pointer; min-width: 13rem;
} }
&.color-action { &.disabled {
color: var(--color-grayscale-200); color: var(--color-blue-scale-400);
background-color: var(--color-white); background-color: var(--background-color-secondary);
}
&:not([disabled]):hover { &:not(.disabled) {
background-color: var(--background-color-secondary); &.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] { .icon {
opacity: 0.5; font-size: 1.6rem;
}
} }
</style> </style>

View File

@ -5,19 +5,28 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { provide, toRef } from "vue"; import { computed, provide } from "vue";
const props = defineProps<{ const props = defineProps<{
busy?: boolean; busy?: boolean;
disabled?: boolean; disabled?: boolean;
color?: "default" | "action"; color?: "primary" | "secondary";
}>(); }>();
provide("isButtonGroupBusy", toRef(props, "busy")); provide(
provide("isButtonGroupDisabled", toRef(props, "disabled")); "isButtonGroupBusy",
provide("buttonGroupColor", toRef(props, "color")); computed(() => props.busy ?? false)
);
provide(
"isButtonGroupDisabled",
computed(() => props.disabled ?? false)
);
provide(
"buttonGroupColor",
computed(() => props.color ?? "primary")
);
</script> </script>
<style scoped> <style lang="postcss" scoped>
.ui-button-group { .ui-button-group {
display: flex; display: flex;
justify-content: left; justify-content: left;

View File

@ -1,35 +1,61 @@
<template> <template>
<span class="ui-filter"> <span class="ui-filter">
<slot /> <span class="label" @click="emit('edit')">
<FontAwesomeIcon :icon="faRemove" class="remove" @click="emit('remove')" /> <slot />
</span>
<span class="remove" @click.stop="emit('remove')">
<FontAwesomeIcon :icon="faRemove" class="icon" />
</span>
</span> </span>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { faRemove } from "@fortawesome/free-solid-svg-icons"; import { faRemove } from "@fortawesome/pro-solid-svg-icons";
const emit = defineEmits<{ const emit = defineEmits<{
(event: "edit"): void;
(event: "remove"): void; (event: "remove"): void;
}>(); }>();
</script> </script>
<style scoped> <style lang="postcss" scoped>
.ui-filter { .ui-filter {
overflow: hidden;
font-size: 1.6rem; font-size: 1.6rem;
display: inline-flex; display: inline-flex;
align-items: center; align-items: stretch;
justify-content: center; justify-content: center;
height: 3.4rem; height: 3.4rem;
padding: 0 1rem;
color: var(--color-extra-blue-base); color: var(--color-extra-blue-base);
border: 1px solid var(--color-extra-blue-base);
border-radius: 1.7rem; border-radius: 1.7rem;
background-color: var(--background-color-extra-blue); background-color: var(--background-color-extra-blue);
gap: 1rem; 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 { .remove {
cursor: pointer; color: white;
color: red; 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> </style>

View File

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

View File

@ -10,7 +10,7 @@
<script lang="ts" setup></script> <script lang="ts" setup></script>
<style scoped> <style lang="postcss" scoped>
.ui-modal { .ui-modal {
position: fixed; position: fixed;
top: 0; top: 0;
@ -24,7 +24,7 @@
} }
.content { .content {
background-color: white; background-color: var(--background-color-primary);
min-width: 40rem; min-width: 40rem;
padding: 2rem; padding: 2rem;
border-radius: 1rem; border-radius: 1rem;
@ -33,6 +33,6 @@
:slotted(.ui-button-group) { :slotted(.ui-button-group) {
justify-content: center; justify-content: center;
margin-top: 2rem; margin-top: 1rem;
} }
</style> </style>

View File

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

View File

@ -2,32 +2,28 @@
<UiButtonGroup <UiButtonGroup
:disabled="selectedRefs.length === 0" :disabled="selectedRefs.length === 0"
class="vms-actions-bar" class="vms-actions-bar"
color="action" color="secondary"
> >
<UiButton :icon-left="faPowerOff">Change power state</UiButton> <UiButton :icon="faPowerOff">Change power state</UiButton>
<UiButton :icon-left="faRoute">Migrate</UiButton> <UiButton :icon="faRoute">Migrate</UiButton>
<UiButton :icon-left="faCopy">Copy</UiButton> <UiButton :icon="faCopy">Copy</UiButton>
<UiButton :icon-left="faEdit">Edit config</UiButton> <UiButton :icon="faEdit">Edit config</UiButton>
<UiButton :icon-left="faCamera">Snapshot</UiButton> <UiButton :icon="faCamera">Snapshot</UiButton>
<UiButton :icon-left="faBox">Backup</UiButton> <UiButton :icon="faBox">Backup</UiButton>
<UiButton :icon-left="faTrashCan">Delete</UiButton> <UiButton :icon="faTrashCan">Delete</UiButton>
<UiButton :icon-left="faFileExport">Export</UiButton> <UiButton :icon="faFileExport">Export</UiButton>
</UiButtonGroup> </UiButtonGroup>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { import { faCopy, faEdit, faTrashCan } from "@fortawesome/pro-regular-svg-icons";
faCopy,
faEdit,
faTrashCan,
} from "@fortawesome/free-regular-svg-icons";
import { import {
faBox, faBox,
faCamera, faCamera,
faFileExport, faFileExport,
faPowerOff, faPowerOff,
faRoute, faRoute,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/pro-solid-svg-icons";
import UiButton from "@/components/ui/UiButton.vue"; import UiButton from "@/components/ui/UiButton.vue";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue"; import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
@ -37,7 +33,7 @@ defineProps<{
}>(); }>();
</script> </script>
<style scoped> <style lang="postcss" scoped>
.vms-actions-bar { .vms-actions-bar {
padding-bottom: 1rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--color-blue-scale-400); border-bottom: 1px solid var(--color-blue-scale-400);

View File

@ -1,22 +1,23 @@
# useBusy composable # useBusy composable
```vue ```vue
<template> <template>
<span class="error" v-if="error">{{ error }}</span> <span class="error" v-if="error">{{ error }}</span>
<button @click="run" :disabled="isBusy">Do something</button> <button @click="run" :disabled="isBusy">Do something</button>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import useBusy from "@/composables/busy.composable"; import useBusy from '@/composables/busy.composable';
async function doSomething() { async function doSomething() {
try { try {
// Doing some async work // Doing some async work
} catch (e) { } catch (e) {
throw "Something bad happened"; throw "Something bad happened";
}
} }
}
const { isBusy, error, run } = useBusy(doSomething); const { isBusy, error, run } = useBusy(doSomething)
</script> </script>
``` ```

View File

@ -1,5 +1,35 @@
# useCollectionFilter composable # 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 ```vue
<template> <template>
<CollectionFilter <CollectionFilter
@ -8,32 +38,32 @@
@add-filter="addFilter" @add-filter="addFilter"
@remove-filter="removeFilter" @remove-filter="removeFilter"
/> />
<div v-for="item in filteredCollection">...</div> <div v-for="item in filteredCollection">...</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue"; import CollectionFilter from "@/components/CollectionFilter.vue";
import CollectionFilter from "@/components/CollectionFilter.vue"; import useCollectionFilter from "@/composables/collection-filter.composable";
import useCollectionFilter from "@/composables/collection-filter.composable"; import { computed } from "vue";
const collection = [ const collection = [
{ name: "Foo", age: 5, registered: true }, { name: "Foo", age: 5, registered: true },
{ name: "Bar", age: 12, registered: false }, { name: "Bar", age: 12, registered: false },
{ name: "Foo Bar", age: 2, registered: true }, { name: "Foo Bar", age: 2, registered: true },
{ name: "Bar Baz", age: 45, registered: false }, { name: "Bar Baz", age: 45, registered: false },
{ name: "Foo Baz", age: 32, registered: false }, { name: "Foo Baz", age: 32, registered: false },
{ name: "Foo Bar Baz", age: 32, registered: true }, { name: "Foo Bar Baz", age: 32, registered: true },
]; ];
const availableFilters: AvailableFilter[] = [ const availableFilters: AvailableFilter[] = [
{ property: "name", label: "Name", type: "string" }, { property: "name", label: "Name", type: "string" },
{ property: "age", label: "Age", type: "number" }, { property: "age", label: "Age", type: "number" },
{ property: "registered", label: "Registered", type: "boolean", icon: faKey }, { 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> </script>
``` ```

View File

@ -20,9 +20,11 @@ export default function useCollectionFilter(
if (config.queryStringParam) { if (config.queryStringParam) {
const queryStringParam = config.queryStringParam; const queryStringParam = config.queryStringParam;
watch(filters, (value) => { watch(filters, (value) =>
router.replace({ query: { [queryStringParam]: value.join(" ") } }); router.replace({
}); query: { ...route.query, [queryStringParam]: value.join(" ") },
})
);
} }
const addFilter = (filter: string) => { const addFilter = (filter: string) => {
@ -47,10 +49,16 @@ export default function useCollectionFilter(
}; };
} }
function queryToSet(filter: LocationQueryValue): Set<string> { function queryToSet(query: LocationQueryValue): Set<string> {
if (filter) { if (!query) {
return new Set(filter.split(" ")); 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 ```vue
<script lang="ts" setup> <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: "Foo", team: "Blue" },
{ name: "Bar", team: "Red" }, { name: "Bar", team: "Red" },
{ name: "Baz", team: "Blue" }, { name: "Baz", team: "Blue" },
]; ]
const bluePlayers = useFilteredCollection( const bluePlayers = useFilteredCollection(players, (player) => player.team === "Blue");
players,
(player) => player.team === "Blue"
);
</script> </script>
``` ```

View File

@ -5,9 +5,7 @@ export default function useFilteredCollection<T>(
collection: MaybeRef<T[]>, collection: MaybeRef<T[]>,
predicate: MaybeRef<(value: T) => boolean> predicate: MaybeRef<(value: T) => boolean>
) { ) {
const filteredCollection = computed(() => { return computed(() => {
return unref(collection).filter(unref(predicate)); return unref(collection).filter(unref(predicate));
}); });
return filteredCollection;
} }

View File

@ -5,28 +5,27 @@
<div v-for="item in items"> <div v-for="item in items">
{{ item.name }} <button @click="openRemoveModal(item)">Delete</button> {{ item.name }} <button @click="openRemoveModal(item)">Delete</button>
</div> </div>
<UiModal v-if="isRemoveModalOpen"> <UiModal v-if="isRemoveModalOpen">
Are you sure you want to delete {{ removeModalPayload.name }} Are you sure you want to delete {{ removeModalPayload.name }}
<button @click="handleRemove">Yes</button> <button @click="handleRemove">Yes</button> <button @click="closeRemoveModal">No</button>
<button @click="closeRemoveModal">No</button>
</UiModal> </UiModal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import useModal from "@/composables/modal.composable"; import useModal from '@/composables/modal.composable';
const { const {
payload: removeModalPayload, payload: removeModalPayload,
isOpen: isRemoveModalOpen, isOpen: isRemoveModalOpen,
open: openRemoveModal, open: openRemoveModal,
close: closeRemoveModal, close: closeRemoveModal,
} = useModal(); } = useModal()
async function handleRemove() { async function handleRemove() {
await removeItem(removeModalPayload.id); await removeItem(removeModalPayload.id);
closeRemoveModal(); closeRemoveModal()
} }
</script> </script>
``` ```

View File

@ -4,30 +4,34 @@
<template> <template>
<table> <table>
<thead> <thead>
<tr> <tr>
<th> <th>
<input type="checkbox" v-model="areAllSelected" /> <input type="checkbox" v-model="areAllSelected">
</th> </th>
<th>Name</th> <th>Name</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in items"> <tr v-for="item in items">
<td> <td>
<input type="checkbox" :value="item.id" v-model="selected" /> <input type="checkbox" :value="item.id" v-model="selected" />
</td> </td>
<td>{{ item.name }}</td> <td>{{ item.name }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- You can use something else than a "Select All" checkbox --> <!-- You can use something else than a "Select All" checkbox -->
<button @click="areAllSelected = !areAllSelected">Toggle all selected</button> <button @click="areAllSelected = !areAllSelected">Toggle all selected</button>
</template> </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> </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( export function sortRecordsByNameLabel(
record1: { name_label: string }, record1: { name_label: string },
record2: { name_label: string } record2: { name_label: string }
@ -18,3 +22,22 @@ export function sortRecordsByNameLabel(
export function escapeRegExp(string: string) { export function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 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 { createRouter, createWebHistory } from "vue-router";
import pool from "@/router/pool"; import pool from "@/router/pool";
import DemoView from "@/views/DemoView.vue";
import HomeView from "@/views/HomeView.vue"; import HomeView from "@/views/HomeView.vue";
import HostDashboardView from "@/views/host/HostDashboardView.vue"; import HostDashboardView from "@/views/host/HostDashboardView.vue";
import HostRootView from "@/views/host/HostRootView.vue"; import HostRootView from "@/views/host/HostRootView.vue";
@ -15,11 +14,6 @@ const router = createRouter({
name: "home", name: "home",
component: HomeView, component: HomeView,
}, },
{
path: "/demo",
name: "demo",
component: DemoView,
},
pool, pool,
{ {
path: "/host/:uuid", path: "/host/:uuid",

View File

@ -1,23 +1,23 @@
declare module "complex-matcher" { declare module "complex-matcher" {
type ComparisonOperator = ">" | ">=" | "<" | "<="; type ComparisonOperator = ">" | ">=" | "<" | "<=";
class Node { class ComplexMatcherNode {
createPredicate(): (value) => boolean; createPredicate(): (value) => boolean;
} }
class Null extends Node { class Null extends ComplexMatcherNode {
match(): true; match(): true;
toString(): ""; toString(): "";
} }
class And extends Node { class And extends ComplexMatcherNode {
constructor(children: Node[]); constructor(children: ComplexMatcherNode[]);
match(value: any): boolean; match(value: any): boolean;
toString(isNested?: boolean): string; toString(isNested?: boolean): string;
children: Node[]; children: ComplexMatcherNode[];
} }
class Comparison extends Node { class Comparison extends ComplexMatcherNode {
constructor(operator: ComparisonOperator, value: number); constructor(operator: ComparisonOperator, value: number);
match(value: any): boolean; match(value: any): boolean;
toString(): string; toString(): string;
@ -25,61 +25,65 @@ declare module "complex-matcher" {
_value: number; _value: number;
} }
class Or extends Node { class Or extends ComplexMatcherNode {
constructor(children: Node[]); constructor(children: ComplexMatcherNode[]);
match(value: any): boolean; match(value: any): boolean;
toString(): string; toString(): string;
children: Node[]; children: ComplexMatcherNode[];
} }
class Not extends Node { class Not extends ComplexMatcherNode {
constructor(child: Node); constructor(child: ComplexMatcherNode);
match(value: any): boolean; match(value: any): boolean;
toString(): string; toString(): string;
child: Node; child: ComplexMatcherNode;
} }
class Number extends Node { class Number extends ComplexMatcherNode {
constructor(value: number); constructor(value: number);
match(value: any): boolean; match(value: any): boolean;
toString(): string; toString(): string;
value: number; value: number;
} }
class Property extends Node { class Property extends ComplexMatcherNode {
constructor(name: string, child: Node); constructor(name: string, child: ComplexMatcherNode);
match(value: any): boolean; match(value: any): boolean;
toString(): string; toString(): string;
name: string; name: string;
child: Node; child: ComplexMatcherNode;
} }
class GlobPattern extends Node { class GlobPattern extends ComplexMatcherNode {
constructor(value: string); constructor(value: string);
match(re: RegExp, value: any): boolean; match(re: RegExp, value: any): boolean;
toString(): string; toString(): string;
value: string; value: string;
} }
class RegExpNode extends Node { class RegExpNode extends ComplexMatcherNode {
constructor(pattern: string, flags: string); constructor(pattern: string, flags: string);
match(value: any): boolean; match(value: any): boolean;
toString(): string; toString(): string;
re: RegExp; re: RegExp;
} }
class StringNode extends Node { class StringNode extends ComplexMatcherNode {
constructor(value: string); constructor(value: string);
match(lcValue: string, value: any): boolean; match(lcValue: string, value: any): boolean;
toString(): string; toString(): string;
value: string; value: string;
} }
class TruthyProperty extends Node { class TruthyProperty extends ComplexMatcherNode {
constructor(name: string); constructor(name: string);
match(value: any): boolean; match(value: any): boolean;
toString(): string; 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 type FilterType = "string" | "boolean" | "number" | "enum";
export interface FilterComparison { export type FilterComparisonType =
label: string; | "stringContains"
pattern: string; | "stringEquals"
default?: boolean; | "stringStartsWith"
before?: string | IconDefinition; | "stringEndsWith"
after?: string | IconDefinition; | "stringMatchesRegex"
escape?: boolean; | "numberLessThan"
} | "numberLessThanOrEquals"
| "numberEquals"
| "numberGreaterThanOrEquals"
| "numberGreaterThan"
| "booleanTrue"
| "booleanFalse";
export type FilterComparisons = Record<FilterType, FilterComparison[]>; export type FilterComparisons = {
[key in FilterComparisonType]?: string;
};
interface AvailableFilterCommon { interface FilterCommon {
property: string;
label?: string; label?: string;
icon?: IconDefinition; icon?: IconDefinition;
} }
export interface AvailableFilterEnum extends AvailableFilterCommon { export interface FilterEnum extends FilterCommon {
type: "enum"; type: "enum";
choices: string[]; choices: string[];
} }
interface AvailableFilterOther extends AvailableFilterCommon { interface FilterOther extends FilterCommon {
type: Exclude<FilterType, "enum">; 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> <template>
<div class="pool-dashboard-view"> <div class="pool-dashboard-view">
<PoolDashboardStatus class="item" /> <PoolDashboardStatus class="item" />
<UiCard style="min-width: 40rem"> <UiCard style="min-width: 40rem">
<UiTitle type="h4">RAM usage</UiTitle> <UiTitle type="h4">Storage usage</UiTitle>
<UsageBar title="Hosts" :data="data.slice(0, 5)"> <ProgressBar :value="65" style="margin: 1rem 0" />
<template #header> <ProgressBar :value="50" style="margin: 1rem 0" />
<span>Host</span> <ProgressBar :value="40" style="margin: 1rem 0" />
<span>Top 5</span> <ProgressBar :value="22" style="margin: 1rem 0" />
</template> </UiCard>
</UsageBar>
<UiCard>
<UiBadge>38/64 GB</UiBadge>
</UiCard> </UiCard>
</div> </div>
</template> </template>
<script lang="ts" setup> <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 PoolDashboardStatus from "@/components/pool/dashboard/PoolDashboardStatus.vue";
import UiBadge from "@/components/ui/UiBadge.vue";
import UiCard from "@/components/ui/UiCard.vue"; import UiCard from "@/components/ui/UiCard.vue";
import UiTitle from "@/components/ui/UiTitle.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> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
.pool-dashboard-view { .pool-dashboard-view {
display: flex; display: flex;
padding: 2rem;
gap: 2rem; gap: 2rem;
} }

View File

@ -13,7 +13,7 @@ import PoolHeader from "@/components/pool/PoolHeader.vue";
import PoolTabBar from "@/components/pool/PoolTabBar.vue"; import PoolTabBar from "@/components/pool/PoolTabBar.vue";
</script> </script>
<style scoped> <style lang="postcss" scoped>
.view { .view {
padding: 2rem; padding: 2rem;
} }

View File

@ -2,21 +2,23 @@
<UiCard class="pool-vms-view"> <UiCard class="pool-vms-view">
<VmsActionsBar :selected-refs="selectedVmsRefs" /> <VmsActionsBar :selected-refs="selectedVmsRefs" />
<CollectionTable <CollectionTable
:available-filters="filters"
:collection="vms"
v-model="selectedVmsRefs" v-model="selectedVmsRefs"
:available-filters="filters"
:available-sorts="filters"
:collection="vms"
id-property="$ref"
> >
<template #header> <template #header>
<th> <ColumnHeader :icon="faPowerOff" />
<FontAwesomeIcon :icon="faPowerOff" /> <ColumnHeader>Name</ColumnHeader>
</th> <ColumnHeader>Description</ColumnHeader>
<th>Name</th>
</template> </template>
<template #row="{ item: vm }"> <template #row="{ item: vm }">
<td> <td>
<PowerStateIcon :state="vm.power_state" /> <PowerStateIcon :state="vm.power_state" />
</td> </td>
<td>{{ vm.name_label }}</td> <td>{{ vm.name_label }}</td>
<td>{{ vm.name_description }}</td>
</template> </template>
</CollectionTable> </CollectionTable>
</UiCard> </UiCard>
@ -25,9 +27,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import type { AvailableFilter } from "@/types/filter"; import type { Filters } from "@/types/filter";
import { faPowerOff } from "@fortawesome/free-solid-svg-icons"; import { faPowerOff } from "@fortawesome/pro-solid-svg-icons";
import CollectionTable from "@/components/CollectionTable.vue"; import CollectionTable from "@/components/CollectionTable.vue";
import ColumnHeader from "@/components/ColumnHeader.vue";
import PowerStateIcon from "@/components/PowerStateIcon.vue"; import PowerStateIcon from "@/components/PowerStateIcon.vue";
import UiCard from "@/components/ui/UiCard.vue"; import UiCard from "@/components/ui/UiCard.vue";
import VmsActionsBar from "@/components/vm/VmsActionsBar.vue"; import VmsActionsBar from "@/components/vm/VmsActionsBar.vue";
@ -36,18 +39,18 @@ import { useVmStore } from "@/stores/vm.store";
const vmStore = useVmStore(); const vmStore = useVmStore();
const { allRecords: vms } = storeToRefs(vmStore); const { allRecords: vms } = storeToRefs(vmStore);
const filters: AvailableFilter[] = [ const filters: Filters = {
{ property: "name_label", label: "VM Name", type: "string" }, name_label: { label: "VM Name", type: "string" },
{ name_description: { label: "VM Description", type: "string" },
property: "power_state", power_state: {
label: "VM State", label: "VM State",
icon: faPowerOff, icon: faPowerOff,
type: "enum", type: "enum",
choices: ["Running", "Halted", "Paused"], choices: ["Running", "Halted", "Paused"],
}, },
]; };
const selectedVmsRefs = ref([]); const selectedVmsRefs = ref([]);
</script> </script>
<style scoped></style> <style lang="postcss" scoped></style>

View File

@ -2,7 +2,7 @@
"extends": "@vue/tsconfig/tsconfig.web.json", "extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"], "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"compilerOptions": { "compilerOptions": {
"lib": ["ES2017"], "lib": ["ES2019"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]