@stonecrop/atable 0.24.0 → 0.26.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.
@@ -14,7 +14,7 @@
14
14
  <tbody>
15
15
  <slot name="body" :data="store">
16
16
  <ARow
17
- v-for="(row, filteredIndex) in store.filteredRows"
17
+ v-for="(row, filteredIndex) in visibleRows"
18
18
  :key="`${row.originalIndex}-${filteredIndex}`"
19
19
  :row="row"
20
20
  :row-index="row.originalIndex"
@@ -57,7 +57,20 @@
57
57
  </slot>
58
58
  </tbody>
59
59
 
60
- <slot name="footer" :data="store" />
60
+ <slot name="footer" :data="store" :page="pagination">
61
+ <ATablePaginationFooter
62
+ :column-count="footerColumnCount"
63
+ :show-footer="showFooter"
64
+ :has-more="hasMore"
65
+ :has-prev="hasPrev"
66
+ :has-next="hasNext"
67
+ :has-local-next="hasLocalNext"
68
+ :has-ever-had-more="hasEverHadMore"
69
+ :loaded-count="loadedCount"
70
+ :loading="paginationLoading"
71
+ :next="paginationNext"
72
+ :prev="paginationPrev" />
73
+ </slot>
61
74
 
62
75
  <!-- Modal overlay -->
63
76
  <slot name="modal" :data="store">
@@ -92,8 +105,10 @@ import AGanttConnection from './AGanttConnection.vue'
92
105
  import ARow from './ARow.vue'
93
106
  import ATableHeader from './ATableHeader.vue'
94
107
  import ATableModal from './ATableModal.vue'
108
+ import ATablePaginationFooter from './ATablePaginationFooter.vue'
109
+ import { useTablePagination } from '../composables/table-pagination'
95
110
  import { createTableStore } from '../stores/table'
96
- import type { ColumnSchema } from '@stonecrop/schema'
111
+ import type { ColumnSchema, GetRecordsOptions, GetRecordsResult } from '@stonecrop/schema'
97
112
 
98
113
  import { schemaToColumns } from '../schemaToColumns'
99
114
  import type {
@@ -123,11 +138,15 @@ const {
123
138
  config = new Object(),
124
139
  schema = [],
125
140
  linkResolver = undefined,
141
+ getRecords = undefined,
142
+ sourceKey = undefined,
126
143
  } = defineProps<{
127
144
  id?: string
128
145
  config?: TableConfig
129
146
  schema?: ColumnSchema[]
130
147
  linkResolver?: LinkResolverFn
148
+ getRecords?: (options?: GetRecordsOptions) => Promise<GetRecordsResult>
149
+ sourceKey?: string
131
150
  }>()
132
151
 
133
152
  const emit = defineEmits<{
@@ -156,6 +175,29 @@ const store = createTableStore({
156
175
  linkResolver: linkResolver ?? injectedLinkResolver,
157
176
  })
158
177
 
178
+ const pagination = useTablePagination({
179
+ rows: () => store.filteredRows,
180
+ pageSize: () => store.config.pageSize,
181
+ getRecords,
182
+ sourceKey: () => sourceKey,
183
+ })
184
+
185
+ const {
186
+ visibleRows,
187
+ showFooter,
188
+ hasMore,
189
+ hasPrev,
190
+ hasNext,
191
+ hasLocalNext,
192
+ hasEverHadMore,
193
+ loadedCount,
194
+ loading: paginationLoading,
195
+ next: paginationNext,
196
+ prev: paginationPrev,
197
+ } = pagination
198
+
199
+ const footerColumnCount = computed(() => store.columns.length + (store.zeroColumn ? 1 : 0))
200
+
159
201
  store.$onAction(({ name, store: storeInstance, args, after }) => {
160
202
  if (name === 'setCellData' || name === 'setCellText') {
161
203
  const [colIndex, rowIndex, newValue] = args
@@ -398,6 +440,7 @@ const handleRowAction = (actionType: RowActionType, rowIndex: number, event?: Mo
398
440
 
399
441
  defineExpose({
400
442
  store,
443
+ pagination,
401
444
  createConnection: store.createConnection,
402
445
  deleteConnection: store.deleteConnection,
403
446
  getConnectionsForBar: store.getConnectionsForBar,
@@ -66,26 +66,11 @@
66
66
 
67
67
  <script setup lang="ts">
68
68
  import { ref, reactive, computed } from 'vue'
69
- import { componentCategory, type ComponentCategory } from '@stonecrop/schema'
70
69
 
71
70
  import { createTableStore } from '../stores/table'
71
+ import { resolveFilterType } from '../resolveFilterType'
72
72
  import type { TableColumn } from '../types'
73
73
 
74
- // The filter widget is chosen by the component's semantic category.
75
- const CATEGORY_FILTER: Record<ComponentCategory, 'text' | 'select' | 'number' | 'date' | 'dateRange' | 'checkbox'> = {
76
- text: 'text',
77
- number: 'number',
78
- boolean: 'checkbox',
79
- date: 'date',
80
- datetime: 'dateRange',
81
- select: 'select',
82
- code: 'text',
83
- link: 'text',
84
- attach: 'text',
85
- quantity: 'number',
86
- currency: 'number',
87
- }
88
-
89
74
  const { column, colIndex, store } = defineProps<{
90
75
  column: TableColumn
91
76
  colIndex: number
@@ -98,13 +83,7 @@ const dateFilter = reactive({
98
83
  endValue: '',
99
84
  })
100
85
 
101
- const resolvedFilterType = computed(() => {
102
- if (column.filterType) return column.filterType
103
- const category = componentCategory(column.component)
104
- if (category) return CATEGORY_FILTER[category]
105
- // An unknown (custom) component gets the widget that can filter anything.
106
- return 'text'
107
- })
86
+ const resolvedFilterType = computed(() => resolveFilterType(column))
108
87
 
109
88
  const getSelectOptions = (col: TableColumn): any[] => {
110
89
  if (col.filterOptions) return col.filterOptions
@@ -0,0 +1,100 @@
1
+ <template>
2
+ <tfoot v-if="showFooter" class="atable-pagination">
3
+ <tr>
4
+ <td :colspan="columnCount">
5
+ <p v-if="hasMore" class="truncation-note">This is a partial list — more records exist on the server.</p>
6
+ <p v-else-if="showCompleteNote" class="completion-note">All {{ loadedCount }} records loaded.</p>
7
+ <div v-if="showControls" class="atable-pagination-controls">
8
+ <button type="button" class="atable-pagination-btn" :disabled="!hasPrev || loading" @click="prev">
9
+ Previous
10
+ </button>
11
+ <button type="button" class="atable-pagination-btn" :disabled="!hasNext || loading" @click="onNext">
12
+ {{ hasMore && !hasLocalNext ? 'Load more' : 'Next' }}
13
+ </button>
14
+ </div>
15
+ </td>
16
+ </tr>
17
+ </tfoot>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import { computed } from 'vue'
22
+
23
+ const props = defineProps<{
24
+ columnCount: number
25
+ showFooter: boolean
26
+ hasMore: boolean
27
+ hasPrev: boolean
28
+ hasNext: boolean
29
+ hasLocalNext: boolean
30
+ hasEverHadMore: boolean
31
+ loadedCount: number
32
+ loading: boolean
33
+ next: () => Promise<void>
34
+ prev: () => void
35
+ }>()
36
+
37
+ const showCompleteNote = computed(() => !props.hasMore && props.hasEverHadMore)
38
+ const showControls = computed(() => props.hasMore || props.hasPrev)
39
+
40
+ const onNext = () => {
41
+ void props.next()
42
+ }
43
+ </script>
44
+
45
+ <style scoped>
46
+ .atable-pagination td {
47
+ padding: 0.75rem 0.5ch;
48
+ border-top: 1px solid var(--sc-row-border-color, #e0e0e0);
49
+ }
50
+
51
+ .truncation-note,
52
+ .completion-note {
53
+ margin: 0 0 0.5rem;
54
+ font-size: var(--sc-table-font-size, 0.875rem);
55
+ color: var(--sc-gray-60, #666);
56
+ }
57
+
58
+ .completion-note {
59
+ margin-bottom: 0;
60
+ }
61
+
62
+ .atable-pagination-controls {
63
+ display: flex;
64
+ gap: 0.5rem;
65
+ }
66
+
67
+ .atable-pagination-btn {
68
+ appearance: none;
69
+ -webkit-appearance: none;
70
+ box-sizing: border-box;
71
+ padding: 0.5rem 1rem;
72
+ background: var(--sc-btn-color, #fafafa);
73
+ color: var(--sc-btn-label-color, #333333);
74
+ border: 1px solid var(--sc-btn-border, #cccccc);
75
+ border-radius: var(--sc-border-radius, 0);
76
+ cursor: pointer;
77
+ font-family: var(--sc-font-family, Arial, sans-serif);
78
+ font-size: var(--sc-table-font-size, 0.875rem);
79
+ font-weight: 400;
80
+ line-height: 1.2;
81
+ transition: background-color 0.15s ease;
82
+ }
83
+
84
+ .atable-pagination-btn:hover:not(:disabled) {
85
+ background: var(--sc-btn-hover, #f2f2f2);
86
+ }
87
+
88
+ .atable-pagination-btn:focus-visible {
89
+ outline: 2px solid var(--sc-input-active-border-color, #333333);
90
+ outline-offset: 1px;
91
+ }
92
+
93
+ .atable-pagination-btn:disabled {
94
+ cursor: not-allowed;
95
+ background: var(--sc-gray-5, #f2f2f2);
96
+ color: var(--sc-gray-50, #808080);
97
+ border-color: var(--sc-gray-20, #cccccc);
98
+ opacity: 1;
99
+ }
100
+ </style>
@@ -0,0 +1,172 @@
1
+ import type { GetRecordsOptions, GetRecordsResult } from '@stonecrop/schema'
2
+ import { computed, nextTick, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
3
+
4
+ import type { TableRow } from '../types'
5
+
6
+ /**
7
+ * A table row after filter/sort, with its index in the held row list.
8
+ * @public
9
+ */
10
+ export type FilteredTableRow = TableRow & { originalIndex: number }
11
+
12
+ /**
13
+ * Options for {@link useTablePagination}.
14
+ * @public
15
+ */
16
+ export interface UseTablePaginationOptions {
17
+ /** Rows after filter and sort — pagination is a window over this list, not part of filtering. */
18
+ rows: MaybeRefOrGetter<FilteredTableRow[]>
19
+ /** When set, slice the held rows into windows of this size. Omit to show the whole held list. */
20
+ pageSize?: MaybeRefOrGetter<number | undefined>
21
+ /** Fetch the next server page. Typed from schema so ATable need not import Stonecrop. */
22
+ getRecords?: (options?: GetRecordsOptions) => Promise<GetRecordsResult>
23
+ /** When this changes, refetch from offset 0. */
24
+ sourceKey?: MaybeRefOrGetter<string | undefined>
25
+ }
26
+
27
+ /**
28
+ * Pagination state and controls returned by {@link useTablePagination}.
29
+ * @public
30
+ */
31
+ export interface TablePagination {
32
+ visibleRows: Ref<FilteredTableRow[]>
33
+ hasPrev: Ref<boolean>
34
+ hasNext: Ref<boolean>
35
+ /** Whether the server reported more records beyond what has been fetched. */
36
+ hasMore: Ref<boolean>
37
+ loading: Ref<boolean>
38
+ next: () => Promise<void>
39
+ prev: () => void
40
+ /** Whether footer chrome should render. */
41
+ showFooter: Ref<boolean>
42
+ /** Whether another in-memory window exists before asking the server. */
43
+ hasLocalNext: Ref<boolean>
44
+ /** Rows currently held after filter/sort — useful for a completion summary. */
45
+ loadedCount: Ref<number>
46
+ /** True once the server reported a further page existed (multi-page fetch). */
47
+ hasEverHadMore: Ref<boolean>
48
+ }
49
+
50
+ /**
51
+ * Window rows after filter/sort, and optionally walk {@link @stonecrop/schema#GetRecordsOptions} for the next page.
52
+ * Pagination is not a filter — pass already-filtered rows.
53
+ * @public
54
+ */
55
+ export function useTablePagination(options: UseTablePaginationOptions): TablePagination {
56
+ const heldRows = computed(() => toValue(options.rows))
57
+ const pageSize = computed(() => toValue(options.pageSize))
58
+ const sourceKey = computed(() => toValue(options.sourceKey))
59
+
60
+ const windowIndex = ref(0)
61
+ const loading = ref(false)
62
+ const serverHasMore = ref(false)
63
+ const hasEverHadMore = ref(false)
64
+ const lastFetchOffset = ref(0)
65
+ const lastPageLength = ref(0)
66
+ const loadedCount = computed(() => heldRows.value.length)
67
+
68
+ const effectivePageSize = computed(() => {
69
+ const size = pageSize.value
70
+ if (size != null && size > 0) {
71
+ return size
72
+ }
73
+ // No pageSize: one window over everything held (server list or full child table).
74
+ const count = heldRows.value.length
75
+ return count > 0 ? count : 1
76
+ })
77
+
78
+ const windowCount = computed(() => Math.max(1, Math.ceil(heldRows.value.length / effectivePageSize.value)))
79
+
80
+ const visibleRows = computed(() => {
81
+ const start = windowIndex.value * effectivePageSize.value
82
+ return heldRows.value.slice(start, start + effectivePageSize.value)
83
+ })
84
+
85
+ const hasLocalNext = computed(() => windowIndex.value < windowCount.value - 1)
86
+ const hasPrev = computed(() => windowIndex.value > 0)
87
+ const hasNext = computed(() => hasLocalNext.value || (Boolean(options.getRecords) && serverHasMore.value))
88
+ const hasMore = computed(() => serverHasMore.value)
89
+
90
+ const showFooter = computed(() => {
91
+ if (options.getRecords) {
92
+ // Keep the footer after the last "Load more" so completion is visible, not silent.
93
+ return serverHasMore.value || hasPrev.value || loading.value || (hasEverHadMore.value && !serverHasMore.value)
94
+ }
95
+ const size = pageSize.value
96
+ return size != null && size > 0 && heldRows.value.length > size
97
+ })
98
+
99
+ const resetWindow = () => {
100
+ windowIndex.value = 0
101
+ hasEverHadMore.value = false
102
+ }
103
+
104
+ const fetchFromServer = async (fetchOptions?: GetRecordsOptions) => {
105
+ if (!options.getRecords) {
106
+ return
107
+ }
108
+ loading.value = true
109
+ try {
110
+ const result = await options.getRecords(fetchOptions)
111
+ serverHasMore.value = result.hasMore
112
+ if (result.hasMore) {
113
+ hasEverHadMore.value = true
114
+ }
115
+ lastFetchOffset.value = fetchOptions?.offset ?? 0
116
+ lastPageLength.value = result.data.length
117
+ } finally {
118
+ loading.value = false
119
+ }
120
+ }
121
+
122
+ watch(
123
+ () => sourceKey.value,
124
+ () => {
125
+ resetWindow()
126
+ if (options.getRecords) {
127
+ void fetchFromServer()
128
+ }
129
+ },
130
+ { immediate: true }
131
+ )
132
+
133
+ watch(heldRows, () => {
134
+ // Shrinking the held set can leave the window past the end.
135
+ if (windowIndex.value >= windowCount.value) {
136
+ windowIndex.value = Math.max(0, windowCount.value - 1)
137
+ }
138
+ })
139
+
140
+ const next = async () => {
141
+ if (hasLocalNext.value) {
142
+ windowIndex.value += 1
143
+ return
144
+ }
145
+ if (options.getRecords && serverHasMore.value) {
146
+ await fetchFromServer({ offset: lastFetchOffset.value + lastPageLength.value })
147
+ await nextTick()
148
+ // Parent/HST grows rows; show the newly appended tail.
149
+ windowIndex.value = Math.max(0, windowCount.value - 1)
150
+ }
151
+ }
152
+
153
+ const prev = () => {
154
+ if (windowIndex.value > 0) {
155
+ windowIndex.value -= 1
156
+ }
157
+ }
158
+
159
+ return {
160
+ visibleRows,
161
+ hasPrev,
162
+ hasNext,
163
+ hasMore,
164
+ loading,
165
+ next,
166
+ prev,
167
+ showFooter,
168
+ hasLocalNext,
169
+ loadedCount,
170
+ hasEverHadMore,
171
+ }
172
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,10 @@ import ATableHeader from './components/ATableHeader.vue'
9
9
  import ATableLoading from './components/ATableLoading.vue'
10
10
  import ATableLoadingBar from './components/ATableLoadingBar.vue'
11
11
  import ATableModal from './components/ATableModal.vue'
12
+ import ATablePaginationFooter from './components/ATablePaginationFooter.vue'
12
13
  export { createTableStore } from './stores/table'
14
+ export { useTablePagination } from './composables/table-pagination'
15
+ export type { FilteredTableRow, TablePagination, UseTablePaginationOptions } from './composables/table-pagination'
13
16
  export type { FilterState, FilterStateRecord } from './stores/table'
14
17
  export type * from './types'
15
18
  export { schemaToColumns } from './schemaToColumns'
@@ -41,6 +44,7 @@ function install(app: App /* options */) {
41
44
  app.component('ATableLoading', ATableLoading)
42
45
  app.component('ATableLoadingBar', ATableLoadingBar)
43
46
  app.component('ATableModal', ATableModal)
47
+ app.component('ATablePaginationFooter', ATablePaginationFooter)
44
48
  }
45
49
 
46
50
  export {
@@ -53,5 +57,6 @@ export {
53
57
  ATableLoading,
54
58
  ATableLoadingBar,
55
59
  ATableModal,
60
+ ATablePaginationFooter,
56
61
  install,
57
62
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Normalize a scalar cell value to a non-empty string, or nothing.
3
+ *
4
+ * Rejecting objects is what the haystack wants: a nested value has no single textual form, and
5
+ * `String()` on one contributes `[object Object]`, which matches a search for "object" and nothing
6
+ * a user would actually type.
7
+ */
8
+ function scalarText(value: unknown): string | undefined {
9
+ if (value == null || typeof value === 'object') return undefined
10
+ if (typeof value === 'string') return value === '' ? undefined : value
11
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
12
+ return String(value)
13
+ }
14
+ return undefined
15
+ }
16
+
17
+ /**
18
+ * Build the substring-search haystack for a link column cell.
19
+ * Server middleware enriches inline FKs to `{ id, displayText }`; search both.
20
+ * @public
21
+ */
22
+ export function linkSearchableText(cellValue: unknown): string {
23
+ if (cellValue == null || cellValue === '') return ''
24
+
25
+ // `in` rather than an assertion to `Record<string, unknown>`: the assertion claims the value has
26
+ // string keys of unknown type, which is narrower than `object` and so is a claim this function
27
+ // cannot make about a cell it did not build.
28
+ if (typeof cellValue === 'object') {
29
+ const parts: string[] = []
30
+ if ('id' in cellValue) {
31
+ const id = scalarText(cellValue.id)
32
+ if (id !== undefined) parts.push(id)
33
+ }
34
+ if ('displayText' in cellValue) {
35
+ const displayText = scalarText(cellValue.displayText)
36
+ if (displayText !== undefined) parts.push(displayText)
37
+ }
38
+ return parts.join(' ')
39
+ }
40
+
41
+ return scalarText(cellValue) ?? ''
42
+ }
@@ -0,0 +1,32 @@
1
+ import { componentCategory, type ComponentCategory } from '@stonecrop/schema'
2
+
3
+ import type { TableColumn } from './types'
4
+
5
+ /** Filter widget type derived from a component's semantic category. */
6
+ export type ResolvedFilterType = 'text' | 'select' | 'number' | 'date' | 'dateRange' | 'checkbox' | 'component'
7
+
8
+ const CATEGORY_FILTER: Record<ComponentCategory, Exclude<ResolvedFilterType, 'component'>> = {
9
+ text: 'text',
10
+ number: 'number',
11
+ boolean: 'checkbox',
12
+ date: 'date',
13
+ datetime: 'dateRange',
14
+ select: 'select',
15
+ code: 'text',
16
+ link: 'text',
17
+ attach: 'text',
18
+ quantity: 'number',
19
+ currency: 'number',
20
+ }
21
+
22
+ /**
23
+ * Resolve the filter widget and filter logic type for a column.
24
+ * Explicit `filterType` wins; otherwise derive from the component category.
25
+ * @public
26
+ */
27
+ export function resolveFilterType(column: Pick<TableColumn, 'filterType' | 'component'>): ResolvedFilterType {
28
+ if (column.filterType) return column.filterType
29
+ const category = componentCategory(column.component)
30
+ if (category) return CATEGORY_FILTER[category]
31
+ return 'text'
32
+ }
@@ -3,6 +3,7 @@ import { componentCategory } from '@stonecrop/schema'
3
3
  import type { BadgeDescriptor } from '@stonecrop/schema'
4
4
  import { type CSSProperties, computed, ref } from 'vue'
5
5
 
6
+ import { linkSearchableText } from '../linkSearchableText'
6
7
  import type {
7
8
  CellContext,
8
9
  ConnectionHandle,
@@ -15,6 +16,7 @@ import type {
15
16
  TableModal,
16
17
  TableRow,
17
18
  } from '../types'
19
+ import { resolveFilterType } from '../resolveFilterType'
18
20
  import { formatCurrency, formatQuantity, generateHash } from '../utils'
19
21
 
20
22
  /**
@@ -77,15 +79,16 @@ function isNodeOpen(rowIndex: number, treeDisplay: TableDisplay[]): boolean {
77
79
  }
78
80
 
79
81
  function applyFilter(cellValue: any, filter: FilterState, column: TableColumn): boolean {
80
- const filterType = column.filterType || 'text'
82
+ const filterType = resolveFilterType(column)
81
83
  const value = filter.value
82
84
 
83
85
  if (!value && filterType !== 'dateRange' && filterType !== 'checkbox') return true
84
86
 
85
87
  switch (filterType) {
86
88
  case 'text': {
87
- const searchableText =
88
- typeof cellValue === 'object' && cellValue !== null
89
+ const searchableText = column.linkDoctype
90
+ ? linkSearchableText(cellValue)
91
+ : typeof cellValue === 'object' && cellValue !== null
89
92
  ? Object.values(cellValue).join(' ')
90
93
  : String(cellValue || '')
91
94
  return searchableText.toLowerCase().includes(String(value).toLowerCase())
@@ -360,7 +363,7 @@ export const createTableStore = (initData: {
360
363
  filter.value ||
361
364
  filter.startValue ||
362
365
  filter.endValue ||
363
- (column.filterType === 'checkbox' && filter.value !== undefined)
366
+ (resolveFilterType(column) === 'checkbox' && filter.value !== undefined)
364
367
 
365
368
  if (!hasFilterValue) return
366
369
 
@@ -205,6 +205,12 @@ export interface BaseTableConfig {
205
205
  * Configuration for row-level actions (add, delete, duplicate, etc.).
206
206
  */
207
207
  rowActions?: RowActionsConfig
208
+
209
+ /**
210
+ * When set, slice the held rows into in-memory windows of this size.
211
+ * Omit to show the whole held list as one window (server paging uses the backend cap).
212
+ */
213
+ pageSize?: number
208
214
  }
209
215
 
210
216
  /**