@open-commune/web 0.2.1 → 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.
@@ -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(() => PUBLICATION_STATUSES.find(({ option }) => option === $props.status))
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
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "description": "The Open Commune application: the admin interface and the server, on a single origin.",
42
42
  "license": "AGPL-3.0-or-later",
43
- "version": "0.2.1",
43
+ "version": "0.2.2",
44
44
  "type": "module",
45
45
  "main": "./nuxt.config.ts",
46
46
  "files": [
@@ -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.display?.headers || [])
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 Record.list($route.params.collection, undefined, { skip, limit: limit.value })
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>