@xen-orchestra/web-core 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/console/VtsRemoteConsole.vue +1 -1
- package/lib/components/data-table/VtsDataTable.vue +13 -14
- package/lib/components/input-wrapper/VtsInputWrapper.vue +94 -0
- package/lib/components/{charts/LinearChart.md → linear-chart/VtsLinearChart.md} +3 -3
- package/lib/components/{charts/LinearChart.vue → linear-chart/VtsLinearChart.vue} +12 -11
- package/lib/components/menu/MenuList.vue +1 -0
- package/lib/components/quick-info-card/VtsQuickInfoCard.vue +29 -0
- package/lib/components/quick-info-column/VtsQuickInfoColumn.vue +13 -0
- package/lib/components/quick-info-row/VtsQuickInfoRow.vue +40 -0
- package/lib/components/state-hero/VtsLoadingHero.vue +1 -3
- package/lib/components/ui/card/UiCard.vue +12 -0
- package/lib/components/ui/dropdown/UiDropdown.vue +214 -0
- package/lib/components/ui/dropdown/UiDropdownList.vue +16 -0
- package/lib/components/ui/label/UiLabel.vue +3 -1
- package/lib/components/ui/progress-bar/UiProgressBar.vue +108 -0
- package/lib/components/ui/radio-button/UiRadioButton.vue +1 -1
- package/lib/components/ui/table-pagination/UiTablePagination.vue +23 -57
- package/lib/components/ui/toaster/UiToaster.vue +6 -2
- package/lib/composables/chart-theme.composable.ts +2 -2
- package/lib/composables/mapper.composable.md +74 -0
- package/lib/composables/mapper.composable.ts +18 -0
- package/lib/composables/pagination.composable.md +32 -0
- package/lib/composables/pagination.composable.ts +91 -0
- package/lib/composables/ranked.composable.md +118 -0
- package/lib/composables/ranked.composable.ts +37 -0
- package/lib/composables/relative-time.composable.md +18 -0
- package/lib/composables/relative-time.composable.ts +66 -0
- package/lib/i18n.ts +12 -0
- package/lib/locales/cs.json +49 -17
- package/lib/locales/de.json +233 -24
- package/lib/locales/en.json +69 -3
- package/lib/locales/es.json +42 -10
- package/lib/locales/fa.json +208 -6
- package/lib/locales/fr.json +68 -2
- package/lib/locales/it.json +178 -0
- package/lib/locales/ru.json +91 -0
- package/lib/locales/sv.json +25 -0
- package/lib/locales/uk.json +1 -0
- package/lib/utils/if-else.utils.ts +1 -1
- package/lib/utils/injection-keys.util.ts +3 -2
- package/lib/utils/time.util.ts +18 -0
- package/package.json +22 -22
- package/lib/components/dropdown/DropdownItem.vue +0 -163
- package/lib/components/dropdown/DropdownList.vue +0 -31
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<!-- v2 -->
|
|
2
|
+
<template>
|
|
3
|
+
<div class="ui-progress-bar" :class="className">
|
|
4
|
+
<div class="progress-bar">
|
|
5
|
+
<div class="fill" :style="{ width: `${fillWidth}%` }" />
|
|
6
|
+
</div>
|
|
7
|
+
<div v-if="shouldShowSteps" class="steps typo-body-regular-small">
|
|
8
|
+
<span>{{ $n(0, 'percent') }}</span>
|
|
9
|
+
<span v-for="step in steps" :key="step">{{ $n(step, 'percent') }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
<VtsLegendList class="legend">
|
|
12
|
+
<UiLegend :accent :value="Math.round(percentage)" unit="%">{{ legend }}</UiLegend>
|
|
13
|
+
</VtsLegendList>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script lang="ts" setup>
|
|
18
|
+
import VtsLegendList from '@core/components/legend-list/VtsLegendList.vue'
|
|
19
|
+
import UiLegend from '@core/components/ui/legend/UiLegend.vue'
|
|
20
|
+
import { toVariants } from '@core/utils/to-variants.util'
|
|
21
|
+
import { useClamp, useMax } from '@vueuse/math'
|
|
22
|
+
import { computed } from 'vue'
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
value: _value,
|
|
26
|
+
max = 100,
|
|
27
|
+
showSteps,
|
|
28
|
+
} = defineProps<{
|
|
29
|
+
legend: string
|
|
30
|
+
value: number
|
|
31
|
+
max?: number
|
|
32
|
+
showSteps?: boolean
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const value = useMax(0, () => _value)
|
|
36
|
+
|
|
37
|
+
const percentage = computed(() => (max <= 0 ? 0 : (value.value / max) * 100))
|
|
38
|
+
const maxPercentage = computed(() => Math.ceil(percentage.value / 100) * 100)
|
|
39
|
+
const fillWidth = useClamp(() => (percentage.value / maxPercentage.value) * 100 || 0, 0, 100)
|
|
40
|
+
const shouldShowSteps = computed(() => showSteps || percentage.value > 100)
|
|
41
|
+
const steps = useMax(1, () => Math.floor(maxPercentage.value / 100))
|
|
42
|
+
|
|
43
|
+
const accent = computed(() => {
|
|
44
|
+
if (percentage.value >= 90) {
|
|
45
|
+
return 'danger'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (percentage.value >= 80) {
|
|
49
|
+
return 'warning'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return 'info'
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const className = computed(() => toVariants({ accent: accent.value }))
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<style lang="postcss" scoped>
|
|
59
|
+
.ui-progress-bar {
|
|
60
|
+
display: flex;
|
|
61
|
+
flex-direction: column;
|
|
62
|
+
gap: 0.4rem;
|
|
63
|
+
|
|
64
|
+
.progress-bar {
|
|
65
|
+
width: 100%;
|
|
66
|
+
height: 1.2rem;
|
|
67
|
+
border-radius: 0.4rem;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
background-color: var(--color-neutral-background-disabled);
|
|
70
|
+
|
|
71
|
+
.fill {
|
|
72
|
+
width: 0;
|
|
73
|
+
height: 100%;
|
|
74
|
+
transition: width 0.25s ease-in-out;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.steps {
|
|
79
|
+
color: var(--color-neutral-txt-secondary);
|
|
80
|
+
display: flex;
|
|
81
|
+
justify-content: space-between;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.legend {
|
|
85
|
+
margin-inline-start: auto;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ACCENT */
|
|
89
|
+
|
|
90
|
+
&.accent--info {
|
|
91
|
+
.fill {
|
|
92
|
+
background-color: var(--color-info-item-base);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
&.accent--warning {
|
|
97
|
+
.fill {
|
|
98
|
+
background-color: var(--color-warning-item-base);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
&.accent--danger {
|
|
103
|
+
.fill {
|
|
104
|
+
background-color: var(--color-danger-item-base);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
@@ -2,27 +2,27 @@
|
|
|
2
2
|
<template>
|
|
3
3
|
<div class="ui-table-pagination">
|
|
4
4
|
<div class="buttons-container">
|
|
5
|
-
<PaginationButton :disabled="isFirstPage
|
|
6
|
-
<PaginationButton :disabled="isFirstPage
|
|
7
|
-
<PaginationButton :disabled="isLastPage
|
|
8
|
-
<PaginationButton :disabled="isLastPage
|
|
5
|
+
<PaginationButton :disabled="isFirstPage" :icon="faAngleDoubleLeft" @click="emit('first')" />
|
|
6
|
+
<PaginationButton :disabled="isFirstPage" :icon="faAngleLeft" @click="emit('previous')" />
|
|
7
|
+
<PaginationButton :disabled="isLastPage" :icon="faAngleRight" @click="emit('next')" />
|
|
8
|
+
<PaginationButton :disabled="isLastPage" :icon="faAngleDoubleRight" @click="emit('last')" />
|
|
9
9
|
</div>
|
|
10
10
|
<span class="typo-body-regular-small label">
|
|
11
|
-
{{ $t('core.select.n-object-of', { from
|
|
11
|
+
{{ $t('core.select.n-object-of', { from, to, total }) }}
|
|
12
12
|
</span>
|
|
13
|
-
<span class="typo-body-regular-small label show">{{ $t('core.show-by') }}</span>
|
|
13
|
+
<span class="typo-body-regular-small label show">{{ $t('core.pagination.show-by') }}</span>
|
|
14
14
|
<div class="dropdown-wrapper">
|
|
15
|
-
<select v-model="
|
|
16
|
-
<option v-for="option in
|
|
17
|
-
{{ option }}
|
|
15
|
+
<select v-model="showBy" class="dropdown typo-body-regular-small">
|
|
16
|
+
<option v-for="option in [50, 100, 150, 200, -1]" :key="option" :value="option" class="typo-body-bold-small">
|
|
17
|
+
{{ option === -1 ? $t('core.pagination.all') : option }}
|
|
18
18
|
</option>
|
|
19
19
|
</select>
|
|
20
|
-
<VtsIcon
|
|
20
|
+
<VtsIcon :icon="faAngleDown" accent="current" class="icon" />
|
|
21
21
|
</div>
|
|
22
22
|
</div>
|
|
23
23
|
</template>
|
|
24
24
|
|
|
25
|
-
<script
|
|
25
|
+
<script lang="ts" setup>
|
|
26
26
|
import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
|
27
27
|
import PaginationButton from '@core/components/ui/table-pagination/PaginationButton.vue'
|
|
28
28
|
import {
|
|
@@ -32,60 +32,26 @@ import {
|
|
|
32
32
|
faAngleLeft,
|
|
33
33
|
faAngleRight,
|
|
34
34
|
} from '@fortawesome/free-solid-svg-icons'
|
|
35
|
-
import { useOffsetPagination } from '@vueuse/core'
|
|
36
|
-
import { computed, ref, watch } from 'vue'
|
|
37
|
-
|
|
38
|
-
export type PaginationPayload = {
|
|
39
|
-
currentPage: number
|
|
40
|
-
pageSize: number
|
|
41
|
-
startIndex: number
|
|
42
|
-
endIndex: number
|
|
43
|
-
}
|
|
44
35
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
36
|
+
defineProps<{
|
|
37
|
+
from: number
|
|
38
|
+
to: number
|
|
39
|
+
total: number
|
|
40
|
+
isFirstPage: boolean
|
|
41
|
+
isLastPage: boolean
|
|
48
42
|
}>()
|
|
49
43
|
|
|
50
44
|
const emit = defineEmits<{
|
|
51
|
-
|
|
45
|
+
first: []
|
|
46
|
+
previous: []
|
|
47
|
+
next: []
|
|
48
|
+
last: []
|
|
52
49
|
}>()
|
|
53
50
|
|
|
54
|
-
const
|
|
55
|
-
const pageSizeOptions = [10, 50, 100, 150, 200]
|
|
56
|
-
const {
|
|
57
|
-
currentPage,
|
|
58
|
-
currentPageSize,
|
|
59
|
-
pageCount,
|
|
60
|
-
isFirstPage,
|
|
61
|
-
isLastPage,
|
|
62
|
-
prev: goToPreviousPage,
|
|
63
|
-
next: goToNextPage,
|
|
64
|
-
} = useOffsetPagination({
|
|
65
|
-
total: () => totalItems,
|
|
66
|
-
pageSize,
|
|
67
|
-
})
|
|
68
|
-
const startIndex = computed(() => (currentPage.value - 1) * currentPageSize.value + 1)
|
|
69
|
-
const endIndex = computed(() => Math.min(currentPage.value * currentPageSize.value, totalItems))
|
|
70
|
-
|
|
71
|
-
const goToFirstPage = () => {
|
|
72
|
-
currentPage.value = 1
|
|
73
|
-
}
|
|
74
|
-
const goToLastPage = () => {
|
|
75
|
-
currentPage.value = pageCount.value
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
watch([currentPage, currentPageSize], ([newPage, newPageSize]) => {
|
|
79
|
-
emit('change', {
|
|
80
|
-
currentPage: newPage,
|
|
81
|
-
pageSize: newPageSize,
|
|
82
|
-
startIndex: startIndex.value,
|
|
83
|
-
endIndex: endIndex.value,
|
|
84
|
-
})
|
|
85
|
-
})
|
|
51
|
+
const showBy = defineModel<number>('showBy', { default: 50 })
|
|
86
52
|
</script>
|
|
87
53
|
|
|
88
|
-
<style
|
|
54
|
+
<style lang="postcss" scoped>
|
|
89
55
|
.ui-table-pagination {
|
|
90
56
|
display: flex;
|
|
91
57
|
align-items: center;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<slot name="description" />
|
|
12
12
|
</div>
|
|
13
13
|
</div>
|
|
14
|
-
<UiButtonIcon class="close-icon" :icon="faXmark" accent="brand" size="medium" />
|
|
14
|
+
<UiButtonIcon class="close-icon" :icon="faXmark" accent="brand" size="medium" @click="emit('close')" />
|
|
15
15
|
</div>
|
|
16
16
|
<div v-if="slots.actions" class="actions">
|
|
17
17
|
<slot name="actions" />
|
|
@@ -24,7 +24,7 @@ import VtsIcon from '@core/components/icon/VtsIcon.vue'
|
|
|
24
24
|
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
|
|
25
25
|
import { toVariants } from '@core/utils/to-variants.util'
|
|
26
26
|
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
|
|
27
|
-
import {
|
|
27
|
+
import { faCheck, faCircle, faExclamation, faInfo, faXmark } from '@fortawesome/free-solid-svg-icons'
|
|
28
28
|
import { computed } from 'vue'
|
|
29
29
|
|
|
30
30
|
type ToasterAccent = 'info' | 'success' | 'warning' | 'danger'
|
|
@@ -33,6 +33,10 @@ const props = defineProps<{
|
|
|
33
33
|
accent: ToasterAccent
|
|
34
34
|
}>()
|
|
35
35
|
|
|
36
|
+
const emit = defineEmits<{
|
|
37
|
+
close: []
|
|
38
|
+
}>()
|
|
39
|
+
|
|
36
40
|
const slots = defineSlots<{
|
|
37
41
|
default(): any
|
|
38
42
|
description?(): any
|
|
@@ -12,8 +12,8 @@ export const useChartTheme = () => {
|
|
|
12
12
|
background: style.getPropertyValue('--color-neutral-background-primary'),
|
|
13
13
|
text: style.getPropertyValue('--color-neutral-txt-secondary'),
|
|
14
14
|
splitLine: style.getPropertyValue('--color-neutral-border'),
|
|
15
|
-
primary: style.getPropertyValue('--color-
|
|
16
|
-
secondary: style.getPropertyValue('--color-warning-
|
|
15
|
+
primary: style.getPropertyValue('--color-brand-item-base'),
|
|
16
|
+
secondary: style.getPropertyValue('--color-warning-item-base'),
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
const colors = ref(getColors())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# `useMapper` composable
|
|
2
|
+
|
|
3
|
+
This composable maps values from one type to another using a mapping record. It takes a source value, a mapping object, and a default value to use when the source value is undefined or not found in the mapping.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const mappedValue = useMapper(sourceValue, mapping, defaultValue)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| | Required | Type | Default | |
|
|
12
|
+
| -------------- | :------: | ------------------------- | ------- | --------------------------------------------------------------------------------- |
|
|
13
|
+
| `sourceValue` | ✓ | `MaybeRefOrGetter<TFrom>` | | The source value to be mapped. Can be a ref, a getter function, or a raw value |
|
|
14
|
+
| `mapping` | ✓ | `Record<TFrom, TTo>` | | An object mapping source values to destination values |
|
|
15
|
+
| `defaultValue` | ✓ | `MaybeRefOrGetter<TTo>` | | The default value to use when the source is undefined or not found in the mapping |
|
|
16
|
+
|
|
17
|
+
## Return value
|
|
18
|
+
|
|
19
|
+
| | Type | |
|
|
20
|
+
| ------------- | ------------------ | ------------------------------------------------ |
|
|
21
|
+
| `mappedValue` | `ComputedRef<TTo>` | A computed reference containing the mapped value |
|
|
22
|
+
|
|
23
|
+
## Example
|
|
24
|
+
|
|
25
|
+
```vue
|
|
26
|
+
<template>
|
|
27
|
+
<div>
|
|
28
|
+
<p>Selected car color: {{ carColor }}</p>
|
|
29
|
+
<p>Recommended wall color: {{ wallColor }}</p>
|
|
30
|
+
|
|
31
|
+
<button @click="carColor = 'red'">Red Car</button>
|
|
32
|
+
<button @click="carColor = 'blue'">Blue Car</button>
|
|
33
|
+
<button @click="carColor = 'black'">Black Car</button>
|
|
34
|
+
<button @click="carColor = 'green'">Green Car</button>
|
|
35
|
+
<button @click="carColor = 'silver'">Silver Car</button>
|
|
36
|
+
<button @click="carColor = undefined">No Car</button>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<script lang="ts" setup>
|
|
41
|
+
import { useMapper } from '@/composables/mapper'
|
|
42
|
+
import { ref } from 'vue'
|
|
43
|
+
|
|
44
|
+
// Source type
|
|
45
|
+
type CarColor = 'red' | 'blue' | 'black' | 'green' | 'silver'
|
|
46
|
+
|
|
47
|
+
// Destination type
|
|
48
|
+
type WallColor = 'beige' | 'lightGray' | 'cream' | 'white'
|
|
49
|
+
|
|
50
|
+
// Create a ref for the source value
|
|
51
|
+
const carColor = ref<CarColor | undefined>(undefined)
|
|
52
|
+
|
|
53
|
+
// Create a computed property that maps car color to wall color
|
|
54
|
+
const wallColor = useMapper<CarColor, WallColor>(
|
|
55
|
+
carColor,
|
|
56
|
+
{
|
|
57
|
+
red: 'beige',
|
|
58
|
+
blue: 'lightGray',
|
|
59
|
+
black: 'cream',
|
|
60
|
+
green: 'beige',
|
|
61
|
+
silver: 'lightGray',
|
|
62
|
+
},
|
|
63
|
+
'white'
|
|
64
|
+
)
|
|
65
|
+
</script>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
In this example:
|
|
69
|
+
|
|
70
|
+
- When `carColor.value` is `'red'` or `'green'`, `wallColor.value` will be `'beige'`
|
|
71
|
+
- When `carColor.value` is `'blue'` or `'silver'`, `wallColor.value` will be `'lightGray'`
|
|
72
|
+
- When `carColor.value` is `'black'`, `wallColor.value` will be `'cream'`
|
|
73
|
+
- When `carColor.value` is `undefined` or any value not in the mapping, `wallColor.value` will be
|
|
74
|
+
`'white'` (the default value)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
|
|
2
|
+
|
|
3
|
+
export function useMapper<TFrom extends string | number, TTo>(
|
|
4
|
+
_source: MaybeRefOrGetter<TFrom | undefined>,
|
|
5
|
+
mapping: Record<TFrom, TTo>,
|
|
6
|
+
_defaultValue: MaybeRefOrGetter<TTo>
|
|
7
|
+
): ComputedRef<TTo> {
|
|
8
|
+
return computed(() => {
|
|
9
|
+
const source = toValue(_source)
|
|
10
|
+
const defaultValue = toValue(_defaultValue)
|
|
11
|
+
|
|
12
|
+
if (source === undefined) {
|
|
13
|
+
return defaultValue
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return Object.prototype.hasOwnProperty.call(mapping, source) ? mapping[source] : defaultValue
|
|
17
|
+
})
|
|
18
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# usePagination Composable
|
|
2
|
+
|
|
3
|
+
Handles record pagination with localStorage and route persistence.
|
|
4
|
+
|
|
5
|
+
The composable uses index-based pagination instead of page numbers, enabling URL sharing across users with different "Show by" settings. The index represents the first visible record.
|
|
6
|
+
|
|
7
|
+
## Storage
|
|
8
|
+
|
|
9
|
+
- Route query: `{id}.idx` stores start index
|
|
10
|
+
- LocalStorage: `{id}.per-page` stores "Show by" value (default: 50)
|
|
11
|
+
|
|
12
|
+
## Key Points
|
|
13
|
+
|
|
14
|
+
- `showBy = -1` displays all records
|
|
15
|
+
- `pageRecords` returns current page's records subset
|
|
16
|
+
- `seek(predicate)` finds and navigates to specific record's page
|
|
17
|
+
- All indices auto-align to page boundaries
|
|
18
|
+
- `paginationBindings` contains props and events for `UiTablePagination`
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// Basic usage
|
|
24
|
+
const { pageRecords, paginationBindings } = usePagination('items', items)
|
|
25
|
+
|
|
26
|
+
// Template
|
|
27
|
+
<div v-for="item in pageRecords" :key="item.id">
|
|
28
|
+
{{ item.name }}
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<UiTablePagination v-bind="paginationBindings" />
|
|
32
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useRouteQuery } from '@core/composables/route-query.composable'
|
|
2
|
+
import { clamp, useLocalStorage } from '@vueuse/core'
|
|
3
|
+
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
|
|
4
|
+
|
|
5
|
+
export function usePagination<T>(id: string, _records: MaybeRefOrGetter<T[]>) {
|
|
6
|
+
const records = computed(() => toValue(_records))
|
|
7
|
+
|
|
8
|
+
const showBy = useLocalStorage(`${id}.per-page`, 50)
|
|
9
|
+
|
|
10
|
+
const pageSize = computed({
|
|
11
|
+
get: () => (showBy.value === -1 ? Number.MAX_SAFE_INTEGER : showBy.value),
|
|
12
|
+
set: value => (showBy.value = value),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function toStartIndex(rawIndex: number | string) {
|
|
16
|
+
const index = clamp(+rawIndex, 0, records.value.length - 1) || 0
|
|
17
|
+
|
|
18
|
+
return Math.floor(index / pageSize.value) * pageSize.value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const startIndex = useRouteQuery<number>(`${id}.idx`, {
|
|
22
|
+
defaultQuery: '0',
|
|
23
|
+
toData: value => toStartIndex(value),
|
|
24
|
+
toQuery: value => toStartIndex(value).toString(10),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const endIndex = computed(() => Math.min(startIndex.value + pageSize.value - 1, records.value.length - 1))
|
|
28
|
+
|
|
29
|
+
const isFirstPage = computed(() => startIndex.value <= 0)
|
|
30
|
+
|
|
31
|
+
const isLastPage = computed(() => endIndex.value >= records.value.length - 1)
|
|
32
|
+
|
|
33
|
+
const pageRecords = computed(() => records.value.slice(startIndex.value, endIndex.value + 1))
|
|
34
|
+
|
|
35
|
+
function seek(predicate: (record: T) => boolean) {
|
|
36
|
+
const index = records.value.findIndex(predicate)
|
|
37
|
+
|
|
38
|
+
if (index !== -1) {
|
|
39
|
+
startIndex.value = index
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function goToNextPage() {
|
|
44
|
+
if (!isLastPage.value) {
|
|
45
|
+
startIndex.value = startIndex.value + pageSize.value
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function goToPreviousPage() {
|
|
50
|
+
if (!isFirstPage.value) {
|
|
51
|
+
startIndex.value = startIndex.value - pageSize.value
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function goToFirstPage() {
|
|
56
|
+
startIndex.value = 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function goToLastPage() {
|
|
60
|
+
startIndex.value = records.value.length - 1
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const paginationBindings = computed(() => ({
|
|
64
|
+
showBy: showBy.value,
|
|
65
|
+
'onUpdate:showBy': (value: number) => (showBy.value = value),
|
|
66
|
+
from: Math.max(0, startIndex.value + 1),
|
|
67
|
+
to: endIndex.value + 1,
|
|
68
|
+
total: records.value.length,
|
|
69
|
+
isFirstPage: isFirstPage.value,
|
|
70
|
+
isLastPage: isLastPage.value,
|
|
71
|
+
onFirst: goToFirstPage,
|
|
72
|
+
onLast: goToLastPage,
|
|
73
|
+
onNext: goToNextPage,
|
|
74
|
+
onPrevious: goToPreviousPage,
|
|
75
|
+
}))
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
startIndex,
|
|
79
|
+
endIndex,
|
|
80
|
+
seek,
|
|
81
|
+
showBy,
|
|
82
|
+
isFirstPage,
|
|
83
|
+
isLastPage,
|
|
84
|
+
goToPreviousPage,
|
|
85
|
+
goToNextPage,
|
|
86
|
+
goToFirstPage,
|
|
87
|
+
goToLastPage,
|
|
88
|
+
pageRecords,
|
|
89
|
+
paginationBindings,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# `useRanked` composable
|
|
2
|
+
|
|
3
|
+
This composable helps sort items based on a predefined ranking order.
|
|
4
|
+
|
|
5
|
+
It takes an array of items and a ranking order, and returns a computed sorted array according to the ranking.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
### Signature 1: Sorting ranks directly
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const sortedRanks = useRanked(ranks, ranking)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| | Required | Type | Default | Description |
|
|
16
|
+
| --------- | :------: | --------------------------- | ------- | ---------------------------------------------------------- |
|
|
17
|
+
| `ranks` | ✓ | `MaybeRefOrGetter<TRank[]>` | | The array of ranks to sort |
|
|
18
|
+
| `ranking` | ✓ | `TRank[]` | | The array of ranks ordered from highest to lowest priority |
|
|
19
|
+
|
|
20
|
+
#### Return value
|
|
21
|
+
|
|
22
|
+
| | Type | Description |
|
|
23
|
+
| ------------- | ---------------------- | --------------------------------------------------------------- |
|
|
24
|
+
| `sortedRanks` | `ComputedRef<TRank[]>` | A computed array of ranks sorted according to the ranking order |
|
|
25
|
+
|
|
26
|
+
- Ranks are sorted based on their position in the `ranking` array (lower index = higher priority)
|
|
27
|
+
- Ranks not found in the `ranking` array are placed after all defined ranks in their original order
|
|
28
|
+
|
|
29
|
+
#### Example: Sorting priority levels
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { useRanked } from '@core/composables/ranked.composable'
|
|
33
|
+
import { ref } from 'vue'
|
|
34
|
+
|
|
35
|
+
// Priority levels in random order
|
|
36
|
+
const currentPriorities = ref(['high', 'medium', 'low', 'critical', 'low', 'medium', 'high'])
|
|
37
|
+
|
|
38
|
+
// Define the ranking order (from highest to lowest priority)
|
|
39
|
+
const priorityRanking = ['critical', 'high', 'medium', 'low']
|
|
40
|
+
|
|
41
|
+
// Sort priorities according to the ranking
|
|
42
|
+
const sortedPriorities = useRanked(currentPriorities, priorityRanking)
|
|
43
|
+
|
|
44
|
+
// sortedPriorities.value will be:
|
|
45
|
+
// ['critical', 'high', 'high', 'medium', 'medium', 'low', 'low']
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Signature 2: Sorting items by their rank
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const sortedItems = useRanked(items, getRank, ranking)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| | Required | Type | Default | Description |
|
|
55
|
+
| --------- | :------: | --------------------------- | ------- | ---------------------------------------------------------- |
|
|
56
|
+
| `items` | ✓ | `MaybeRefOrGetter<TItem[]>` | | The array of items to sort |
|
|
57
|
+
| `getRank` | ✓ | `(item: TItem) => TRank` | | A function to extract rank from an item |
|
|
58
|
+
| `ranking` | ✓ | `TRank[]` | | The array of ranks ordered from highest to lowest priority |
|
|
59
|
+
|
|
60
|
+
#### Return value
|
|
61
|
+
|
|
62
|
+
| | Type | Description |
|
|
63
|
+
| ------------- | ---------------------- | ------------------------------------------------- |
|
|
64
|
+
| `sortedItems` | `ComputedRef<TItem[]>` | A computed array of items sorted by their ranking |
|
|
65
|
+
|
|
66
|
+
- Items are sorted based on their rank in the `ranking` array (lower index = higher priority)
|
|
67
|
+
- Items with ranks not found in the `ranking` array are placed after all ranked items in their original order
|
|
68
|
+
|
|
69
|
+
#### Example: Sorting tasks by priority
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { useRanked } from '@core/composables/ranked.composable'
|
|
73
|
+
import { ref } from 'vue'
|
|
74
|
+
|
|
75
|
+
// Tasks with different priorities
|
|
76
|
+
const tasks = ref([
|
|
77
|
+
{
|
|
78
|
+
id: 1,
|
|
79
|
+
title: 'Fix login bug',
|
|
80
|
+
priority: 'high',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 2,
|
|
84
|
+
title: 'Update documentation',
|
|
85
|
+
priority: 'low',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 3,
|
|
89
|
+
title: 'Server outage',
|
|
90
|
+
priority: 'critical',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 4,
|
|
94
|
+
title: 'Refactor components',
|
|
95
|
+
priority: 'medium',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 5,
|
|
99
|
+
title: 'Design review',
|
|
100
|
+
priority: 'medium',
|
|
101
|
+
},
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
// Define the ranking order (from highest to lowest priority)
|
|
105
|
+
const priorityRanking = ['critical', 'high', 'medium', 'low']
|
|
106
|
+
|
|
107
|
+
// Sort tasks by priority according to the ranking
|
|
108
|
+
const sortedTasks = useRanked(tasks, task => task.priority, priorityRanking)
|
|
109
|
+
|
|
110
|
+
// sortedTasks.value will be:
|
|
111
|
+
// [
|
|
112
|
+
// { id: 3, title: 'Server outage', priority: 'critical' },
|
|
113
|
+
// { id: 1, title: 'Fix login bug', priority: 'high' },
|
|
114
|
+
// { id: 4, title: 'Refactor components', priority: 'medium' },
|
|
115
|
+
// { id: 5, title: 'Design review', priority: 'medium' },
|
|
116
|
+
// { id: 2, title: 'Update documentation', priority: 'low' }
|
|
117
|
+
// ]
|
|
118
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { clamp, useSorted } from '@vueuse/core'
|
|
2
|
+
import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue'
|
|
3
|
+
|
|
4
|
+
export function useRanked<TRank extends string | number>(
|
|
5
|
+
ranks: MaybeRefOrGetter<TRank[]>,
|
|
6
|
+
ranking: NoInfer<TRank>[]
|
|
7
|
+
): ComputedRef<TRank[]>
|
|
8
|
+
|
|
9
|
+
export function useRanked<TItem, TRank extends string | number>(
|
|
10
|
+
items: MaybeRefOrGetter<TItem[]>,
|
|
11
|
+
getRank: (item: TItem) => TRank,
|
|
12
|
+
ranking: NoInfer<TRank>[]
|
|
13
|
+
): ComputedRef<TItem[]>
|
|
14
|
+
|
|
15
|
+
export function useRanked<TItem, TRank extends string | number>(
|
|
16
|
+
items: MaybeRefOrGetter<TItem[]>,
|
|
17
|
+
ranksOrGetRank: TRank[] | ((item: TItem) => TRank),
|
|
18
|
+
ranksOrNone?: TRank[]
|
|
19
|
+
) {
|
|
20
|
+
const getRank = typeof ranksOrGetRank === 'function' ? ranksOrGetRank : (item: TItem) => item as unknown as TRank
|
|
21
|
+
|
|
22
|
+
const ranks = ranksOrNone === undefined ? (ranksOrGetRank as TRank[]) : ranksOrNone
|
|
23
|
+
|
|
24
|
+
const ranksMap = computed(() => Object.fromEntries(ranks.map((rank, index) => [rank, index + 1]))) as ComputedRef<
|
|
25
|
+
Record<TRank, number>
|
|
26
|
+
>
|
|
27
|
+
|
|
28
|
+
function getRankNumber(item: TItem): number {
|
|
29
|
+
return ranksMap.value[getRank(item)] ?? toValue(items).length + 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compare(item1: TItem, item2: TItem) {
|
|
33
|
+
return clamp(getRankNumber(item1) - getRankNumber(item2), -1, 1) as -1 | 0 | 1
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return useSorted(items, compare)
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# useRelativeTime composable
|
|
2
|
+
|
|
3
|
+
## Usage
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
const relativeTime = useRelativeTime(fromDate, toDate)
|
|
7
|
+
|
|
8
|
+
console.log(relativeTime.value) // 3 days 27 minutes 10 seconds ago
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
# Reactivity
|
|
12
|
+
|
|
13
|
+
Both arguments can be `Ref`
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
const now = useNow()
|
|
17
|
+
const relativeTime = useRelativeTime(fromDate, now) // Value will be updated each time `now` changes
|
|
18
|
+
```
|