(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:
Dmitry S 2020-12-09 00:59:42 -05:00
parent 8f023a6446
commit 92224084e4
3 changed files with 81 additions and 34 deletions

View File

@ -7,7 +7,8 @@ import {buildConfigContainer} from 'app/client/ui/RightPanel';
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
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 {DocSnapshot} from 'app/common/UserAPI';
import {Disposable, dom, IDomComponent, MultiHolder, Observable, styled} from 'grainjs';
@ -16,7 +17,7 @@ import * as moment from 'moment';
const DocHistorySubTab = StringUnion("activity", "snapshots");
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) {
super();
@ -46,35 +47,58 @@ export class DocHistory extends Disposable implements IDomComponent {
const doc = this._docPageModel.currentDoc.get();
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
// change that to list snapshots of the trunk, and highlight this one among them).
if (doc.idParts.snapshotId) {
return cssSnapshot(cssSnapshotCard('You are looking at a backup snapshot.'));
// origUrlId is the snapshot-less URL, which we use to fetch snapshot history, and for
// snapshot comparisons.
const origUrlId = buildUrlId({...doc.idParts, snapshotId: undefined});
// 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 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);
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 prevSnapshot = snapshotList[index + 1] || null;
return cssSnapshot(
cssSnapshotTime(getTimeFromNow(snapshot.lastModified)),
cssSnapshotCard(
cssSnapshotCard.cls('-current', Boolean(
snapshot.snapshotId === doc.idParts.snapshotId ||
(compareSnapshotId && snapshot.snapshotId === compareSnapshotId)
)),
dom('div',
cssDatePart(modified.format('ddd ll')), ' ',
cssDatePart(modified.format('LT'))
),
cssMenuDots(icon('Dots'),
menu(() => [menuItemLink(urlState().setLinkUrl({doc: snapshot.docId}), 'Open Snapshot')],
{placement: 'bottom-end', parentSelectorToMark: '.' + cssSnapshotCard.className}),
menu(() => [
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-card'),
),
testId('doc-history-snapshot'),
);
}),
})),
);
}
}
@ -102,6 +126,13 @@ const cssSnapshotCard = styled('div', `
overflow: hidden;
display: flex;
align-items: center;
--icon-color: ${colors.slate};
&-current {
background-color: ${colors.dark};
color: ${colors.light};
--icon-color: ${colors.light};
}
`);
const cssDatePart = styled('span', `
@ -117,9 +148,7 @@ const cssMenuDots = styled('div', `
line-height: 0px;
border-radius: 3px;
cursor: default;
--icon-color: ${colors.slate};
&:hover, &.weasel-popup-open {
background-color: ${colors.mediumGrey};
--icon-color: ${colors.slate};
}
`);

View File

@ -7,12 +7,12 @@ import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {primaryButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
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 * as roles from 'app/common/roles';
import {Document} from 'app/common/UserAPI';
import {dom, DomContents, styled} from 'grainjs';
import {cssMenuItem, MenuCreateFunc} from 'popweasel';
import {MenuCreateFunc} from 'popweasel';
function buildOriginalUrlId(urlId: string, isSnapshot: boolean): string {
const parts = parseUrlId(urlId);
@ -136,9 +136,20 @@ function menuOriginal(doc: Document, appModel: AppModel, isSnapshot: boolean) {
const termToUse = isSnapshot ? "Current Version" : "Original";
const origUrlId = buildOriginalUrlId(doc.id, isSnapshot);
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() {
const user = appModel.currentValidUser;
replaceTrunkWithFork(user, doc, appModel, origUrlId).catch(reportError);
@ -151,11 +162,13 @@ function menuOriginal(doc: Document, appModel: AppModel, isSnapshot: boolean) {
)
),
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'),
),
menuItemLink({href: originalUrlComparison, target: '_blank'}, `Compare to ${termToUse}`,
cssAnnotateMenuItem('Beta'),
menuItemLink(compareHref, {target: '_blank'}, `Compare to ${termToUse}`,
menuAnnotate('Beta'),
testId('compare-original'),
),
];
@ -298,16 +311,3 @@ const cssMenuIcon = styled(icon, `
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;
}
`);

View File

@ -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, `
margin: 8px 0;
`);
@ -374,3 +378,17 @@ const cssCmdKey = styled('span', `
color: ${colors.slate};
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;
}
`);