mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Improve snapshot listing, and add compare snapshot links
Summary: - When viewing a snapshot, list all snapshots for a doc, highlighting the current one. - Include links in per-snapshot menu to compare-to-current and compare-to-previous. - Compare links include "beta" tags. - Set order of comparison to have older on the left, and newer on the right. Test Plan: Moved out DocHistory test from Snapshots, and added some test cases. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2681
This commit is contained in:
		
							parent
							
								
									8f023a6446
								
							
						
					
					
						commit
						92224084e4
					
				@ -7,7 +7,8 @@ import {buildConfigContainer} from 'app/client/ui/RightPanel';
 | 
				
			|||||||
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
 | 
					import {buttonSelect} from 'app/client/ui2018/buttonSelect';
 | 
				
			||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
 | 
					import {colors, testId, vars} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {icon} from 'app/client/ui2018/icons';
 | 
					import {icon} from 'app/client/ui2018/icons';
 | 
				
			||||||
import {menu, menuItemLink} from 'app/client/ui2018/menus';
 | 
					import {menu, menuAnnotate, menuItemLink} from 'app/client/ui2018/menus';
 | 
				
			||||||
 | 
					import {buildUrlId, parseUrlId} from 'app/common/gristUrls';
 | 
				
			||||||
import {StringUnion} from 'app/common/StringUnion';
 | 
					import {StringUnion} from 'app/common/StringUnion';
 | 
				
			||||||
import {DocSnapshot} from 'app/common/UserAPI';
 | 
					import {DocSnapshot} from 'app/common/UserAPI';
 | 
				
			||||||
import {Disposable, dom, IDomComponent, MultiHolder, Observable, styled} from 'grainjs';
 | 
					import {Disposable, dom, IDomComponent, MultiHolder, Observable, styled} from 'grainjs';
 | 
				
			||||||
@ -16,7 +17,7 @@ import * as moment from 'moment';
 | 
				
			|||||||
const DocHistorySubTab = StringUnion("activity", "snapshots");
 | 
					const DocHistorySubTab = StringUnion("activity", "snapshots");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class DocHistory extends Disposable implements IDomComponent {
 | 
					export class DocHistory extends Disposable implements IDomComponent {
 | 
				
			||||||
  private _subTab = createSessionObs(this, "docHistorySubTab", "activity", DocHistorySubTab.guard);
 | 
					  private _subTab = createSessionObs(this, "docHistorySubTab", "snapshots", DocHistorySubTab.guard);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(private _docPageModel: DocPageModel, private _actionLog: IDomComponent) {
 | 
					  constructor(private _docPageModel: DocPageModel, private _actionLog: IDomComponent) {
 | 
				
			||||||
    super();
 | 
					    super();
 | 
				
			||||||
@ -46,35 +47,58 @@ export class DocHistory extends Disposable implements IDomComponent {
 | 
				
			|||||||
    const doc = this._docPageModel.currentDoc.get();
 | 
					    const doc = this._docPageModel.currentDoc.get();
 | 
				
			||||||
    if (!doc) { return null; }
 | 
					    if (!doc) { return null; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // If this is a snapshot already, say so to the user. We won't find any list of snapshots of it (though we could
 | 
					    // origUrlId is the snapshot-less URL, which we use to fetch snapshot history, and for
 | 
				
			||||||
    // change that to list snapshots of the trunk, and highlight this one among them).
 | 
					    // snapshot comparisons.
 | 
				
			||||||
    if (doc.idParts.snapshotId) {
 | 
					    const origUrlId = buildUrlId({...doc.idParts, snapshotId: undefined});
 | 
				
			||||||
      return cssSnapshot(cssSnapshotCard('You are looking at a backup snapshot.'));
 | 
					
 | 
				
			||||||
 | 
					    // If comparing one snapshot to another, get the other ID, so that we can highlight it too.
 | 
				
			||||||
 | 
					    const compareUrlId = urlState().state.get().params?.compare;
 | 
				
			||||||
 | 
					    const compareSnapshotId = compareUrlId && parseUrlId(compareUrlId).snapshotId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Helper to set a link to open a snapshot, optionally comparing it with a docId.
 | 
				
			||||||
 | 
					    // We include urlState().state to preserve the currently selected page.
 | 
				
			||||||
 | 
					    function setLink(snapshot: DocSnapshot, compareDocId?: string) {
 | 
				
			||||||
 | 
					      return dom.attr('href', (use) => urlState().makeUrl({
 | 
				
			||||||
 | 
					        ...use(urlState().state), doc: snapshot.docId, params: {compare: compareDocId}}));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const snapshots = Observable.create<DocSnapshot[]>(owner, []);
 | 
					    const snapshots = Observable.create<DocSnapshot[]>(owner, []);
 | 
				
			||||||
    const userApi = this._docPageModel.appModel.api;
 | 
					    const userApi = this._docPageModel.appModel.api;
 | 
				
			||||||
    const docApi = userApi.getDocAPI(doc.id);
 | 
					    const docApi = userApi.getDocAPI(origUrlId);
 | 
				
			||||||
    docApi.getSnapshots().then(result => snapshots.set(result.snapshots)).catch(reportError);
 | 
					    docApi.getSnapshots().then(result => snapshots.set(result.snapshots)).catch(reportError);
 | 
				
			||||||
    return dom('div',
 | 
					    return dom('div',
 | 
				
			||||||
      dom.forEach(snapshots, (snapshot) => {
 | 
					      // Note that most recent snapshots are first.
 | 
				
			||||||
 | 
					      dom.domComputed(snapshots, (snapshotList) => snapshotList.map((snapshot, index) => {
 | 
				
			||||||
        const modified = moment(snapshot.lastModified);
 | 
					        const modified = moment(snapshot.lastModified);
 | 
				
			||||||
 | 
					        const prevSnapshot = snapshotList[index + 1] || null;
 | 
				
			||||||
        return cssSnapshot(
 | 
					        return cssSnapshot(
 | 
				
			||||||
          cssSnapshotTime(getTimeFromNow(snapshot.lastModified)),
 | 
					          cssSnapshotTime(getTimeFromNow(snapshot.lastModified)),
 | 
				
			||||||
          cssSnapshotCard(
 | 
					          cssSnapshotCard(
 | 
				
			||||||
 | 
					            cssSnapshotCard.cls('-current', Boolean(
 | 
				
			||||||
 | 
					              snapshot.snapshotId === doc.idParts.snapshotId ||
 | 
				
			||||||
 | 
					              (compareSnapshotId && snapshot.snapshotId === compareSnapshotId)
 | 
				
			||||||
 | 
					            )),
 | 
				
			||||||
            dom('div',
 | 
					            dom('div',
 | 
				
			||||||
              cssDatePart(modified.format('ddd ll')), ' ',
 | 
					              cssDatePart(modified.format('ddd ll')), ' ',
 | 
				
			||||||
              cssDatePart(modified.format('LT'))
 | 
					              cssDatePart(modified.format('LT'))
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
            cssMenuDots(icon('Dots'),
 | 
					            cssMenuDots(icon('Dots'),
 | 
				
			||||||
              menu(() => [menuItemLink(urlState().setLinkUrl({doc: snapshot.docId}), 'Open Snapshot')],
 | 
					              menu(() => [
 | 
				
			||||||
                {placement: 'bottom-end', parentSelectorToMark: '.' + cssSnapshotCard.className}),
 | 
					                  menuItemLink(setLink(snapshot), 'Open Snapshot'),
 | 
				
			||||||
 | 
					                  menuItemLink(setLink(snapshot, origUrlId), 'Compare to Current',
 | 
				
			||||||
 | 
					                    menuAnnotate('Beta')),
 | 
				
			||||||
 | 
					                  prevSnapshot && menuItemLink(setLink(prevSnapshot, snapshot.docId), 'Compare to Previous',
 | 
				
			||||||
 | 
					                    menuAnnotate('Beta')),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					                {placement: 'bottom-end', parentSelectorToMark: '.' + cssSnapshotCard.className}
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
              testId('doc-history-snapshot-menu'),
 | 
					              testId('doc-history-snapshot-menu'),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
 | 
					            testId('doc-history-card'),
 | 
				
			||||||
          ),
 | 
					          ),
 | 
				
			||||||
          testId('doc-history-snapshot'),
 | 
					          testId('doc-history-snapshot'),
 | 
				
			||||||
        );
 | 
					        );
 | 
				
			||||||
      }),
 | 
					      })),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -102,6 +126,13 @@ const cssSnapshotCard = styled('div', `
 | 
				
			|||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  align-items: center;
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  --icon-color: ${colors.slate};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &-current {
 | 
				
			||||||
 | 
					    background-color: ${colors.dark};
 | 
				
			||||||
 | 
					    color: ${colors.light};
 | 
				
			||||||
 | 
					    --icon-color: ${colors.light};
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const cssDatePart = styled('span', `
 | 
					const cssDatePart = styled('span', `
 | 
				
			||||||
@ -117,9 +148,7 @@ const cssMenuDots = styled('div', `
 | 
				
			|||||||
  line-height: 0px;
 | 
					  line-height: 0px;
 | 
				
			||||||
  border-radius: 3px;
 | 
					  border-radius: 3px;
 | 
				
			||||||
  cursor: default;
 | 
					  cursor: default;
 | 
				
			||||||
  --icon-color: ${colors.slate};
 | 
					 | 
				
			||||||
  &:hover, &.weasel-popup-open {
 | 
					  &:hover, &.weasel-popup-open {
 | 
				
			||||||
    background-color: ${colors.mediumGrey};
 | 
					    background-color: ${colors.mediumGrey};
 | 
				
			||||||
    --icon-color: ${colors.slate};
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
				
			|||||||
@ -7,12 +7,12 @@ import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
 | 
				
			|||||||
import {primaryButton} from 'app/client/ui2018/buttons';
 | 
					import {primaryButton} from 'app/client/ui2018/buttons';
 | 
				
			||||||
import {colors, testId} from 'app/client/ui2018/cssVars';
 | 
					import {colors, testId} from 'app/client/ui2018/cssVars';
 | 
				
			||||||
import {icon} from 'app/client/ui2018/icons';
 | 
					import {icon} from 'app/client/ui2018/icons';
 | 
				
			||||||
import {menu, menuDivider, menuIcon, menuItem, menuItemLink, menuText} from 'app/client/ui2018/menus';
 | 
					import {menu, menuAnnotate, menuDivider, menuIcon, menuItem, menuItemLink, menuText} from 'app/client/ui2018/menus';
 | 
				
			||||||
import {buildUrlId, parseUrlId} from 'app/common/gristUrls';
 | 
					import {buildUrlId, parseUrlId} from 'app/common/gristUrls';
 | 
				
			||||||
import * as roles from 'app/common/roles';
 | 
					import * as roles from 'app/common/roles';
 | 
				
			||||||
import {Document} from 'app/common/UserAPI';
 | 
					import {Document} from 'app/common/UserAPI';
 | 
				
			||||||
import {dom, DomContents, styled} from 'grainjs';
 | 
					import {dom, DomContents, styled} from 'grainjs';
 | 
				
			||||||
import {cssMenuItem, MenuCreateFunc} from 'popweasel';
 | 
					import {MenuCreateFunc} from 'popweasel';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function buildOriginalUrlId(urlId: string, isSnapshot: boolean): string {
 | 
					function buildOriginalUrlId(urlId: string, isSnapshot: boolean): string {
 | 
				
			||||||
  const parts = parseUrlId(urlId);
 | 
					  const parts = parseUrlId(urlId);
 | 
				
			||||||
@ -136,9 +136,20 @@ function menuOriginal(doc: Document, appModel: AppModel, isSnapshot: boolean) {
 | 
				
			|||||||
  const termToUse = isSnapshot ? "Current Version" : "Original";
 | 
					  const termToUse = isSnapshot ? "Current Version" : "Original";
 | 
				
			||||||
  const origUrlId = buildOriginalUrlId(doc.id, isSnapshot);
 | 
					  const origUrlId = buildOriginalUrlId(doc.id, isSnapshot);
 | 
				
			||||||
  const originalUrl = urlState().makeUrl({doc: origUrlId});
 | 
					  const originalUrl = urlState().makeUrl({doc: origUrlId});
 | 
				
			||||||
  const originalUrlComparison = urlState().makeUrl({
 | 
					
 | 
				
			||||||
    doc: origUrlId, params: { compare: doc.id }
 | 
					  // When comparing forks, show changes from the original to the fork. When comparing a snapshot,
 | 
				
			||||||
  });
 | 
					  // show changes from the snapshot to the original, which seems more natural. The per-snapshot
 | 
				
			||||||
 | 
					  // comparison links in DocHistory use the same order.
 | 
				
			||||||
 | 
					  const [leftDocId, rightDocId] = isSnapshot ? [doc.id, origUrlId] : [origUrlId, doc.id];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Preserve the current state in order to stay on the selected page. TODO: Should auto-switch to
 | 
				
			||||||
 | 
					  // first page when the requested page is not in the document.
 | 
				
			||||||
 | 
					  const compareHref = dom.attr('href', (use) => urlState().makeUrl({
 | 
				
			||||||
 | 
					    ...use(urlState().state), doc: leftDocId, params: {compare: rightDocId}}));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const compareUrlId = urlState().state.get().params?.compare;
 | 
				
			||||||
 | 
					  const comparingSnapshots: boolean = isSnapshot && Boolean(compareUrlId && parseUrlId(compareUrlId).snapshotId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function replaceOriginal() {
 | 
					  function replaceOriginal() {
 | 
				
			||||||
    const user = appModel.currentValidUser;
 | 
					    const user = appModel.currentValidUser;
 | 
				
			||||||
    replaceTrunkWithFork(user, doc, appModel, origUrlId).catch(reportError);
 | 
					    replaceTrunkWithFork(user, doc, appModel, origUrlId).catch(reportError);
 | 
				
			||||||
@ -151,11 +162,13 @@ function menuOriginal(doc: Document, appModel: AppModel, isSnapshot: boolean) {
 | 
				
			|||||||
      )
 | 
					      )
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
    menuItem(replaceOriginal, `Replace ${termToUse}...`,
 | 
					    menuItem(replaceOriginal, `Replace ${termToUse}...`,
 | 
				
			||||||
      dom.cls('disabled', !roles.canEdit(doc.trunkAccess || null)),
 | 
					      // Disable if original is not writable, and also when comparing snapshots (since it's
 | 
				
			||||||
 | 
					      // unclear which of the versions to use).
 | 
				
			||||||
 | 
					      dom.cls('disabled', !roles.canEdit(doc.trunkAccess || null) || comparingSnapshots),
 | 
				
			||||||
      testId('replace-original'),
 | 
					      testId('replace-original'),
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
    menuItemLink({href: originalUrlComparison, target: '_blank'}, `Compare to ${termToUse}`,
 | 
					    menuItemLink(compareHref, {target: '_blank'}, `Compare to ${termToUse}`,
 | 
				
			||||||
      cssAnnotateMenuItem('Beta'),
 | 
					      menuAnnotate('Beta'),
 | 
				
			||||||
      testId('compare-original'),
 | 
					      testId('compare-original'),
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
  ];
 | 
					  ];
 | 
				
			||||||
@ -298,16 +311,3 @@ const cssMenuIcon = styled(icon, `
 | 
				
			|||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const cssAnnotateMenuItem = styled('span', `
 | 
					 | 
				
			||||||
  color: ${colors.lightGreen};
 | 
					 | 
				
			||||||
  text-transform: uppercase;
 | 
					 | 
				
			||||||
  font-size: 8px;
 | 
					 | 
				
			||||||
  vertical-align: super;
 | 
					 | 
				
			||||||
  margin-top: -4px;
 | 
					 | 
				
			||||||
  margin-left: 4px;
 | 
					 | 
				
			||||||
  font-weight: bold;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  .${cssMenuItem.className}-sel > & {
 | 
					 | 
				
			||||||
    color: white;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
`);
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -255,6 +255,10 @@ export function menuItemCmd(cmd: Command, label: string, ...args: DomElementArg[
 | 
				
			|||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function menuAnnotate(text: string) {
 | 
				
			||||||
 | 
					  return cssAnnotateMenuItem('Beta');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const menuDivider = styled(weasel.cssMenuDivider, `
 | 
					export const menuDivider = styled(weasel.cssMenuDivider, `
 | 
				
			||||||
  margin: 8px 0;
 | 
					  margin: 8px 0;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
@ -374,3 +378,17 @@ const cssCmdKey = styled('span', `
 | 
				
			|||||||
  color: ${colors.slate};
 | 
					  color: ${colors.slate};
 | 
				
			||||||
  margin-right: -12px;
 | 
					  margin-right: -12px;
 | 
				
			||||||
`);
 | 
					`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const cssAnnotateMenuItem = styled('span', `
 | 
				
			||||||
 | 
					  color: ${colors.lightGreen};
 | 
				
			||||||
 | 
					  text-transform: uppercase;
 | 
				
			||||||
 | 
					  font-size: 8px;
 | 
				
			||||||
 | 
					  vertical-align: super;
 | 
				
			||||||
 | 
					  margin-top: -4px;
 | 
				
			||||||
 | 
					  margin-left: 4px;
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .${weasel.cssMenuItem.className}-sel > & {
 | 
				
			||||||
 | 
					    color: white;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					`);
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user