mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) move client code to core
Summary: This moves all client code to core, and makes minimal fix-ups to get grist and grist-core to compile correctly. The client works in core, but I'm leaving clean-up around the build and bundles to follow-up. Test Plan: existing tests pass; server-dev bundle looks sane Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2627
This commit is contained in:
295
app/client/ui/MakeCopyMenu.ts
Normal file
295
app/client/ui/MakeCopyMenu.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Link or button that opens a menu to make a copy of a document, full or empty. It's used for
|
||||
* the sample documents (those in the Support user's Examples & Templates workspace).
|
||||
*/
|
||||
|
||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||
import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {getWorkspaceInfo, ownerName, workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {select} from 'app/client/ui2018/menus';
|
||||
import {confirmModal, cssModalBody, cssModalButtons, cssModalWidth, modal, saveModal} from 'app/client/ui2018/modals';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Document, Organization, Workspace} from 'app/common/UserAPI';
|
||||
import {Computed, Disposable, dom, input, Observable, styled, subscribe} from 'grainjs';
|
||||
import sortBy = require('lodash/sortBy');
|
||||
|
||||
|
||||
export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, app: AppModel, origUrlId: string) {
|
||||
const trunkAccess = (await app.api.getDoc(origUrlId)).access;
|
||||
if (!roles.canEdit(trunkAccess)) {
|
||||
modal((ctl) => [
|
||||
cssModalBody(`Replacing the original requires editing rights on the original document.`),
|
||||
cssModalButtons(
|
||||
bigBasicButton('Cancel', dom.on('click', () => ctl.close())),
|
||||
)
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const docApi = app.api.getDocAPI(origUrlId);
|
||||
const cmp = await docApi.compareDoc(doc.id);
|
||||
let titleText = 'Update Original';
|
||||
let buttonText = 'Update';
|
||||
let warningText = 'The original version of this document will be updated.';
|
||||
if (cmp.summary === 'left' || cmp.summary === 'both') {
|
||||
titleText = 'Original Has Modifications';
|
||||
buttonText = 'Overwrite';
|
||||
warningText = `${warningText} Be careful, the original has changes not in this document. ` +
|
||||
`Those changes will be overwritten.`;
|
||||
} else if (cmp.summary === 'unrelated') {
|
||||
titleText = 'Original Looks Unrelated';
|
||||
buttonText = 'Overwrite';
|
||||
warningText = `${warningText} It will be overwritten, losing any content not in this document.`;
|
||||
} else if (cmp.summary === 'same') {
|
||||
titleText = 'Original Looks Identical';
|
||||
warningText = `${warningText} However, it appears to be already identical.`;
|
||||
}
|
||||
confirmModal(titleText, buttonText,
|
||||
async () => {
|
||||
try {
|
||||
await docApi.replace({sourceDocId: doc.id});
|
||||
await urlState().pushUrl({doc: origUrlId});
|
||||
} catch (e) {
|
||||
reportError(e); // For example: no write access on trunk.
|
||||
}
|
||||
}, warningText);
|
||||
}
|
||||
|
||||
// Show message in a modal with a `Sign up` button that redirects to the login page.
|
||||
function signupModal(message: string) {
|
||||
return modal((ctl) => [
|
||||
cssModalBody(message),
|
||||
cssModalButtons(
|
||||
bigPrimaryButtonLink('Sign up', {href: getLoginOrSignupUrl(), target: '_blank'}, testId('modal-signup')),
|
||||
bigBasicButton('Cancel', dom.on('click', () => ctl.close())),
|
||||
),
|
||||
cssModalWidth('normal'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we should offer user the option to copy this doc to other orgs.
|
||||
* We allow copying out of source org when the source org is a personal org, or user has owner
|
||||
* access to the doc, or the doc is public.
|
||||
*/
|
||||
function allowOtherOrgs(doc: Document, app: AppModel): boolean {
|
||||
const org = app.currentOrg;
|
||||
const isPersonalOrg = Boolean(org && org.owner);
|
||||
// We allow copying out of a personal org.
|
||||
if (isPersonalOrg) { return true; }
|
||||
// Otherwise, it's a proper org. Allow copying out if the doc is public or if the user has
|
||||
// owner access to it. In case of a fork, it's the owner access to trunk that matters.
|
||||
if (doc.public || roles.canEditAccess(doc.trunkAccess || doc.access)) { return true; }
|
||||
// For non-public docs on a team site, non-privileged users are not allowed to copy them out.
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ask user for the desination and new name, and make a copy of the doc using those.
|
||||
*/
|
||||
export async function makeCopy(doc: Document, app: AppModel, modalTitle: string): Promise<void> {
|
||||
if (!app.currentValidUser) {
|
||||
signupModal('To save your changes, please sign up, then reload this page.');
|
||||
return;
|
||||
}
|
||||
const orgs = allowOtherOrgs(doc, app) ? await app.api.getOrgs(true) : null;
|
||||
|
||||
// Show a dialog with a form to select destination.
|
||||
saveModal((ctl, owner) => {
|
||||
const saveCopyModal = SaveCopyModal.create(owner, doc, app, orgs);
|
||||
return {
|
||||
title: modalTitle,
|
||||
body: saveCopyModal.buildDom(),
|
||||
saveFunc: () => saveCopyModal.save(),
|
||||
saveDisabled: saveCopyModal.saveDisabled,
|
||||
width: 'normal',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
class SaveCopyModal extends Disposable {
|
||||
private _workspaces = Observable.create<Workspace[]|null>(this, null);
|
||||
private _destName = Observable.create<string>(this, '');
|
||||
private _destOrg = Observable.create<Organization|null>(this, this._app.currentOrg);
|
||||
private _destWS = Observable.create<Workspace|null>(this, this._doc.workspace);
|
||||
private _asTemplate = Observable.create<boolean>(this, false);
|
||||
private _saveDisabled = Computed.create(this, this._destWS, this._destName, (use, ws, name) =>
|
||||
(!name.trim() || !ws || !roles.canEdit(ws.access)));
|
||||
|
||||
// Only show workspaces for team sites, since they are not a feature of personal orgs.
|
||||
private _showWorkspaces = Computed.create(this, this._destOrg, (use, org) => (org && !org.owner));
|
||||
|
||||
// If orgs is non-null, then we show a selector for orgs.
|
||||
constructor(private _doc: Document, private _app: AppModel, private _orgs: Organization[]|null) {
|
||||
super();
|
||||
if (_doc.name !== 'Untitled') {
|
||||
this._destName.set(_doc.name + ' (copy)');
|
||||
}
|
||||
if (this._orgs && this._app.currentOrg) {
|
||||
// Set _destOrg to an Organization object from _orgs array; there should be one equivalent
|
||||
// to currentOrg, but we need the actual object for select() to recognize it as selected.
|
||||
const orgId = this._app.currentOrg.id;
|
||||
this._destOrg.set(this._orgs.find((org) => org.id === orgId) || null);
|
||||
}
|
||||
this.autoDispose(subscribe(this._destOrg, (use, org) => this._updateWorkspaces(org).catch(reportError)));
|
||||
}
|
||||
|
||||
public get saveDisabled() { return this._saveDisabled; }
|
||||
|
||||
public async save() {
|
||||
const ws = this._destWS.get();
|
||||
if (!ws) { throw new Error('No destination workspace'); }
|
||||
const api = this._app.api;
|
||||
const org = this._destOrg.get();
|
||||
const docWorker = await api.getWorkerAPI('import');
|
||||
const destName = this._destName.get() + '.grist';
|
||||
const uploadId = await docWorker.copyDoc(this._doc.id, this._asTemplate.get(), destName);
|
||||
const {id} = await docWorker.importDocToWorkspace(uploadId, ws.id);
|
||||
await urlState().pushUrl({org: org?.domain || undefined, doc: id, docPage: urlState().state.get().docPage});
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return [
|
||||
cssField(
|
||||
cssLabel("Name"),
|
||||
input(this._destName, {onInput: true}, dom.cls(cssInput.className),
|
||||
// modal dialog grabs focus after 10ms delay; so to focus this input, wait a bit longer
|
||||
// (see the TODO in app/client/ui2018/modals.ts about weasel.js and focus).
|
||||
(elem) => { setTimeout(() => { elem.focus(); }, 20); },
|
||||
dom.on('focus', (ev, elem) => { elem.select(); }),
|
||||
testId('copy-dest-name'))
|
||||
),
|
||||
cssField(
|
||||
cssLabel("As Template"),
|
||||
cssCheckbox(this._asTemplate, 'Include the structure without any of the data.',
|
||||
testId('save-as-template'))
|
||||
),
|
||||
// Show the team picker only when saving to other teams is allowed and there are other teams
|
||||
// accessible.
|
||||
(this._orgs ?
|
||||
cssField(
|
||||
cssLabel("Organization"),
|
||||
select(this._destOrg, this._orgs.map(value => ({value, label: value.name}))),
|
||||
testId('copy-dest-org'),
|
||||
) : null
|
||||
),
|
||||
// Don't show the workspace picker when destOrg is a personal site and there is just one
|
||||
// workspace, since workspaces are not a feature of personal orgs.
|
||||
// Show the workspace picker only when destOrg is a team site, because personal orgs do not have workspaces.
|
||||
dom.domComputed((use) => use(this._showWorkspaces) && use(this._workspaces), (wss) =>
|
||||
wss === false ? null :
|
||||
wss && wss.length === 0 ? cssWarningText("You do not have write access to this site",
|
||||
testId('copy-warning')) :
|
||||
[
|
||||
cssField(
|
||||
cssLabel("Workspace"),
|
||||
(wss === null ?
|
||||
cssSpinner(loadingSpinner()) :
|
||||
select(this._destWS, wss.map(value => ({
|
||||
value,
|
||||
label: workspaceName(this._app, value),
|
||||
disabled: !roles.canEdit(value.access),
|
||||
})))
|
||||
),
|
||||
testId('copy-dest-workspace'),
|
||||
),
|
||||
wss ? dom.maybe(this._saveDisabled, () =>
|
||||
cssWarningText("You do not have write access to the selected workspace",
|
||||
testId('copy-warning')
|
||||
)
|
||||
) : null
|
||||
]
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a list of workspaces for the given org, in the same order in which we list them in HomeModel,
|
||||
* and set this._workspaces to it. While fetching, this._workspaces is set to null.
|
||||
* Once fetched, we also set this._destWS.
|
||||
*/
|
||||
private async _updateWorkspaces(org: Organization|null) {
|
||||
this._workspaces.set(null); // Show that workspaces are loading.
|
||||
try {
|
||||
let wss = org ? await this._app.api.getOrgWorkspaces(org.id) : [];
|
||||
// Sort the same way that HomeModel sorts workspaces.
|
||||
wss = sortBy(wss,
|
||||
(ws) => [ws.isSupportWorkspace, ownerName(this._app, ws).toLowerCase(), ws.name.toLowerCase()]);
|
||||
// Filter out isSupportWorkspace, since it's not writable and confusing to include.
|
||||
// (The support user creating a new example can just download and upload.)
|
||||
wss = wss.filter(ws => !ws.isSupportWorkspace);
|
||||
|
||||
let defaultWS: Workspace|undefined;
|
||||
const showWorkspaces = (org && !org.owner);
|
||||
if (showWorkspaces) {
|
||||
// If we show a workspace selector, default to the current document's workspace (when its
|
||||
// org is selected) even if it's not writable. User can switch the workspace manually.
|
||||
defaultWS = wss.find(ws => (ws.id === this._doc.workspace.id));
|
||||
} else {
|
||||
// If the workspace selector is not shown (for personal orgs), prefer the user's default
|
||||
// Home workspace as long as its writable.
|
||||
defaultWS = wss.find(ws => getWorkspaceInfo(this._app, ws).isDefault && roles.canEdit(ws.access));
|
||||
}
|
||||
const firstWritable = wss.find(ws => roles.canEdit(ws.access));
|
||||
|
||||
// If there is at least one destination available, set one as the current selection.
|
||||
// Otherwise, make it clear to the user that there are no options.
|
||||
if (firstWritable) {
|
||||
this._workspaces.set(wss);
|
||||
this._destWS.set(defaultWS || firstWritable);
|
||||
} else {
|
||||
this._workspaces.set([]);
|
||||
this._destWS.set(null);
|
||||
}
|
||||
} catch (e) {
|
||||
this._workspaces.set([]);
|
||||
this._destWS.set(null);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const cssInput = styled('input', `
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
border-radius: 3px;
|
||||
padding: 5px;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
outline: none;
|
||||
`);
|
||||
|
||||
export const cssField = styled('div', `
|
||||
margin: 16px 0;
|
||||
display: flex;
|
||||
`);
|
||||
|
||||
export const cssLabel = styled('label', `
|
||||
font-weight: normal;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
color: ${colors.dark};
|
||||
margin: 8px 16px 0 0;
|
||||
white-space: nowrap;
|
||||
width: 80px;
|
||||
flex: none;
|
||||
`);
|
||||
|
||||
const cssWarningText = styled('div', `
|
||||
color: red;
|
||||
margin-top: 8px;
|
||||
`);
|
||||
|
||||
const cssSpinner = styled('div', `
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
`);
|
||||
|
||||
const cssCheckbox = styled(labeledSquareCheckbox, `
|
||||
margin-top: 8px;
|
||||
`);
|
||||
Reference in New Issue
Block a user