diff --git a/src/store/data.js b/src/store/data.js index 75fc23a92f..f17f2b1732 100644 --- a/src/store/data.js +++ b/src/store/data.js @@ -95,20 +95,29 @@ export const useDataStore = defineStore('data', { async loadColumnsFromBE({ view, tableId }) { let allColumns = await this.getColumnsFromBE({ tableId, viewId: view?.id }) if (view) { - // Transform array to object for faster access + // Meta columns (e.g. id, created-by) aren't real DB columns and + // never come back from the backend fetch above -- append any + // that this view has settings for, same as before. const columnSettingsMap = view.columnSettings?.reduce((acc, item) => { acc[item.columnId] = item return acc }, {}) ?? {} - allColumns = allColumns.concat(MetaColumns.filter(col => columnSettingsMap[col.id])) - if (view.columnSettings) { - allColumns = allColumns.sort((a, b) => { - const orderA = columnSettingsMap[a.id]?.order ?? Number.MAX_SAFE_INTEGER - const orderB = columnSettingsMap[b.id]?.order ?? Number.MAX_SAFE_INTEGER - return orderA - orderB - }) - } + + // Sort using each column's own viewColumnInformation (set + // server-side by ColumnService::enhanceColumn(), same field the + // public-share load path already relies on) as the primary + // source, falling back to columnSettingsMap for synthetic meta + // columns that were just concatenated above and never went + // through server-side enhancement. This keeps ordering correct + // even for callers that can't supply a full view.columnSettings + // array (e.g. the embedded reference widget), while preserving + // existing behavior for callers that can. + allColumns = allColumns.sort((a, b) => { + const orderA = a.viewColumnInformation?.order ?? columnSettingsMap[a.id]?.order ?? Number.MAX_SAFE_INTEGER + const orderB = b.viewColumnInformation?.order ?? columnSettingsMap[b.id]?.order ?? Number.MAX_SAFE_INTEGER + return orderA - orderB + }) } else { // no view: keep the backend-ordered result (ColumnService::findAllByTable already applies columnOrder) } @@ -159,6 +168,9 @@ export const useDataStore = defineStore('data', { this.columns[stateId].push(parseCol(res.data)) this.loading[stateId] = false } + + emit('tables:column:created', { isView, elementId, column: res.data }) + return true }, @@ -181,6 +193,8 @@ export const useDataStore = defineStore('data', { this.columns[stateId][index] = parseCol(col) } + emit('tables:column:updated', { isView, elementId, column: res.data }) + return true }, @@ -198,6 +212,8 @@ export const useDataStore = defineStore('data', { this.columns[stateId] = filteredColumns } + emit('tables:column:deleted', { isView, elementId, columnId: id }) + return true }, @@ -300,6 +316,8 @@ export const useDataStore = defineStore('data', { await this.removeRowIfNotInView({ rowId: row?.id, viewId, stateId }) } + emit('tables:row:updated', { isView, elementId, row: res.data }) + return true }, @@ -340,6 +358,8 @@ export const useDataStore = defineStore('data', { await this.removeRowIfNotInView({ rowId: row?.id, viewId, stateId }) } + emit('tables:row:created', { isView: !!viewId, elementId: viewId ?? tableId, row: res?.data?.ocs?.data }) + return true }, @@ -386,6 +406,9 @@ export const useDataStore = defineStore('data', { const filteredRows = this.rows[stateId].filter(r => r.id !== rowId) this.rows[stateId] = filteredRows } + + emit('tables:row:deleted', { isView, elementId, rowId }) + return true }, diff --git a/src/views/ContentReferenceWidget.vue b/src/views/ContentReferenceWidget.vue index f4749867d1..0f9eac20fe 100644 --- a/src/views/ContentReferenceWidget.vue +++ b/src/views/ContentReferenceWidget.vue @@ -6,7 +6,7 @@

- + {{ richObject.emoji }} {{ richObject.title }}

-
+
@@ -52,9 +52,24 @@ import permissionsMixin from '../shared/components/ncTable/mixins/permissionsMix import { NcLoadingIcon } from '@nextcloud/vue' import { useResizeObserver } from '@vueuse/core' import { spawnDialog } from '@nextcloud/vue/functions/dialog' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' import { useTablesStore } from '../store/store.js' import { useDataStore } from '../store/data.js' +// Row/column CRUD events emitted by data.js -- both the main Tables app and +// this widget go through the same data.js actions (even though they run on +// separate Pinia instances), so listening here gives real-time updates +// whenever a row or column changes anywhere: from the main app's grid, from +// another copy of this widget, etc. -- without polling or requiring a +// manual refresh. +// NOTE: these are past-tense ("created"/"updated"/"deleted") deliberately -- +// TableView.vue already emits 'tables:row:create'/'tables:row:delete'/ +// 'tables:column:create'/'tables:column:delete' as imperative "open the +// modal" signals when the user clicks a button, with a different payload +// shape. Don't reuse those names here. +const ROW_EVENTS = ['tables:row:created', 'tables:row:updated', 'tables:row:deleted'] +const COLUMN_EVENTS = ['tables:column:created', 'tables:column:updated', 'tables:column:deleted'] + export default { components: { @@ -85,16 +100,22 @@ export default { data() { return { searchExp: null, - localRows: [], // Keep as fallback only showCopyRow: false, copyPrefillData: null, rowToDelete: null, tablesStore: null, dataStore: null, + // True once the initial backend fetch for rows+columns has completed. + // Nothing in this component reads richObject.rows/richObject.columns + // as data any more -- they're only ever used for id/type/title/emoji. + loaded: false, } }, computed: { + isView() { + return Boolean(this.richObject?.type) + }, tablePermissions() { return { canCreateRows: this.canCreateRowInElement(this.richObject), @@ -123,41 +144,27 @@ export default { return this.rows } }, - getRows() { - return this.dataStore ? this.dataStore.getRows(false, this.richObject.id) : [] - }, - // Use computed property to get rows from store or richObject + // Store is the ONLY source of truth for rows/columns. No fallback to + // richObject.rows / richObject.columns -- those are just a snapshot frozen + // at reference-resolution time and can be arbitrarily stale (old column + // order, missing newly created rows, etc.). rows() { - // First try to get from the store - const storeRows = this.getRows - if (storeRows && storeRows.length > 0) { - return storeRows - } - // Fallback to richObject rows or local rows - return this.richObject?.rows || this.localRows + return this.dataStore ? this.dataStore.getRows(this.isView, this.richObject.id) : [] + }, + columns() { + return this.dataStore ? this.dataStore.getColumns(this.isView, this.richObject.id) : [] }, }, watch: { + // Covers "refresh": if the parent re-resolves the reference and swaps in + // a new richObject (same or different id) without remounting this + // component, refetch from the backend rather than trusting whatever + // richObject now contains. richObject: { deep: true, - handler(newVal) { - if (newVal && newVal.rows && this.localRows !== newVal.rows) { - this.localRows = newVal.rows - } - }, - }, - rows: { - deep: true, - handler(newRows) { - if (this.richObject && newRows) { - /* eslint-disable vue/no-mutating-props */ - this.richObject.rows = newRows - this.richObject.rowsCount = newRows.length - /* eslint-enable vue/no-mutating-props */ - } - // Force update of filteredRows when rows change - this.search(this.searchExp ? this.searchExp.source : '') + handler() { + this.reload() }, }, }, @@ -166,17 +173,50 @@ export default { useResizeObserver(this.$el, (entries) => { const entry = entries[0] const { width } = entry.contentRect - // In Vue 3 $el can be a fragment/comment node (no style), so guard it. - this.$el?.style?.setProperty?.('--widget-content-width', `${width}px`) + this.$el.style.setProperty('--widget-content-width', `${width}px`) }) this.tablesStore = useTablesStore() this.dataStore = useDataStore() - await this.loadRows() + ROW_EVENTS.forEach(event => subscribe(event, this.onRowChanged)) + COLUMN_EVENTS.forEach(event => subscribe(event, this.onColumnChanged)) + + await this.reload() + }, + + // NOTE: if this component runs under Vue 3's Options API, rename this hook + // to `unmounted()`. Left as `beforeDestroy` to match the rest of this file's + // Vue 2-style lifecycle usage -- adjust if your build is Vue 3. + beforeDestroy() { + ROW_EVENTS.forEach(event => unsubscribe(event, this.onRowChanged)) + COLUMN_EVENTS.forEach(event => unsubscribe(event, this.onColumnChanged)) }, methods: { + // Builds the { tableId } or { viewId } payload loadRowsFromBE expects, + // based on whether richObject is a table or a view. + elementIdPayload() { + return this.isView + ? { viewId: this.richObject.id } + : { tableId: this.richObject.id } + }, + // True if an emitted row/column event refers to this exact table/view. + matchesThisElement(payload) { + return !!payload + && payload.isView === this.isView + && String(payload.elementId) === String(this.richObject.id) + }, + onRowChanged(payload) { + if (this.matchesThisElement(payload)) { + this.loadRows() + } + }, + onColumnChanged(payload) { + if (this.matchesThisElement(payload)) { + this.loadColumns() + } + }, search(searchString) { this.searchExp = (searchString !== '') ? new RegExp(searchString.trim(), 'ig') @@ -186,28 +226,26 @@ export default { const { default: CreateRow } = await import('../modules/modals/CreateRow.vue') spawnDialog(CreateRow, { showModal: true, - columns: this.richObject.columns, - isView: Boolean(this.richObject.type), + columns: this.columns, + isView: this.isView, elementId: this.richObject.id, }, async () => { - // Reload rows from the backend to get the latest data - await this.dataStore.loadRowsFromBE({ - tableId: this.richObject.id, - }) + // Reload rows from the backend to get the latest data. (data.js's + // insertNewRow also emits tables:row:created, so onRowChanged + // above will fire this again too -- harmless, just a duplicate fetch.) + await this.dataStore.loadRowsFromBE(this.elementIdPayload()) }) }, async editRow(rowId) { const { default: EditRow } = await import('../modules/modals/EditRow.vue') spawnDialog(EditRow, { showModal: true, - columns: this.richObject.columns, + columns: this.columns, row: this.getRow(rowId), - isView: Boolean(this.richObject.type), + isView: this.isView, element: this.richObject, }, async () => { - await this.dataStore.loadRowsFromBE({ - tableId: this.richObject.id, - }) + await this.dataStore.loadRowsFromBE(this.elementIdPayload()) }) }, copyRow(rowId) { @@ -222,26 +260,29 @@ export default { }, async loadRows() { if (!this.dataStore) return - - if (this.richObject.rows) { - this.localRows = this.richObject.rows - this.dataStore.seedRows({ - isView: Boolean(this.richObject.type), - elementId: this.richObject.id, - rows: this.richObject.rows, - }) - return - } - try { - await this.dataStore.loadRowsFromBE({ - tableId: this.richObject.id, - }) - // No need to set local rows as the computed property will use store data + await this.dataStore.loadRowsFromBE(this.elementIdPayload()) } catch (error) { console.error('Error loading rows:', error) } }, + async loadColumns() { + if (!this.dataStore) return + try { + if (this.isView) { + await this.dataStore.loadColumnsFromBE({ view: this.richObject }) + } else { + await this.dataStore.loadColumnsFromBE({ tableId: this.richObject.id }) + } + } catch (error) { + console.error('Error loading columns:', error) + } + }, + async reload() { + this.loaded = false + await Promise.all([this.loadRows(), this.loadColumns()]) + this.loaded = true + }, }, } @@ -249,14 +290,18 @@ export default { .tables-content-widget { min-height: max(50vh, 200px); - height: 50vh; + height: 60vh; + max-height: calc(100dvh - 40px); overflow: scroll; + overscroll-behavior: contain; + isolation: isolate; & .header { position: sticky; top: 0; inset-inline-start: 0; - z-index: 1; + z-index: 7; + background-color: var(--color-main-background); :where(.options) { position: sticky; @@ -285,8 +330,11 @@ export default { .nc-table { min-width: var(--widget-content-width); - :where(.options.row) { - display: none; + :deep(.options.row) { + height: 0 !important; + overflow: hidden !important; + margin: 0 !important; + padding: 0 !important; } :where(thead) {