From 59a0caaab3433c55ae64dbe3df63994fbbfdc659 Mon Sep 17 00:00:00 2001 From: Chris Millar Date: Thu, 16 Apr 2026 18:27:05 -0600 Subject: [PATCH 1/3] feat: detect stale sheet/config and prevent concurrent overwrites * Add StaleCheck class to check for stale configs and sheets * Checks every 30 seconds for changes. * If changes, but nothing new locally, automatically updates local sheet. * If local and remote changes, present a dialog allowing to refresh or keep local edits. Keeping local edits will prevent auto saves and manual blue button saves. * da-title updates to support the following: * disabledText property which doubles for disabling actions * Remove paper plane if only one action available * Any sheet inside `/.da/` can no longer be previewed or published. * Lazily check for disablePublish to ensure actions show ASAP. * Move `isSending` to class property to allow showing send event for save only actions. * Created a class-level event for save action Resolves: #704 Co-Authored-By: Claude Opus 4.6 (1M context) --- blocks/edit/da-title/da-title.css | 61 ++++++-- blocks/edit/da-title/da-title.js | 133 +++++++++++------- blocks/sheet/sheet.js | 44 +++++- blocks/sheet/utils/index.js | 10 +- blocks/sheet/utils/utils.js | 114 ++++++++++++++- .../blocks/edit/da-title/da-title.test.js | 81 +++++++---- 6 files changed, 343 insertions(+), 100 deletions(-) diff --git a/blocks/edit/da-title/da-title.css b/blocks/edit/da-title/da-title.css index d3b565346..9a0e87a72 100644 --- a/blocks/edit/da-title/da-title.css +++ b/blocks/edit/da-title/da-title.css @@ -44,6 +44,11 @@ da-dialog { background: var(--s2-blue-900); border-color: var(--s2-blue-900); color: #FFF; + + &:disabled { + background: var(--s2-gray-500); + border-color: var(--s2-gray-500); + } } .da-title-inner { @@ -121,13 +126,16 @@ da-dialog { z-index: 2; } -.collab-icon.collab-popup::after { +.collab-icon.collab-popup::after, +.da-title-action::after { display: block; content: attr(data-popup-content); position: absolute; bottom: -32px; left: 50%; transform: translateX(-50%); + font-size: 12px; + line-height: 1.6; text-align: center; text-transform: capitalize; background: #676767; @@ -137,6 +145,21 @@ da-dialog { border-radius: 4px; } +.da-title-action { + position: relative; + display: none; + + &::after { + display: none; + bottom: -22px; + text-transform: unset; + } + + &:hover::after { + display: unset; + } +} + .collab-icon-user { height: 24px; border-radius: 12px; @@ -172,9 +195,15 @@ da-dialog { height: 27px; } -.da-title-action { +.da-title-action-send { position: relative; - display: none; + padding: 5px 0; + width: 44px; + height: 44px; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; } .da-title-actions { @@ -184,7 +213,8 @@ da-dialog { gap: 12px; height: 44px; - &.is-open { + &.is-open, + &.has-one-action { margin-left: 12px; .da-title-action { @@ -200,6 +230,18 @@ da-dialog { } } + &.has-one-action { + margin-left: 0; + + .da-title-action-send { + display: none; + } + + &::before { + background: transparent; + } + } + &.is-fixed { position: fixed; right: 18px; @@ -208,17 +250,6 @@ da-dialog { } } -.da-title-action-send { - position: relative; - padding: 5px 0; - width: 44px; - height: 44px; - display: flex; - justify-content: center; - align-items: center; - overflow: hidden; -} - .da-title-action-send-icon { position: relative; display: block; diff --git a/blocks/edit/da-title/da-title.js b/blocks/edit/da-title/da-title.js index 0eaf57fda..dc40cbf4e 100644 --- a/blocks/edit/da-title/da-title.js +++ b/blocks/edit/da-title/da-title.js @@ -38,10 +38,12 @@ export default class DaTitle extends LitElement { collabUsers: { attribute: false }, previewPrefix: { attribute: false }, livePrefix: { attribute: false }, + disabledText: { attribute: false }, _lazyMods: { state: true }, _configs: { state: true }, _actions: { state: true }, _status: { state: true }, + _isSending: { state: true }, _dialog: { state: true }, }; @@ -68,7 +70,7 @@ export default class DaTitle extends LitElement { update(changed) { super.update(changed); if (changed.has('details') && this.details) { - this.reset(); + this.setup(); this.delayedSetup(); } } @@ -86,7 +88,51 @@ export default class DaTitle extends LitElement { reset() { this._scheduled = undefined; this._configs = undefined; - this._actions = {}; + } + + setup() { + this.reset(); + this._actions = { + available: this.getAvailableActions() + } + // Lazily filter the actions down + this.filterActions(); + } + + getAvailableActions() { + const { view, path, fullpath } = this.details; + + // Config only gets save + if (view === 'config') return ['save']; + + // DA app configs only get save + if (fullpath.includes('/.da/') && view === 'sheet') return ['save']; + + const availableActions = []; + + if (view === 'sheet') { + availableActions.push('save'); + } + + if (path) { + availableActions.push('preview', 'publish'); + } + + return availableActions; + } + + async filterActions() { + const { org, site, fullpath } = this.details; + const configs = await Promise.all(fetchDaConfigs({ org, site })); + const configTab = configs.flatMap((config) => getFirstSheet(config) || []); + + // Check which actions should be allowed for the document based on config + const publishConfigs = configTab.filter((c) => c.key === 'editor.hidePublish'); + const hidePublish = publishConfigs.some((c) => fullpath.startsWith(c.value)); + if (!hidePublish) return; + + this._actions.available = this._actions.available.filter((action) => action !== 'publish'); + this.requestUpdate(); } // Run setup after a short delay. @@ -101,12 +147,7 @@ export default class DaTitle extends LitElement { ]); const { org, site, path, fullpath } = this.details; - const configs = await Promise.all(fetchDaConfigs({ org, site })); - this._configs = configs.flatMap((config) => getFirstSheet(config) || []); - - this._actions.available = await this.getAvailableActions(); - this.requestUpdate(); - + // Only a valid path gets AEM-bound features if (path) { this._aemHrefs = await getAemHrefs({ path: fullpath }); @@ -119,40 +160,20 @@ export default class DaTitle extends LitElement { return getExistingSchedule(org, site, path); } - async getAvailableActions() { - const { view, path, fullpath } = this.details; - - // Config only gets save - if (view === 'config') return ['save']; - - const availableActions = []; - - if (view === 'sheet') { - availableActions.push('save'); - } - - if (path) { - availableActions.push('preview'); - - // Check which actions should be allowed for the document based on config - const publishConfigs = this._configs.filter((c) => c.key === 'editor.hidePublish'); - const hidePublish = publishConfigs.some((c) => fullpath.startsWith(c.value)); - - if (!hidePublish) availableActions.push('publish'); - } - - return availableActions; - } - toggleActions() { this._actions.open = !this._actions.open; this.requestUpdate(); } - handleError(json, action, icon) { + handleSuccess(action) { + const opts = { detail: { action }, composed: true, bubbles: true }; + const event = new CustomEvent('success', opts); + this.dispatchEvent(event); + } + + handleError(json, action) { this._status = { ...json.error, action }; - icon.classList.remove('is-sending'); - icon.parentElement.classList.add('is-error'); + this._isSending = false; } async setScheduledDialog(schedule) { @@ -207,14 +228,23 @@ export default class DaTitle extends LitElement { async handleAction(action) { this._status = null; - this._sendButton.classList.add('is-sending'); + this._isSending = true; this._actions.open = false; - this.requestUpdate(); const { org, site, view, fullpath, path } = this.details; const aemPath = `/${org}/${site}${path}`; + // Bail before writing if the remote drifted under us — protects against + // last-write-wins. Drift triggers the stale-content dialog via onStale. + if (view === 'sheet' || view === 'config') { + const { staleCheck } = await import('../../sheet/utils/utils.js'); + if (await staleCheck.checkForDrift()) { + this._isSending = false; + return; + } + } + // Only save to DA if it is a sheet or config if (view === 'sheet') { const sheetPath = fullpath.replace('.json', ''); @@ -229,11 +259,16 @@ export default class DaTitle extends LitElement { return; } } + if (view === 'sheet' || view === 'config') { + // Tell anything listening save was successful + this.handleSuccess('save'); + } + // AEM Actions if (action === 'preview' || action === 'publish') { let json = await saveToAem(aemPath, 'preview'); if (json.error) { - this.handleError(json, 'preview', this._sendButton); + this.handleError(json, 'preview'); return; } @@ -244,7 +279,7 @@ export default class DaTitle extends LitElement { if (this._scheduled?.scheduled) { const shouldContinue = await this.setScheduledDialog(this._scheduled); if (!shouldContinue) { - this._sendButton.classList.remove('is-sending'); + this._isSending = false; return; } } @@ -254,7 +289,7 @@ export default class DaTitle extends LitElement { // Handle all AEM errors if (json.error) { - this.handleError(json, 'publish', this._sendButton); + this.handleError(json, 'publish'); return; } @@ -286,7 +321,7 @@ export default class DaTitle extends LitElement { if (action === 'publish') saveDaVersion(fullpath, 'Published'); else if (action === 'preview') saveDaVersion(fullpath, 'Previewed'); } - this._sendButton.classList.remove('is-sending'); + this._isSending = false; } async handleRoleRequest() { @@ -317,10 +352,6 @@ export default class DaTitle extends LitElement { this._dialog = { title, content, action: closeAction }; } - get _sendButton() { - return this.shadowRoot.querySelector('.da-title-action-send-icon'); - } - get _canPrepare() { return !!this.details.path; } @@ -337,7 +368,9 @@ export default class DaTitle extends LitElement { `)}`; @@ -406,10 +439,10 @@ export default class DaTitle extends LitElement { ${this.collabStatus ? this.renderCollab() : nothing} ${this._canPrepare ? html`` : nothing} ${this._status ? this.renderError() : nothing} -
+
${this.renderActions()} -