@groupteknology/vuno 0.1.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/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/module.d.mts +10 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +28 -0
- package/dist/runtime/app/assets/css/main.css +1 -0
- package/dist/runtime/app/components/Confirm/Confirm.d.vue.ts +21 -0
- package/dist/runtime/app/components/Confirm/Confirm.vue +65 -0
- package/dist/runtime/app/components/Confirm/Confirm.vue.d.ts +21 -0
- package/dist/runtime/app/components/Field/FieldArray.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldArray.vue +128 -0
- package/dist/runtime/app/components/Field/FieldArray.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldCheckbox.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldCheckbox.vue +30 -0
- package/dist/runtime/app/components/Field/FieldCheckbox.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldColor.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldColor.vue +57 -0
- package/dist/runtime/app/components/Field/FieldColor.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldEmail.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldEmail.vue +37 -0
- package/dist/runtime/app/components/Field/FieldEmail.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldFile.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldFile.vue +93 -0
- package/dist/runtime/app/components/Field/FieldFile.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldNumber.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldNumber.vue +38 -0
- package/dist/runtime/app/components/Field/FieldNumber.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldPassword.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldPassword.vue +35 -0
- package/dist/runtime/app/components/Field/FieldPassword.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldRadio.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldRadio.vue +33 -0
- package/dist/runtime/app/components/Field/FieldRadio.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldSelect.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldSelect.vue +38 -0
- package/dist/runtime/app/components/Field/FieldSelect.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldSlug.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldSlug.vue +92 -0
- package/dist/runtime/app/components/Field/FieldSlug.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldText.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldText.vue +38 -0
- package/dist/runtime/app/components/Field/FieldText.vue.d.ts +5 -0
- package/dist/runtime/app/components/Field/FieldTextarea.d.vue.ts +5 -0
- package/dist/runtime/app/components/Field/FieldTextarea.vue +36 -0
- package/dist/runtime/app/components/Field/FieldTextarea.vue.d.ts +5 -0
- package/dist/runtime/app/components/Form/Form.d.vue.ts +48 -0
- package/dist/runtime/app/components/Form/Form.vue +137 -0
- package/dist/runtime/app/components/Form/Form.vue.d.ts +48 -0
- package/dist/runtime/app/components/Form/FormField.d.vue.ts +7 -0
- package/dist/runtime/app/components/Form/FormField.vue +61 -0
- package/dist/runtime/app/components/Form/FormField.vue.d.ts +7 -0
- package/dist/runtime/app/components/Form/FormModal.d.vue.ts +54 -0
- package/dist/runtime/app/components/Form/FormModal.vue +82 -0
- package/dist/runtime/app/components/Form/FormModal.vue.d.ts +54 -0
- package/dist/runtime/app/components/Form/FormSection.d.vue.ts +12 -0
- package/dist/runtime/app/components/Form/FormSection.vue +43 -0
- package/dist/runtime/app/components/Form/FormSection.vue.d.ts +12 -0
- package/dist/runtime/app/components/Page/Page.d.vue.ts +24 -0
- package/dist/runtime/app/components/Page/Page.vue +63 -0
- package/dist/runtime/app/components/Page/Page.vue.d.ts +24 -0
- package/dist/runtime/app/components/State/EmptyState.d.vue.ts +20 -0
- package/dist/runtime/app/components/State/EmptyState.vue +36 -0
- package/dist/runtime/app/components/State/EmptyState.vue.d.ts +20 -0
- package/dist/runtime/app/components/State/FailureState.d.vue.ts +10 -0
- package/dist/runtime/app/components/State/FailureState.vue +34 -0
- package/dist/runtime/app/components/State/FailureState.vue.d.ts +10 -0
- package/dist/runtime/app/components/Table/Table.d.vue.ts +70 -0
- package/dist/runtime/app/components/Table/Table.vue +432 -0
- package/dist/runtime/app/components/Table/Table.vue.d.ts +70 -0
- package/dist/runtime/app/components/Table/TableCell.d.vue.ts +8 -0
- package/dist/runtime/app/components/Table/TableCell.vue +84 -0
- package/dist/runtime/app/components/Table/TableCell.vue.d.ts +8 -0
- package/dist/runtime/app/composables/useVunoCrud.d.ts +23 -0
- package/dist/runtime/app/composables/useVunoCrud.js +117 -0
- package/dist/runtime/app/composables/useVunoField.d.ts +10 -0
- package/dist/runtime/app/composables/useVunoField.js +34 -0
- package/dist/runtime/app/composables/useVunoForm.d.ts +2 -0
- package/dist/runtime/app/composables/useVunoForm.js +9 -0
- package/dist/runtime/app/composables/useVunoTableQuery.d.ts +51 -0
- package/dist/runtime/app/composables/useVunoTableQuery.js +110 -0
- package/dist/runtime/app/composables/useVunoTheme.d.ts +12 -0
- package/dist/runtime/app/composables/useVunoTheme.js +35 -0
- package/dist/runtime/app/theme/index.d.ts +14 -0
- package/dist/runtime/app/theme/index.js +210 -0
- package/dist/runtime/app/types/app-config.d.ts +8 -0
- package/dist/runtime/app/types/app-config.js +1 -0
- package/dist/runtime/app/types/confirm.d.ts +11 -0
- package/dist/runtime/app/types/confirm.js +0 -0
- package/dist/runtime/app/types/crud.d.ts +64 -0
- package/dist/runtime/app/types/crud.js +0 -0
- package/dist/runtime/app/types/field.d.ts +121 -0
- package/dist/runtime/app/types/field.js +0 -0
- package/dist/runtime/app/types/form.d.ts +39 -0
- package/dist/runtime/app/types/form.js +1 -0
- package/dist/runtime/app/types/index.d.ts +9 -0
- package/dist/runtime/app/types/index.js +1 -0
- package/dist/runtime/app/types/page.d.ts +6 -0
- package/dist/runtime/app/types/page.js +0 -0
- package/dist/runtime/app/types/responsive.d.ts +8 -0
- package/dist/runtime/app/types/responsive.js +0 -0
- package/dist/runtime/app/types/table.d.ts +130 -0
- package/dist/runtime/app/types/table.js +0 -0
- package/dist/runtime/app/types/theme.d.ts +77 -0
- package/dist/runtime/app/types/theme.js +0 -0
- package/dist/runtime/app/utils/format.d.ts +9 -0
- package/dist/runtime/app/utils/format.js +30 -0
- package/dist/runtime/app/utils/grid.d.ts +9 -0
- package/dist/runtime/app/utils/grid.js +91 -0
- package/dist/runtime/app/utils/path.d.ts +16 -0
- package/dist/runtime/app/utils/path.js +74 -0
- package/dist/types.d.mts +5 -0
- package/package.json +83 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useVunoIcons, useVunoMessages, useVunoTheme } from "#vuno/composables/useVunoTheme";
|
|
3
|
+
const props = defineProps({
|
|
4
|
+
hint: { type: String, required: false },
|
|
5
|
+
icon: { type: String, required: false },
|
|
6
|
+
title: { type: String, required: false },
|
|
7
|
+
ui: { type: Object, required: false }
|
|
8
|
+
});
|
|
9
|
+
defineSlots();
|
|
10
|
+
const icons = useVunoIcons();
|
|
11
|
+
const messages = useVunoMessages();
|
|
12
|
+
const ui = useVunoTheme("state", () => props.ui);
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<div :class="ui.root">
|
|
17
|
+
<UIcon
|
|
18
|
+
class="text-dimmed"
|
|
19
|
+
:class="ui.icon"
|
|
20
|
+
:name="props.icon ?? icons.empty"
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
<p :class="ui.title">{{ props.title ?? messages.emptyTable }}</p>
|
|
24
|
+
|
|
25
|
+
<p
|
|
26
|
+
v-if="props.hint"
|
|
27
|
+
:class="ui.hint"
|
|
28
|
+
>
|
|
29
|
+
{{ props.hint }}
|
|
30
|
+
</p>
|
|
31
|
+
|
|
32
|
+
<div :class="ui.actions">
|
|
33
|
+
<slot name="actions" />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { VunoFieldSlots } from '#vuno/types/field';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
hint?: string;
|
|
4
|
+
icon?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
ui?: VunoFieldSlots;
|
|
7
|
+
};
|
|
8
|
+
type __VLS_Slots = {
|
|
9
|
+
/** La invitación a crear el primer registro va aquí. */
|
|
10
|
+
actions?: () => unknown;
|
|
11
|
+
};
|
|
12
|
+
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
13
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
14
|
+
declare const _default: typeof __VLS_export;
|
|
15
|
+
export default _default;
|
|
16
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
17
|
+
new (): {
|
|
18
|
+
$slots: S;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { VunoFieldSlots } from '#vuno/types/field';
|
|
2
|
+
import type { VunoTableFailure } from '#vuno/types/table';
|
|
3
|
+
type __VLS_Props = {
|
|
4
|
+
/** El fallo con su reintento dentro: no hay forma de pintarlo sin salida. */
|
|
5
|
+
failure: VunoTableFailure;
|
|
6
|
+
ui?: VunoFieldSlots;
|
|
7
|
+
};
|
|
8
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
9
|
+
declare const _default: typeof __VLS_export;
|
|
10
|
+
export default _default;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useVunoIcons, useVunoMessages, useVunoTheme } from "#vuno/composables/useVunoTheme";
|
|
3
|
+
const props = defineProps({
|
|
4
|
+
failure: { type: Object, required: true },
|
|
5
|
+
ui: { type: Object, required: false }
|
|
6
|
+
});
|
|
7
|
+
const icons = useVunoIcons();
|
|
8
|
+
const messages = useVunoMessages();
|
|
9
|
+
const ui = useVunoTheme("state", () => props.ui);
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div :class="ui.root">
|
|
14
|
+
<UIcon
|
|
15
|
+
class="text-error"
|
|
16
|
+
:class="ui.icon"
|
|
17
|
+
:name="icons.failure"
|
|
18
|
+
/>
|
|
19
|
+
|
|
20
|
+
<p :class="ui.title">{{ props.failure.title ?? messages.failureTitle }}</p>
|
|
21
|
+
|
|
22
|
+
<p :class="ui.hint">{{ props.failure.hint ?? messages.failureHint }}</p>
|
|
23
|
+
|
|
24
|
+
<div :class="ui.actions">
|
|
25
|
+
<UButton
|
|
26
|
+
color="neutral"
|
|
27
|
+
:icon="icons.retry"
|
|
28
|
+
:label="messages.retry"
|
|
29
|
+
variant="outline"
|
|
30
|
+
@click="props.failure.retry()"
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { VunoFieldSlots } from '#vuno/types/field';
|
|
2
|
+
import type { VunoTableFailure } from '#vuno/types/table';
|
|
3
|
+
type __VLS_Props = {
|
|
4
|
+
/** El fallo con su reintento dentro: no hay forma de pintarlo sin salida. */
|
|
5
|
+
failure: VunoTableFailure;
|
|
6
|
+
ui?: VunoFieldSlots;
|
|
7
|
+
};
|
|
8
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
9
|
+
declare const _default: typeof __VLS_export;
|
|
10
|
+
export default _default;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { VunoFieldSlots } from '#vuno/types/field';
|
|
2
|
+
import type { VunoTableDefinition, VunoTableFailure, VunoTableQuery, VunoTableRow } from '#vuno/types/table';
|
|
3
|
+
declare const __VLS_export: <TRow extends VunoTableRow>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>>["props"], __VLS_ctx?: __VLS_PrettifyLocal<Pick<NonNullable<Awaited<typeof __VLS_setup>>, "attrs" | "emit" | "slots">>, __VLS_exposed?: NonNullable<Awaited<typeof __VLS_setup>>["expose"], __VLS_setup?: Promise<{
|
|
4
|
+
props: import("vue").PublicProps & __VLS_PrettifyLocal<({
|
|
5
|
+
data: TRow[];
|
|
6
|
+
definition: VunoTableDefinition<TRow>;
|
|
7
|
+
/**
|
|
8
|
+
* Fallo de la consulta. Sin esto, un error se pintaría como «no hay
|
|
9
|
+
* resultados» y la tabla afirmaría que no hay nada cuando en realidad
|
|
10
|
+
* no se pudo saber.
|
|
11
|
+
*/
|
|
12
|
+
failure?: VunoTableFailure;
|
|
13
|
+
loading?: boolean;
|
|
14
|
+
/** Total de filas en servidor, no las de la página actual. */
|
|
15
|
+
total?: number;
|
|
16
|
+
ui?: VunoFieldSlots;
|
|
17
|
+
} & {
|
|
18
|
+
/**
|
|
19
|
+
* Estado de consulta. El consumidor lo observa y devuelve `data` y `total`.
|
|
20
|
+
*
|
|
21
|
+
* Es obligatorio a propósito: la tabla no trae datos por sí misma, así que
|
|
22
|
+
* un estado interno avanzaría de página sin que nadie pidiera la siguiente
|
|
23
|
+
* —el pie diría «11-20» mientras se siguen viendo las filas 1-10—. Dónde
|
|
24
|
+
* vive ese estado lo decide `useVunoTableQuery`: un `ref` normal o la
|
|
25
|
+
* query string.
|
|
26
|
+
*/
|
|
27
|
+
query: VunoTableQuery;
|
|
28
|
+
/** Claves de las filas seleccionadas, no las filas: con paginación en servidor las de otras páginas no están cargadas. */
|
|
29
|
+
selection?: (number | string)[];
|
|
30
|
+
}) & {
|
|
31
|
+
onRowClick?: ((row: TRow) => any) | undefined;
|
|
32
|
+
"onUpdate:query"?: ((value: VunoTableQuery) => any) | undefined;
|
|
33
|
+
"onUpdate:selection"?: ((value: (string | number)[]) => any) | undefined;
|
|
34
|
+
}> & (typeof globalThis extends {
|
|
35
|
+
__VLS_PROPS_FALLBACK: infer P;
|
|
36
|
+
} ? P : {});
|
|
37
|
+
expose: (exposed: import("vue").ShallowUnwrapRef<{
|
|
38
|
+
clearFilters: () => void;
|
|
39
|
+
clearSearch: () => void;
|
|
40
|
+
clearSelection: () => void;
|
|
41
|
+
goToPage: (page: number) => void;
|
|
42
|
+
}>) => void;
|
|
43
|
+
attrs: any;
|
|
44
|
+
slots: {
|
|
45
|
+
[key: `cell-${string}`]: (props: {
|
|
46
|
+
row: TRow;
|
|
47
|
+
value: unknown;
|
|
48
|
+
}) => unknown;
|
|
49
|
+
empty?: () => unknown;
|
|
50
|
+
'empty-actions'?: () => unknown;
|
|
51
|
+
selection?: (props: {
|
|
52
|
+
count: number;
|
|
53
|
+
keys: (number | string)[];
|
|
54
|
+
clear: () => void;
|
|
55
|
+
}) => unknown;
|
|
56
|
+
toolbar?: (props: {
|
|
57
|
+
query: VunoTableQuery;
|
|
58
|
+
}) => unknown;
|
|
59
|
+
};
|
|
60
|
+
emit: ((evt: "rowClick", row: TRow) => void) & (((event: "update:query", value: VunoTableQuery) => void) & ((event: "update:selection", value: (string | number)[]) => void));
|
|
61
|
+
}>) => import("vue").VNode & {
|
|
62
|
+
__ctx?: NonNullable<Awaited<typeof __VLS_setup>>;
|
|
63
|
+
};
|
|
64
|
+
declare const _default: typeof __VLS_export;
|
|
65
|
+
export default _default;
|
|
66
|
+
type __VLS_PrettifyLocal<T> = (T extends any ? {
|
|
67
|
+
[K in keyof T]: T[K];
|
|
68
|
+
} : {
|
|
69
|
+
[K in keyof T as K]: T[K];
|
|
70
|
+
}) & {};
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, onBeforeUnmount, ref, useTemplateRef, watch } from "vue";
|
|
3
|
+
import EmptyState from "#vuno/components/State/EmptyState.vue";
|
|
4
|
+
import FailureState from "#vuno/components/State/FailureState.vue";
|
|
5
|
+
import Form from "#vuno/components/Form/Form.vue";
|
|
6
|
+
import { useVunoIcons, useVunoMessages, useVunoTheme } from "#vuno/composables/useVunoTheme";
|
|
7
|
+
import { getPath } from "#vuno/utils/path";
|
|
8
|
+
import TableCell from "./TableCell.vue";
|
|
9
|
+
const props = defineProps({
|
|
10
|
+
data: { type: Array, required: true },
|
|
11
|
+
definition: { type: Object, required: true },
|
|
12
|
+
failure: { type: Object, required: false },
|
|
13
|
+
loading: { type: Boolean, required: false },
|
|
14
|
+
total: { type: Number, required: false },
|
|
15
|
+
ui: { type: Object, required: false }
|
|
16
|
+
});
|
|
17
|
+
const total = computed(() => props.total ?? 0);
|
|
18
|
+
const hasFailure = computed(() => Boolean(props.failure));
|
|
19
|
+
const emit = defineEmits(["rowClick"]);
|
|
20
|
+
const query = defineModel("query", { type: Object, ...{ required: true } });
|
|
21
|
+
const selection = defineModel("selection", { type: Array, ...{ default: () => [] } });
|
|
22
|
+
defineSlots();
|
|
23
|
+
const icons = useVunoIcons();
|
|
24
|
+
const messages = useVunoMessages();
|
|
25
|
+
const ui = useVunoTheme("table", () => props.ui);
|
|
26
|
+
const rowKey = computed(() => props.definition.rowKey ?? "id");
|
|
27
|
+
const pageSizes = computed(() => props.definition.pageSizes ?? [10, 25, 50, 100]);
|
|
28
|
+
const getRowId = (row) => String(getPath(row, rowKey.value) ?? "");
|
|
29
|
+
const search = ref(query.value.search);
|
|
30
|
+
let searchTimer;
|
|
31
|
+
watch(search, (value) => {
|
|
32
|
+
clearTimeout(searchTimer);
|
|
33
|
+
searchTimer = setTimeout(() => {
|
|
34
|
+
if (value === query.value.search) return;
|
|
35
|
+
query.value = { ...query.value, page: 1, search: value };
|
|
36
|
+
}, 300);
|
|
37
|
+
});
|
|
38
|
+
watch(
|
|
39
|
+
() => query.value.search,
|
|
40
|
+
(value) => {
|
|
41
|
+
if (value !== search.value) search.value = value;
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
onBeforeUnmount(() => clearTimeout(searchTimer));
|
|
45
|
+
const searchInput = useTemplateRef("searchInput");
|
|
46
|
+
function clearSearch() {
|
|
47
|
+
clearTimeout(searchTimer);
|
|
48
|
+
search.value = "";
|
|
49
|
+
query.value = { ...query.value, page: 1, search: "" };
|
|
50
|
+
searchInput.value?.inputRef?.focus();
|
|
51
|
+
}
|
|
52
|
+
function sortDirectionFor(key) {
|
|
53
|
+
return query.value.sort?.column === key ? query.value.sort.direction : null;
|
|
54
|
+
}
|
|
55
|
+
function toggleSort(key) {
|
|
56
|
+
const current = sortDirectionFor(key);
|
|
57
|
+
const sort = current === null ? { column: key, direction: "asc" } : current === "asc" ? { column: key, direction: "desc" } : null;
|
|
58
|
+
query.value = { ...query.value, page: 1, sort };
|
|
59
|
+
}
|
|
60
|
+
function sortIcon(key) {
|
|
61
|
+
const direction = sortDirectionFor(key);
|
|
62
|
+
if (direction === "asc") return icons.value.sortAsc;
|
|
63
|
+
if (direction === "desc") return icons.value.sortDesc;
|
|
64
|
+
return icons.value.sort;
|
|
65
|
+
}
|
|
66
|
+
const filtersOpen = ref(false);
|
|
67
|
+
const filterState = ref({ ...query.value.filters });
|
|
68
|
+
watch(
|
|
69
|
+
() => query.value.filters,
|
|
70
|
+
(value) => {
|
|
71
|
+
filterState.value = { ...value };
|
|
72
|
+
},
|
|
73
|
+
{ deep: true }
|
|
74
|
+
);
|
|
75
|
+
const hasValue = (value) => value !== void 0 && value !== null && value !== "";
|
|
76
|
+
const filtersDefinition = computed(() => ({
|
|
77
|
+
sections: [{ id: "filters", fields: props.definition.filters ?? [] }]
|
|
78
|
+
}));
|
|
79
|
+
const activeFilters = computed(
|
|
80
|
+
() => (props.definition.filters ?? []).filter((field) => hasValue(query.value.filters[field.name])).map((field) => {
|
|
81
|
+
const value = query.value.filters[field.name];
|
|
82
|
+
const option = "items" in field ? field.items?.find((item) => String(item.value) === String(value)) : void 0;
|
|
83
|
+
return {
|
|
84
|
+
name: field.name,
|
|
85
|
+
label: field.label ?? field.name,
|
|
86
|
+
value: option?.label ?? String(value)
|
|
87
|
+
};
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
function applyFilters() {
|
|
91
|
+
query.value = { ...query.value, filters: { ...filterState.value }, page: 1 };
|
|
92
|
+
filtersOpen.value = false;
|
|
93
|
+
}
|
|
94
|
+
function clearFilters() {
|
|
95
|
+
filterState.value = {};
|
|
96
|
+
query.value = { ...query.value, filters: {}, page: 1 };
|
|
97
|
+
filtersOpen.value = false;
|
|
98
|
+
}
|
|
99
|
+
function removeFilter(name) {
|
|
100
|
+
const filters = { ...query.value.filters };
|
|
101
|
+
delete filters[name];
|
|
102
|
+
query.value = { ...query.value, filters, page: 1 };
|
|
103
|
+
}
|
|
104
|
+
const rowSelection = computed({
|
|
105
|
+
get: () => Object.fromEntries(selection.value.map((key) => [String(key), true])),
|
|
106
|
+
set: (value) => {
|
|
107
|
+
selection.value = Object.entries(value).filter(([, selected]) => selected).map(([key]) => key);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
const clearSelection = () => {
|
|
111
|
+
selection.value = [];
|
|
112
|
+
};
|
|
113
|
+
const columns = computed(() => {
|
|
114
|
+
const list = [];
|
|
115
|
+
if (props.definition.selectable) {
|
|
116
|
+
list.push({ id: "select", enableSorting: false });
|
|
117
|
+
}
|
|
118
|
+
for (const column of props.definition.columns) {
|
|
119
|
+
list.push({
|
|
120
|
+
id: column.key,
|
|
121
|
+
accessorKey: column.key,
|
|
122
|
+
enableSorting: false,
|
|
123
|
+
// La columna de acciones no lleva rótulo salvo que se pida uno.
|
|
124
|
+
header: column.type === "actions" ? column.label ?? "" : column.label ?? column.key,
|
|
125
|
+
meta: {
|
|
126
|
+
class: {
|
|
127
|
+
td: [column.align === "end" ? "text-end" : column.align === "center" ? "text-center" : "", column.class].filter(Boolean).join(" "),
|
|
128
|
+
th: [column.align === "end" ? "text-end" : column.align === "center" ? "text-center" : "", column.width].filter(Boolean).join(" ")
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return list;
|
|
134
|
+
});
|
|
135
|
+
const rangeStart = computed(() => total.value === 0 ? 0 : (query.value.page - 1) * query.value.pageSize + 1);
|
|
136
|
+
const rangeEnd = computed(() => Math.min(query.value.page * query.value.pageSize, total.value));
|
|
137
|
+
function onPageChange(page) {
|
|
138
|
+
query.value = { ...query.value, page };
|
|
139
|
+
}
|
|
140
|
+
function onPageSizeChange(size) {
|
|
141
|
+
query.value = { ...query.value, page: 1, pageSize: size };
|
|
142
|
+
}
|
|
143
|
+
defineExpose({
|
|
144
|
+
clearFilters,
|
|
145
|
+
clearSearch,
|
|
146
|
+
clearSelection,
|
|
147
|
+
goToPage: onPageChange
|
|
148
|
+
});
|
|
149
|
+
function onRowSelect(_event, row) {
|
|
150
|
+
emit("rowClick", row.original);
|
|
151
|
+
}
|
|
152
|
+
</script>
|
|
153
|
+
|
|
154
|
+
<template>
|
|
155
|
+
<div :class="ui.root">
|
|
156
|
+
<div :class="ui.toolbar">
|
|
157
|
+
<UInput
|
|
158
|
+
ref="searchInput"
|
|
159
|
+
v-model="search"
|
|
160
|
+
:icon="icons.search"
|
|
161
|
+
:placeholder="props.definition.searchPlaceholder ?? messages.searchPlaceholder"
|
|
162
|
+
:ui="{ root: ui.search }"
|
|
163
|
+
>
|
|
164
|
+
<template
|
|
165
|
+
v-if="search.length > 0"
|
|
166
|
+
#trailing
|
|
167
|
+
>
|
|
168
|
+
<UButton
|
|
169
|
+
:aria-label="messages.clearSearch"
|
|
170
|
+
color="neutral"
|
|
171
|
+
:icon="icons.clearSearch"
|
|
172
|
+
size="xs"
|
|
173
|
+
variant="link"
|
|
174
|
+
@click="clearSearch"
|
|
175
|
+
/>
|
|
176
|
+
</template>
|
|
177
|
+
</UInput>
|
|
178
|
+
|
|
179
|
+
<div :class="ui.toolbarActions">
|
|
180
|
+
<UButton
|
|
181
|
+
v-if="(props.definition.filters?.length ?? 0) > 0"
|
|
182
|
+
color="neutral"
|
|
183
|
+
:icon="icons.filter"
|
|
184
|
+
:label="messages.filters"
|
|
185
|
+
variant="outline"
|
|
186
|
+
@click="filtersOpen = true"
|
|
187
|
+
>
|
|
188
|
+
<!-- `UButton` no admite `badge`: el contador va en el slot. -->
|
|
189
|
+
<template
|
|
190
|
+
v-if="activeFilters.length > 0"
|
|
191
|
+
#trailing
|
|
192
|
+
>
|
|
193
|
+
<UBadge
|
|
194
|
+
color="primary"
|
|
195
|
+
:label="String(activeFilters.length)"
|
|
196
|
+
size="sm"
|
|
197
|
+
variant="solid"
|
|
198
|
+
/>
|
|
199
|
+
</template>
|
|
200
|
+
</UButton>
|
|
201
|
+
|
|
202
|
+
<slot
|
|
203
|
+
name="toolbar"
|
|
204
|
+
:query="query"
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div
|
|
210
|
+
v-if="props.definition.selectable && selection.length > 0"
|
|
211
|
+
:class="ui.selection"
|
|
212
|
+
>
|
|
213
|
+
<slot
|
|
214
|
+
:clear="clearSelection"
|
|
215
|
+
:count="selection.length"
|
|
216
|
+
:keys="selection"
|
|
217
|
+
name="selection"
|
|
218
|
+
>
|
|
219
|
+
<span>{{ selection.length }} {{ selection.length === 1 ? messages.rowSelected : messages.rowsSelected }}</span>
|
|
220
|
+
<UButton
|
|
221
|
+
color="neutral"
|
|
222
|
+
:label="messages.clearSelection"
|
|
223
|
+
size="xs"
|
|
224
|
+
variant="ghost"
|
|
225
|
+
@click="clearSelection"
|
|
226
|
+
/>
|
|
227
|
+
</slot>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div
|
|
231
|
+
v-if="activeFilters.length > 0"
|
|
232
|
+
:class="ui.chips"
|
|
233
|
+
>
|
|
234
|
+
<UBadge
|
|
235
|
+
v-for="filter in activeFilters"
|
|
236
|
+
:key="filter.name"
|
|
237
|
+
color="neutral"
|
|
238
|
+
variant="subtle"
|
|
239
|
+
>
|
|
240
|
+
<span>{{ filter.label }}: {{ filter.value }}</span>
|
|
241
|
+
<UButton
|
|
242
|
+
:aria-label="`${messages.clearFilters}: ${filter.label}`"
|
|
243
|
+
color="neutral"
|
|
244
|
+
:icon="icons.fileRemove"
|
|
245
|
+
size="xs"
|
|
246
|
+
variant="link"
|
|
247
|
+
@click="removeFilter(filter.name)"
|
|
248
|
+
/>
|
|
249
|
+
</UBadge>
|
|
250
|
+
|
|
251
|
+
<UButton
|
|
252
|
+
color="neutral"
|
|
253
|
+
:label="messages.clearFilters"
|
|
254
|
+
size="xs"
|
|
255
|
+
variant="ghost"
|
|
256
|
+
@click="clearFilters"
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<USlideover
|
|
261
|
+
v-model:open="filtersOpen"
|
|
262
|
+
:title="messages.filtersTitle"
|
|
263
|
+
>
|
|
264
|
+
<template #body>
|
|
265
|
+
<Form
|
|
266
|
+
v-model:state="filterState"
|
|
267
|
+
:definition="filtersDefinition"
|
|
268
|
+
:ui="{ sections: ui.filtersBody }"
|
|
269
|
+
/>
|
|
270
|
+
</template>
|
|
271
|
+
|
|
272
|
+
<template #footer>
|
|
273
|
+
<div :class="ui.filtersFooter">
|
|
274
|
+
<UButton
|
|
275
|
+
color="neutral"
|
|
276
|
+
:label="messages.clearFilters"
|
|
277
|
+
variant="ghost"
|
|
278
|
+
@click="clearFilters"
|
|
279
|
+
/>
|
|
280
|
+
<UButton
|
|
281
|
+
:label="messages.applyFilters"
|
|
282
|
+
@click="applyFilters"
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
</template>
|
|
286
|
+
</USlideover>
|
|
287
|
+
|
|
288
|
+
<UTable
|
|
289
|
+
v-model:rowSelection="rowSelection"
|
|
290
|
+
:columns="columns"
|
|
291
|
+
:data="hasFailure ? [] : props.data"
|
|
292
|
+
:getRowId="getRowId"
|
|
293
|
+
:loading="props.loading ?? false"
|
|
294
|
+
:paginationOptions="{ manualPagination: true }"
|
|
295
|
+
:sortingOptions="{ manualSorting: true }"
|
|
296
|
+
:ui="{ base: ui.table, tbody: ui.tbody, td: ui.td, th: ui.th }"
|
|
297
|
+
@select="onRowSelect"
|
|
298
|
+
>
|
|
299
|
+
<template #select-header>
|
|
300
|
+
<UCheckbox
|
|
301
|
+
:modelValue="props.data.length > 0 && props.data.every((row2) => selection.includes(getRowId(row2)))"
|
|
302
|
+
@update:model-value="
|
|
303
|
+
(checked) => {
|
|
304
|
+
const keys = props.data.map((row2) => getRowId(row2));
|
|
305
|
+
selection = checked ? [.../* @__PURE__ */ new Set([...selection, ...keys])] : selection.filter((key) => !keys.includes(String(key)));
|
|
306
|
+
}
|
|
307
|
+
"
|
|
308
|
+
/>
|
|
309
|
+
</template>
|
|
310
|
+
|
|
311
|
+
<template #select-cell="{ row }">
|
|
312
|
+
<UCheckbox
|
|
313
|
+
:modelValue="row.getIsSelected()"
|
|
314
|
+
@update:model-value="row.toggleSelected(!row.getIsSelected())"
|
|
315
|
+
/>
|
|
316
|
+
</template>
|
|
317
|
+
|
|
318
|
+
<template
|
|
319
|
+
v-for="column in props.definition.columns"
|
|
320
|
+
:key="`${column.key}-header`"
|
|
321
|
+
#[`${column.key}-header`]
|
|
322
|
+
>
|
|
323
|
+
<UButton
|
|
324
|
+
v-if="column.sortable"
|
|
325
|
+
class="-mx-2"
|
|
326
|
+
color="neutral"
|
|
327
|
+
:icon="sortIcon(column.key)"
|
|
328
|
+
:label="column.label ?? column.key"
|
|
329
|
+
size="xs"
|
|
330
|
+
trailing
|
|
331
|
+
variant="ghost"
|
|
332
|
+
@click="toggleSort(column.key)"
|
|
333
|
+
/>
|
|
334
|
+
<span v-else>{{ column.type === "actions" ? column.label ?? "" : column.label ?? column.key }}</span>
|
|
335
|
+
</template>
|
|
336
|
+
|
|
337
|
+
<template
|
|
338
|
+
v-for="column in props.definition.columns"
|
|
339
|
+
:key="`${column.key}-cell`"
|
|
340
|
+
#[`${column.key}-cell`]="{ row }"
|
|
341
|
+
>
|
|
342
|
+
<!-- `stop`: sin esto, abrir el menú dispara también el clic de
|
|
343
|
+
fila y se acaban abriendo dos diálogos a la vez. -->
|
|
344
|
+
<UDropdownMenu
|
|
345
|
+
v-if="column.type === 'actions'"
|
|
346
|
+
:items="[
|
|
347
|
+
column.actions.filter((action) => !action.hidden?.(row.original)).map((action) => ({
|
|
348
|
+
color: action.color,
|
|
349
|
+
disabled: action.disabled?.(row.original) ?? false,
|
|
350
|
+
icon: action.icon,
|
|
351
|
+
label: action.label,
|
|
352
|
+
onSelect: () => action.onSelect(row.original)
|
|
353
|
+
}))
|
|
354
|
+
]"
|
|
355
|
+
@click.stop
|
|
356
|
+
>
|
|
357
|
+
<UButton
|
|
358
|
+
:aria-label="messages.rowActions"
|
|
359
|
+
color="neutral"
|
|
360
|
+
:icon="icons.actions"
|
|
361
|
+
size="xs"
|
|
362
|
+
variant="ghost"
|
|
363
|
+
/>
|
|
364
|
+
</UDropdownMenu>
|
|
365
|
+
|
|
366
|
+
<slot
|
|
367
|
+
v-else
|
|
368
|
+
:name="`cell-${column.key}`"
|
|
369
|
+
:row="row.original"
|
|
370
|
+
:value="getPath(row.original, column.key)"
|
|
371
|
+
>
|
|
372
|
+
<TableCell
|
|
373
|
+
:column="column"
|
|
374
|
+
:row="row.original"
|
|
375
|
+
/>
|
|
376
|
+
</slot>
|
|
377
|
+
</template>
|
|
378
|
+
|
|
379
|
+
<template #empty>
|
|
380
|
+
<!-- Un fallo y una lista vacía no son lo mismo: el primero
|
|
381
|
+
ofrece reintentar, el segundo invita a crear el primero. -->
|
|
382
|
+
<FailureState
|
|
383
|
+
v-if="props.failure"
|
|
384
|
+
:failure="props.failure"
|
|
385
|
+
/>
|
|
386
|
+
|
|
387
|
+
<!-- Cargando y sin filas todavía no es «no hay nada»: decirlo
|
|
388
|
+
antes de tiempo hace parpadear la tabla en cada cambio. -->
|
|
389
|
+
<div v-else-if="props.loading" />
|
|
390
|
+
|
|
391
|
+
<slot
|
|
392
|
+
v-else
|
|
393
|
+
name="empty"
|
|
394
|
+
>
|
|
395
|
+
<EmptyState
|
|
396
|
+
:hint="props.definition.empty?.hint"
|
|
397
|
+
:icon="props.definition.empty?.icon"
|
|
398
|
+
:title="props.definition.empty?.title"
|
|
399
|
+
>
|
|
400
|
+
<template #actions>
|
|
401
|
+
<slot name="empty-actions" />
|
|
402
|
+
</template>
|
|
403
|
+
</EmptyState>
|
|
404
|
+
</slot>
|
|
405
|
+
</template>
|
|
406
|
+
</UTable>
|
|
407
|
+
|
|
408
|
+
<div
|
|
409
|
+
v-if="total > 0 && !hasFailure"
|
|
410
|
+
:class="ui.footer"
|
|
411
|
+
>
|
|
412
|
+
<div :class="ui.meta">
|
|
413
|
+
<span>{{ messages.showing }} {{ rangeStart }}–{{ rangeEnd }} {{ messages.of }} {{ total }}</span>
|
|
414
|
+
|
|
415
|
+
<USelect
|
|
416
|
+
:items="pageSizes"
|
|
417
|
+
:modelValue="query.pageSize"
|
|
418
|
+
size="xs"
|
|
419
|
+
:ui="{ base: 'w-20' }"
|
|
420
|
+
@update:model-value="(size) => onPageSizeChange(Number(size))"
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
|
|
424
|
+
<UPagination
|
|
425
|
+
:itemsPerPage="query.pageSize"
|
|
426
|
+
:page="query.page"
|
|
427
|
+
:total="total"
|
|
428
|
+
@update:page="onPageChange"
|
|
429
|
+
/>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
</template>
|