@open-commune/web 0.2.0 → 0.2.2
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/components/data/display/row/data-display-badge-publish-status.vue +2 -10
- package/components/forms/inputs/input-filter-status.vue +70 -0
- package/components/forms/inputs/input-filter.vue +55 -0
- package/components/forms/inputs/input-search.vue +58 -0
- package/models/record.js +23 -0
- package/package.json +1 -1
- package/pages/[collection]/index.vue +75 -6
|
@@ -4,15 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<script setup>
|
|
6
6
|
import badge from '~/components/elements/badges/badge'
|
|
7
|
-
|
|
8
|
-
// Prepare constants
|
|
9
|
-
const PUBLICATION_STATUSES = [
|
|
10
|
-
{ label: 'Brouillon', color: '#e5e7eb', option: 'draft' },
|
|
11
|
-
{ label: 'A valider', color: '#facc15', option: 'waiting' },
|
|
12
|
-
{ label: 'Prêt', color: '#60a5fa', option: 'ready' },
|
|
13
|
-
{ label: 'Programmé', option: 'scheduled' },
|
|
14
|
-
{ label: 'Publié', color: '#34d399', option: 'published' }
|
|
15
|
-
]
|
|
7
|
+
import { STATUSES } from '~/constants/statuses'
|
|
16
8
|
|
|
17
9
|
// Prepare props
|
|
18
10
|
const $props = defineProps({
|
|
@@ -23,7 +15,7 @@ const $props = defineProps({
|
|
|
23
15
|
})
|
|
24
16
|
|
|
25
17
|
// Prepare computed
|
|
26
|
-
const correspondingStatus = computed(() =>
|
|
18
|
+
const correspondingStatus = computed(() => STATUSES.find(({ value }) => value === $props.status))
|
|
27
19
|
const badgeTitle = computed(() => correspondingStatus.value?.label)
|
|
28
20
|
const badgeColor = computed(() => correspondingStatus.value?.color)
|
|
29
21
|
</script>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<input-filter label="Statut" :value="`${modelValue.length}/${STATUSES.length}`">
|
|
3
|
+
<template #leading>
|
|
4
|
+
<span v-if="selected.length" class="mr-1.5 flex items-center -space-x-1">
|
|
5
|
+
<span
|
|
6
|
+
v-for="status in selected"
|
|
7
|
+
:key="status.value"
|
|
8
|
+
class="size-2.5 rounded-full ring-2 ring-[var(--ui-bg-elevated)]"
|
|
9
|
+
:style="`background-color: ${status.color}`"
|
|
10
|
+
/>
|
|
11
|
+
</span>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<div class="w-56 p-1.5">
|
|
15
|
+
<button
|
|
16
|
+
v-for="status in STATUSES"
|
|
17
|
+
:key="status.value"
|
|
18
|
+
type="button"
|
|
19
|
+
class="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
|
20
|
+
@click="_toggle(status.value)"
|
|
21
|
+
>
|
|
22
|
+
<span
|
|
23
|
+
class="flex size-5 shrink-0 items-center justify-center rounded-[6px] border"
|
|
24
|
+
:class="modelValue.includes(status.value)
|
|
25
|
+
? 'border-inverted bg-inverted text-inverted'
|
|
26
|
+
: 'border-default bg-elevated'"
|
|
27
|
+
>
|
|
28
|
+
<UIcon v-if="modelValue.includes(status.value)" name="i-lucide-check" class="size-3.5" />
|
|
29
|
+
</span>
|
|
30
|
+
<span class="size-2.5 shrink-0 rounded-full" :style="`background-color: ${status.color}`" />
|
|
31
|
+
<span class="text-default">{{ status.label }}</span>
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
</input-filter>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<script setup>
|
|
38
|
+
/**
|
|
39
|
+
* The publication statuses a listing keeps. It owns nothing but the choice —
|
|
40
|
+
* the screen holds them and decides what to do with them.
|
|
41
|
+
*/
|
|
42
|
+
import inputFilter from '~/components/forms/inputs/input-filter'
|
|
43
|
+
import { ALL_STATUSES, STATUSES } from '~/constants/statuses'
|
|
44
|
+
|
|
45
|
+
// Prepare props
|
|
46
|
+
const $props = defineProps({
|
|
47
|
+
modelValue: { type: Array, default: () => ALL_STATUSES },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Prepare emitters
|
|
51
|
+
const $emitters = defineEmits(['update:modelValue'])
|
|
52
|
+
|
|
53
|
+
// Prepare computed
|
|
54
|
+
const selected = computed(
|
|
55
|
+
() => STATUSES.filter(({ value }) => $props.modelValue.includes(value))
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Prepare methods
|
|
59
|
+
// Rebuilt in declared order, so the dots read the same whatever order the
|
|
60
|
+
// boxes were ticked in.
|
|
61
|
+
const _toggle = (value) => {
|
|
62
|
+
const kept = $props.modelValue.includes(value)
|
|
63
|
+
? $props.modelValue.filter(status => status !== value)
|
|
64
|
+
: STATUSES
|
|
65
|
+
.filter(({ value: candidate }) => $props.modelValue.includes(candidate) || candidate === value)
|
|
66
|
+
.map(({ value: candidate }) => candidate)
|
|
67
|
+
|
|
68
|
+
$emitters('update:modelValue', kept)
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Locked: an applied filter nobody can lift from here. The default
|
|
3
|
+
`disabled` opacity is neutralised — the value is the only information the
|
|
4
|
+
chip carries, and it faded to pale grey. -->
|
|
5
|
+
<UButton
|
|
6
|
+
v-if="disabled"
|
|
7
|
+
color="neutral"
|
|
8
|
+
variant="ghost"
|
|
9
|
+
disabled
|
|
10
|
+
class="h-9 rounded-md border border-default bg-accented px-3 disabled:cursor-default disabled:opacity-100"
|
|
11
|
+
>
|
|
12
|
+
{{ label }}
|
|
13
|
+
<span v-if="value" class="rounded-md bg-elevated px-2 py-0.5 text-[13px] text-highlighted">
|
|
14
|
+
{{ value }}
|
|
15
|
+
</span>
|
|
16
|
+
</UButton>
|
|
17
|
+
|
|
18
|
+
<UPopover v-else v-model:open="open" :ui="{ content: 'bg-elevated !rounded-lg' }">
|
|
19
|
+
<UButton
|
|
20
|
+
color="neutral"
|
|
21
|
+
variant="ghost"
|
|
22
|
+
class="h-9 rounded-md border border-default bg-transparent px-3 hover:bg-accented data-[state=open]:bg-accented"
|
|
23
|
+
>
|
|
24
|
+
<slot name="leading" />
|
|
25
|
+
{{ label }}
|
|
26
|
+
<span v-if="value" class="ml-1.5 text-[13px] text-dimmed">{{ value }}</span>
|
|
27
|
+
<template #trailing>
|
|
28
|
+
<UIcon
|
|
29
|
+
:name="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
30
|
+
class="size-4 text-dimmed"
|
|
31
|
+
/>
|
|
32
|
+
</template>
|
|
33
|
+
</UButton>
|
|
34
|
+
<template #content>
|
|
35
|
+
<slot />
|
|
36
|
+
</template>
|
|
37
|
+
</UPopover>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<script setup>
|
|
41
|
+
/**
|
|
42
|
+
* The shell a listing filter wears: a button, a value, a popover. It holds no
|
|
43
|
+
* filtering logic — what is filtered belongs to whoever fills it — so that a
|
|
44
|
+
* locked filter and an open one stay the same object in two states.
|
|
45
|
+
*/
|
|
46
|
+
defineProps({
|
|
47
|
+
label: { type: String, required: true },
|
|
48
|
+
/** What the filter currently reads as: a count, a name. */
|
|
49
|
+
value: { type: String, default: '' },
|
|
50
|
+
/** Applied and unliftable: no popover, the value shown as a pill. */
|
|
51
|
+
disabled: { type: Boolean, default: false },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const open = ref(false)
|
|
55
|
+
</script>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- The open state is a variable, not `hover:` / `focus-within:`: an
|
|
3
|
+
`<input>` has an intrinsic width that utility classes do not override,
|
|
4
|
+
and the box stayed at 48px instead of 36. -->
|
|
5
|
+
<div
|
|
6
|
+
class="transition-[width] duration-200"
|
|
7
|
+
:class="expanded ? 'w-64' : 'w-9'"
|
|
8
|
+
@mouseenter="hovered = true"
|
|
9
|
+
@mouseleave="hovered = false"
|
|
10
|
+
>
|
|
11
|
+
<UInput
|
|
12
|
+
v-model="term"
|
|
13
|
+
:placeholder="placeholder"
|
|
14
|
+
icon="i-lucide-search"
|
|
15
|
+
class="w-full"
|
|
16
|
+
:ui="{
|
|
17
|
+
base: 'h-9 w-full rounded-md border border-default bg-transparent hover:bg-elevated focus-within:bg-elevated focus:outline-none focus-visible:outline-none ring-0 focus:ring-0 focus-visible:ring-0',
|
|
18
|
+
// Collapsed, the icon fills the box to centre itself, so it must be
|
|
19
|
+
// transparent to clicks or it intercepts the one that opens the field.
|
|
20
|
+
leading: expanded ? 'ps-2.5' : 'inset-0 justify-center ps-0 pointer-events-none',
|
|
21
|
+
leadingIcon: 'size-4 text-dimmed',
|
|
22
|
+
}"
|
|
23
|
+
:class="expanded ? '' : '[&_input]:px-0 [&_input]:placeholder:text-transparent'"
|
|
24
|
+
@focus="focused = true"
|
|
25
|
+
@blur="focused = false"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup>
|
|
31
|
+
import { refDebounced } from '@vueuse/core'
|
|
32
|
+
|
|
33
|
+
// Prepare props
|
|
34
|
+
const $props = defineProps({
|
|
35
|
+
modelValue: { type: String, default: '' },
|
|
36
|
+
placeholder: { type: String, default: 'Rechercher' },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Prepare emitters
|
|
40
|
+
const $emitters = defineEmits(['update:modelValue'])
|
|
41
|
+
|
|
42
|
+
// Prepare reactive
|
|
43
|
+
const term = ref($props.modelValue)
|
|
44
|
+
const hovered = ref(false)
|
|
45
|
+
const focused = ref(false)
|
|
46
|
+
|
|
47
|
+
// A keystroke must not fire a request.
|
|
48
|
+
const debounced = refDebounced(term, 350)
|
|
49
|
+
|
|
50
|
+
// Prepare computed
|
|
51
|
+
const expanded = computed(() => hovered.value || focused.value || Boolean(term.value))
|
|
52
|
+
|
|
53
|
+
// Prepare watchers
|
|
54
|
+
watch(debounced, value => $emitters('update:modelValue', value.trim()))
|
|
55
|
+
watch(() => $props.modelValue, (value) => {
|
|
56
|
+
if (value !== term.value.trim()) term.value = value
|
|
57
|
+
})
|
|
58
|
+
</script>
|
package/models/record.js
CHANGED
|
@@ -63,6 +63,16 @@ const _recordModel = class Record extends Model {
|
|
|
63
63
|
// à 500 (select-record) : on pagine pour honorer la limite demandée.
|
|
64
64
|
const PAGE_MAX = 100;
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Narrowing sent to the server, shared by the listing and its count so the two
|
|
68
|
+
* can never disagree. Empty criteria are dropped rather than sent: `status: []`
|
|
69
|
+
* would read as "no status at all".
|
|
70
|
+
*/
|
|
71
|
+
const narrowingOf = (queryParams = {}) => ({
|
|
72
|
+
...(queryParams.search?.trim() ? { search: queryParams.search.trim() } : {}),
|
|
73
|
+
...(queryParams.status?.length ? { status: queryParams.status } : {}),
|
|
74
|
+
});
|
|
75
|
+
|
|
66
76
|
const _recordAPI = class RecordAPI extends ModelAPI {
|
|
67
77
|
/**
|
|
68
78
|
* record aplati → payload entries.* : seuls les champs DÉFINIS par la
|
|
@@ -112,11 +122,14 @@ const _recordAPI = class RecordAPI extends ModelAPI {
|
|
|
112
122
|
|
|
113
123
|
const skip = Number(queryParams.skip ?? 0);
|
|
114
124
|
const wanted = Number(queryParams.limit ?? 20);
|
|
125
|
+
const narrowing = narrowingOf(queryParams);
|
|
126
|
+
|
|
115
127
|
const entries = [];
|
|
116
128
|
while (entries.length < wanted) {
|
|
117
129
|
const size = Math.min(wanted - entries.length, PAGE_MAX);
|
|
118
130
|
const page = await this.trpc.collections.entries.list.query({
|
|
119
131
|
collectionId: definition.id,
|
|
132
|
+
...narrowing,
|
|
120
133
|
skip: skip + entries.length,
|
|
121
134
|
limit: size,
|
|
122
135
|
});
|
|
@@ -160,6 +173,16 @@ const _recordAPI = class RecordAPI extends ModelAPI {
|
|
|
160
173
|
await this.trpc.collections.entries.remove.mutate({ id: _id });
|
|
161
174
|
return this.repo.destroy(_id);
|
|
162
175
|
}
|
|
176
|
+
|
|
177
|
+
/** The total a loaded listing is a window onto, under the same narrowing. */
|
|
178
|
+
async count(collection, queryParams = {}) {
|
|
179
|
+
const definition = await getDefinition(this.trpc, collection).catch(() => null);
|
|
180
|
+
if (!definition) return 0;
|
|
181
|
+
return this.trpc.collections.entries.count.query({
|
|
182
|
+
collectionId: definition.id,
|
|
183
|
+
...narrowingOf(queryParams),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
163
186
|
};
|
|
164
187
|
|
|
165
188
|
export default { model: _recordModel, api: _recordAPI };
|
package/package.json
CHANGED
|
@@ -1,23 +1,53 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<section class="w-full">
|
|
3
|
+
<!-- Both controls narrow on the SERVER: a collection of several hundred
|
|
4
|
+
entries does not fit in the browser, and narrowing what is loaded only
|
|
5
|
+
ever finds what is already visible. -->
|
|
6
|
+
<div v-if="!$route.params.event" class="mb-4 flex flex-wrap items-center gap-2">
|
|
7
|
+
<input-filter
|
|
8
|
+
v-if="collectionRecord?.name"
|
|
9
|
+
label="Collection"
|
|
10
|
+
:value="collectionRecord.name"
|
|
11
|
+
disabled
|
|
12
|
+
/>
|
|
13
|
+
<input-filter-status v-model="statuses" />
|
|
14
|
+
<input-search v-model="search" class="ml-auto" />
|
|
15
|
+
</div>
|
|
16
|
+
|
|
3
17
|
<default-table
|
|
4
|
-
v-if="!$route.params.event"
|
|
18
|
+
v-if="!$route.params.event && records.length"
|
|
19
|
+
:key="narrowingKey"
|
|
5
20
|
:headers="headers"
|
|
6
|
-
:filters="filters"
|
|
7
21
|
:items="records"
|
|
8
22
|
:actions="abilities"
|
|
9
23
|
:pagination-size="limit"
|
|
10
|
-
display-search-bar
|
|
11
24
|
paginated-content
|
|
12
25
|
@fetch-more="_fetchMoreRecords"
|
|
13
26
|
@update="_updateRecord"
|
|
14
27
|
@remove="_removeRecord"
|
|
15
28
|
/>
|
|
29
|
+
|
|
30
|
+
<UEmpty
|
|
31
|
+
v-else-if="!$route.params.event"
|
|
32
|
+
icon="i-lucide-inbox"
|
|
33
|
+
:title="narrowed ? 'Aucun résultat' : 'Aucune entrée'"
|
|
34
|
+
:description="narrowed
|
|
35
|
+
? 'Aucune entrée ne correspond à cette recherche ou à ces statuts.'
|
|
36
|
+
: 'Cette collection est vide pour le moment.'"
|
|
37
|
+
/>
|
|
38
|
+
|
|
39
|
+
<p v-if="!$route.params.event && records.length" class="mt-6 text-center text-sm text-dimmed">
|
|
40
|
+
{{ records.length }} affichée{{ records.length > 1 ? 's' : '' }} sur {{ total }}
|
|
41
|
+
</p>
|
|
16
42
|
</section>
|
|
17
43
|
</template>
|
|
18
44
|
|
|
19
45
|
<script setup>
|
|
20
46
|
import defaultTable from '~/components/data/lists/data-lists-table'
|
|
47
|
+
import inputFilter from '~/components/forms/inputs/input-filter'
|
|
48
|
+
import inputFilterStatus from '~/components/forms/inputs/input-filter-status'
|
|
49
|
+
import inputSearch from '~/components/forms/inputs/input-search'
|
|
50
|
+
import { ALL_STATUSES } from '~/constants/statuses'
|
|
21
51
|
|
|
22
52
|
// Prepare composable
|
|
23
53
|
const { Collection, Record } = useModels()
|
|
@@ -32,17 +62,47 @@ definePageMeta({
|
|
|
32
62
|
|
|
33
63
|
// Prepare reactive data
|
|
34
64
|
const limit = ref(25)
|
|
65
|
+
const search = ref('')
|
|
66
|
+
const statuses = ref([...ALL_STATUSES])
|
|
67
|
+
const total = ref(0)
|
|
35
68
|
|
|
36
69
|
// Prepare computed
|
|
37
70
|
const collectionRecord = computed(() => Collection.repo.where('slug', $route.params.collection).first())
|
|
38
71
|
const records = computed(() => Record.repo.where('relatedCollection', $route.params.collection).get().map(Record.onRetrieve))
|
|
39
|
-
const headers = computed(() => collectionRecord.value
|
|
40
|
-
const filters = computed(() => [])
|
|
72
|
+
const headers = computed(() => collectionRecord.value?.display?.headers || [])
|
|
41
73
|
const abilities = computed(() => ['update', 'remove'])
|
|
42
74
|
|
|
75
|
+
const narrowing = computed(() => ({
|
|
76
|
+
search: search.value,
|
|
77
|
+
// Keeping every status is not a filter: send nothing rather than all five.
|
|
78
|
+
status: statuses.value.length === ALL_STATUSES.length ? undefined : statuses.value
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
const narrowed = computed(
|
|
82
|
+
() => Boolean(search.value) || statuses.value.length !== ALL_STATUSES.length
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Remounts the table when the narrowing changes: it counts its pages
|
|
87
|
+
* internally, and a page 3 kept over a one-page result shows nothing.
|
|
88
|
+
*/
|
|
89
|
+
const narrowingKey = computed(() => `${search.value}|${statuses.value.join(',')}`)
|
|
90
|
+
|
|
43
91
|
// Prepare methods
|
|
92
|
+
const _load = async (skip = 0) => {
|
|
93
|
+
await Record.list($route.params.collection, undefined, {
|
|
94
|
+
skip,
|
|
95
|
+
limit: limit.value,
|
|
96
|
+
...narrowing.value
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const _refreshTotal = async () => {
|
|
101
|
+
total.value = await Record.count($route.params.collection, narrowing.value)
|
|
102
|
+
}
|
|
103
|
+
|
|
44
104
|
const _fetchMoreRecords = async (skip = 0) => {
|
|
45
|
-
await
|
|
105
|
+
await _load(skip)
|
|
46
106
|
}
|
|
47
107
|
|
|
48
108
|
const _updateRecord = (item) => {
|
|
@@ -51,5 +111,14 @@ const _updateRecord = (item) => {
|
|
|
51
111
|
|
|
52
112
|
const _removeRecord = async (item) => {
|
|
53
113
|
await Record.remove(item._id, $route.params.collection)
|
|
114
|
+
await _refreshTotal()
|
|
54
115
|
}
|
|
116
|
+
|
|
117
|
+
// Prepare watchers
|
|
118
|
+
watch(narrowing, async () => {
|
|
119
|
+
await _load(0)
|
|
120
|
+
await _refreshTotal()
|
|
121
|
+
}, { deep: true })
|
|
122
|
+
|
|
123
|
+
onMounted(_refreshTotal)
|
|
55
124
|
</script>
|