(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:
Paul Fitzpatrick
2020-10-02 11:10:00 -04:00
parent 5d60d51763
commit 1654a2681f
395 changed files with 52651 additions and 47 deletions

View File

@@ -0,0 +1,191 @@
/**
* UI for managing granular ACLs.
*/
import {GristDoc} from 'app/client/components/GristDoc';
import {createObsArray} from 'app/client/lib/koArrayWrap';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {primaryButton} from 'app/client/ui2018/buttons';
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuItem, select} from 'app/client/ui2018/menus';
import {arrayRepeat, setDifference} from 'app/common/gutil';
import {Computed, Disposable, dom, ObsArray, obsArray, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
interface AclState {
ownerOnlyTableIds: Set<string>;
ownerOnlyStructure: boolean;
}
function buildAclState(gristDoc: GristDoc): AclState {
const ownerOnlyTableIds = new Set<string>();
let ownerOnlyStructure = false;
const tableData = gristDoc.docModel.aclResources.tableData;
for (const res of tableData.getRecords()) {
const code = String(res.colIds);
if (res.tableId && code === '~o') {
ownerOnlyTableIds.add(String(res.tableId));
}
if (!res.tableId && code === '~o structure') {
ownerOnlyStructure = true;
}
}
return {ownerOnlyTableIds, ownerOnlyStructure};
}
export class AccessRules extends Disposable {
public isAnythingChanged: Computed<boolean>;
// NOTE: For the time being, rules correspond one to one with resources.
private _initialState: AclState = buildAclState(this._gristDoc);
private _allTableIds: ObsArray<string> = createObsArray(this, this._gristDoc.docModel.allTableIds);
private _ownerOnlyTableIds = this.autoDispose(obsArray([...this._initialState.ownerOnlyTableIds]));
private _ownerOnlyStructure = Observable.create<boolean>(this, this._initialState.ownerOnlyStructure);
private _currentState = Computed.create<AclState>(this, (use) => ({
ownerOnlyTableIds: new Set(use(this._ownerOnlyTableIds)),
ownerOnlyStructure: use(this._ownerOnlyStructure),
}));
constructor(private _gristDoc: GristDoc) {
super();
this.isAnythingChanged = Computed.create(this, (use) =>
!isEqual(use(this._currentState), this._initialState));
}
public async save(): Promise<void> {
if (!this.isAnythingChanged.get()) { return; }
// If anything has changed, we re-fetch the state from the current docModel (it may have been
// changed by other users), and apply changes, if any, relative to that.
const latestState = buildAclState(this._gristDoc);
const currentState = this._currentState.get();
const tableData = this._gristDoc.docModel.aclResources.tableData;
await tableData.docData.bundleActions('Update Access Rules', async () => {
// If ownerOnlyStructure flag changed, add or remove the relevant resource record.
if (currentState.ownerOnlyStructure !== latestState.ownerOnlyStructure) {
if (currentState.ownerOnlyStructure) {
await tableData.sendTableAction(['AddRecord', null, {tableId: "", colIds: "~o structure"}]);
} else {
const rowId = tableData.findMatchingRowId({tableId: '', colIds: '~o structure'});
if (rowId) {
await this._gristDoc.docModel.aclResources.sendTableAction(['RemoveRecord', rowId]);
}
}
}
// Handle tables added to ownerOnlyTableIds.
const tablesAdded = setDifference(currentState.ownerOnlyTableIds, latestState.ownerOnlyTableIds);
if (tablesAdded.size) {
await tableData.sendTableAction(['BulkAddRecord', arrayRepeat(tablesAdded.size, null), {
tableId: [...tablesAdded],
colIds: arrayRepeat(tablesAdded.size, "~o"),
}]);
}
// Handle table removed from ownerOnlyTaleIds.
const tablesRemoved = setDifference(latestState.ownerOnlyTableIds, currentState.ownerOnlyTableIds);
if (tablesRemoved.size) {
const rowIds = Array.from(tablesRemoved, t => tableData.findRow('tableId', t)).filter(r => r);
await tableData.sendTableAction(['BulkRemoveRecord', rowIds]);
}
});
}
public buildDom() {
return [
cssAddTableRow(
primaryButton(icon('Plus'), 'Add Table Rules',
menu(() => [
dom.forEach(this._allTableIds, (tableId) =>
// Add the table on a timeout, to avoid disabling the clicked menu item
// synchronously, which prevents the menu from closing on click.
menuItem(() => setTimeout(() => this._ownerOnlyTableIds.push(tableId), 0),
tableId,
dom.cls('disabled', (use) => use(this._ownerOnlyTableIds).includes(tableId)),
)
),
]),
),
),
shadowScroll(
dom.forEach(this._ownerOnlyTableIds, (tableId) => {
return cssTableRule(
cssTableHeader(
dom('div', 'Rules for ', dom('b', dom.text(tableId))),
cssRemove(icon('Remove'),
dom.on('click', () =>
this._ownerOnlyTableIds.splice(this._ownerOnlyTableIds.get().indexOf(tableId), 1))
),
),
cssTableBody(
cssPermissions('All Access'),
cssPrincipals('Owners'),
),
);
}),
cssTableRule(
cssTableHeader('Default Rule'),
cssTableBody(
cssPermissions('Schema Edit'),
cssPrincipals(
select(this._ownerOnlyStructure, [
{label: 'Owners Only', value: true},
{label: 'Owners & Editors', value: false}
]),
)
),
),
),
];
}
}
const cssAddTableRow = styled('div', `
margin: 0 64px 16px 64px;
display: flex;
justify-content: flex-end;
`);
const cssTableRule = styled('div', `
margin: 16px 64px;
border: 1px solid ${colors.darkGrey};
border-radius: 4px;
padding: 8px 16px 16px 16px;
`);
const cssTableHeader = styled('div', `
display: flex;
align-items: center;
margin-bottom: 8px;
`);
const cssTableBody = styled('div', `
display: flex;
align-items: center;
`);
const cssPermissions = styled('div', `
flex: 1;
white-space: nowrap;
color: ${colors.lightGreen};
`);
const cssPrincipals = styled('div', `
flex: 1;
color: ${colors.lightGreen};
`);
const cssRemove = styled('div', `
flex: none;
margin: 0 4px 0 auto;
height: 24px;
width: 24px;
padding: 4px;
border-radius: 3px;
cursor: default;
--icon-color: ${colors.slate};
&:hover {
background-color: ${colors.darkGrey};
--icon-color: ${colors.slate};
}
`);

View File

@@ -0,0 +1,224 @@
import {loadUserManager} from 'app/client/lib/imports';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState';
import {showDocSettingsModal} from 'app/client/ui/DocumentSettings';
import {showProfileModal} from 'app/client/ui/ProfileDialog';
import {createUserImage} from 'app/client/ui/UserImage';
import {primaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
import {commonUrls} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles';
import {getOrgName, Organization, SUPPORT_EMAIL} from 'app/common/UserAPI';
import {bundleChanges, Disposable, dom, DomElementArg, Observable, styled} from 'grainjs';
import {cssMenuItem} from 'popweasel';
/**
* Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in
* button instead.
*/
export class AccountWidget extends Disposable {
private _users = Observable.create<FullUser[]>(this, []);
private _orgs = Observable.create<Organization[]>(this, []);
constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) {
super();
// We initialize users and orgs asynchronously when we create the menu, so it will *probably* be
// available by the time the user opens it. Even if not, we do not delay the opening of the menu.
this._fetchUsersAndOrgs().catch(reportError);
}
public buildDom() {
return cssAccountWidget(
dom.domComputed(this._appModel.currentValidUser, (user) =>
(user ?
cssUserIcon(createUserImage(user, 'medium', testId('user-icon')),
menu(() => this._makeAccountMenu(user), {placement: 'bottom-end'}),
) :
primaryButtonLink('Sign in',
{href: getLoginOrSignupUrl(), style: 'margin: 8px'},
testId('user-signin'))
)
),
testId('dm-account'),
);
}
private async _fetchUsersAndOrgs() {
if (!this._appModel.topAppModel.isSingleOrg) {
const data = await this._appModel.api.getSessionAll();
if (this.isDisposed()) { return; }
bundleChanges(() => {
this._users.set(data.users);
this._orgs.set(data.orgs);
});
}
}
/**
* Renders the content of the account menu, with a list of available orgs, settings, and sign-out.
* Note that `user` should NOT be anonymous (none of the items are really relevant).
*/
private _makeAccountMenu(user: FullUser): DomElementArg[] {
// Opens the user-manager for the org.
const manageUsers = async (org: Organization) => {
const api = this._appModel.api;
(await loadUserManager()).showUserManagerModal(api, {
permissionData: api.getOrgAccess(org.id),
activeEmail: user ? user.email : null,
resourceType: 'organization',
resourceId: org.id
});
};
const currentOrg = this._appModel.currentOrg;
const gristDoc = this._docPageModel ? this._docPageModel.gristDoc.get() : null;
const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount &&
(currentOrg.billingAccount.isManager || user.email === SUPPORT_EMAIL));
return [
cssUserInfo(
createUserImage(user, 'large'),
cssUserName(user.name,
cssEmail(user.email, testId('usermenu-email'))
)
),
menuItem(() => showProfileModal(this._appModel), 'Profile Settings'),
// Enable 'Document Settings' when there is an open document.
(gristDoc ?
menuItem(() => showDocSettingsModal(gristDoc.docInfo, this._docPageModel!), 'Document Settings',
testId('dm-doc-settings')) :
null),
// Show 'Organization Settings' when on a home page of a valid org.
(!this._docPageModel && currentOrg && !currentOrg.owner ?
menuItem(() => manageUsers(currentOrg), 'Manage Users', testId('dm-org-access'),
dom.cls('disabled', !roles.canEditAccess(currentOrg.access))) :
// Don't show on doc pages, or for personal orgs.
null),
// Show link to billing pages.
currentOrg && !currentOrg.owner ?
// For links, disabling with just a class is hard; easier to just not make it a link.
// TODO weasel menus should support disabling menuItemLink.
(isBillingManager ?
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') :
menuItem(() => null, 'Billing Account', dom.cls('disabled', true))
) :
menuItemLink({href: commonUrls.plans}, 'Upgrade Plan'),
// TODO Add section ("Here right now") listing icons of other users currently on this doc.
// (See Invision "Panels" near the bottom.)
// In case of a single-org setup, skip all the account-switching UI. We'll also skip the
// org-listing UI below.
this._appModel.topAppModel.isSingleOrg ? [] : [
menuDivider(),
menuSubHeader(dom.text((use) => use(this._users).length > 1 ? 'Switch Accounts' : 'Accounts')),
dom.forEach(this._users, (_user) => {
if (_user.id === user.id) { return null; }
return menuItem(() => this._switchAccount(_user),
cssSmallIconWrap(createUserImage(_user, 'small')),
cssOtherEmail(_user.email, testId('usermenu-other-email')),
);
}),
menuItemLink({href: getLoginUrl()}, "Add Account", testId('dm-add-account')),
],
menuItemLink({href: getLogoutUrl()}, "Sign Out", testId('dm-log-out')),
dom.maybe((use) => use(this._orgs).length > 0, () => [
menuDivider(),
menuSubHeader('Switch Sites'),
]),
dom.forEach(this._orgs, (org) =>
menuItemLink(urlState().setLinkUrl({org: org.domain || undefined}),
cssOrgSelected.cls('', this._appModel.currentOrg ? org.id === this._appModel.currentOrg.id : false),
getOrgName(org),
cssOrgCheckmark('Tick', testId('usermenu-org-tick')),
testId('usermenu-org'),
)
),
];
}
// Switch BrowserSession to use the given user for the currently loaded org.
private async _switchAccount(user: FullUser) {
await this._appModel.api.setSessionActive(user.email);
if (urlState().state.get().doc) {
// Document access level may have changed.
// If it was not accessible but now is, we currently need to reload the page to get
// a complete gristConfig for the document from the server.
// If it was accessible but now is not, it would suffice to reconnect the web socket.
// For simplicity, just reload from server in either case.
// TODO: get fancier here to avoid reload.
window.location.reload(true);
return;
}
this._appModel.topAppModel.initialize();
}
}
const cssAccountWidget = styled('div', `
margin-right: 16px;
white-space: nowrap;
`);
const cssUserIcon = styled('div', `
height: 48px;
width: 48px;
padding: 8px;
cursor: pointer;
`);
const cssUserInfo = styled('div', `
padding: 12px 24px 12px 16px;
min-width: 200px;
display: flex;
align-items: center;
`);
const cssUserName = styled('div', `
margin-left: 8px;
font-size: ${vars.mediumFontSize};
font-weight: ${vars.headerControlTextWeight};
color: ${colors.dark};
`);
const cssEmail = styled('div', `
margin-top: 4px;
font-size: ${vars.smallFontSize};
font-weight: initial;
color: ${colors.slate};
`);
const cssSmallIconWrap = styled('div', `
flex: none;
margin: -4px 8px -4px 0px;
`);
const cssOtherEmail = styled('div', `
color: ${colors.slate};
.${cssMenuItem.className}-sel & {
color: ${colors.light};
}
`);
const cssOrgSelected = styled('div', `
background-color: ${colors.dark};
color: ${colors.light};
`);
const cssOrgCheckmark = styled(icon, `
flex: none;
margin-left: 16px;
--icon-color: ${colors.light};
display: none;
.${cssOrgSelected.className} > & {
display: block;
}
`);

View File

@@ -0,0 +1,75 @@
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {dom, DomElementArg, Observable, styled} from "grainjs";
export function addNewButton(isOpen: Observable<boolean> | boolean = true, ...args: DomElementArg[]) {
return cssAddNewButton(
cssAddNewButton.cls('-open', isOpen),
// Setting spacing as flex items allows them to shrink faster when there isn't enough space.
cssLeftMargin(),
cssAddText('Add New'),
dom('div', {style: 'flex: 1 1 16px'}),
cssPlusButton(cssPlusIcon('Plus')),
dom('div', {style: 'flex: 0 1 16px'}),
...args,
);
}
export const cssAddNewButton = styled('div', `
display: flex;
align-items: center;
margin: 22px 0px 22px 0px;
height: 40px;
color: ${colors.light};
border: none;
border-radius: 4px;
cursor: default;
text-align: left;
font-size: ${vars.bigControlFontSize};
font-weight: bold;
overflow: hidden;
--circle-color: ${colors.lightGreen};
&:hover, &.weasel-popup-open {
--circle-color: ${colors.darkGreen};
}
&-open {
margin: 22px 16px 22px 16px;
background-color: ${colors.lightGreen};
--circle-color: ${colors.darkGreen};
}
&-open:hover, &-open.weasel-popup-open {
background-color: ${colors.darkGreen};
--circle-color: ${colors.darkerGreen};
}
`);
const cssLeftMargin = styled('div', `
flex: 0 1 24px;
display: none;
.${cssAddNewButton.className}-open & {
display: block;
}
`);
const cssAddText = styled('div', `
flex: 0 0.5 content;
white-space: nowrap;
min-width: 0px;
display: none;
.${cssAddNewButton.className}-open & {
display: block;
}
`);
const cssPlusButton = styled('div', `
flex: none;
height: 28px;
width: 28px;
border-radius: 14px;
background-color: var(--circle-color);
text-align: center;
`);
const cssPlusIcon = styled(icon, `
background-color: ${colors.light};
margin-top: 6px;
`);

123
app/client/ui/ApiKey.ts Normal file
View File

@@ -0,0 +1,123 @@
import * as billingPageCss from 'app/client/ui/BillingPageCss';
import { basicButton } from 'app/client/ui2018/buttons';
import { confirmModal } from 'app/client/ui2018/modals';
import { Disposable, dom, makeTestId, Observable, observable, styled } from "grainjs";
interface IWidgetOptions {
apiKey: Observable<string>;
onDelete: () => Promise<void>;
onCreate: () => Promise<void>;
anonymous?: boolean; // Configure appearance and available options for anonymous use.
// When anonymous, no modifications are permitted to profile information.
// TODO: add browser test for this option.
}
const testId = makeTestId('test-apikey-');
/**
* ApiKey component shows an api key with controls to change it. Expects `options.apiKey` the api
* key and shows it if value is truthy along with a 'Delete' button that triggers the
* `options.onDelete` callback. When `options.apiKey` is falsy, hides it and show a 'Create' button
* that triggers the `options.onCreate` callback. It is the responsability of the caller to update
* the `options.apiKey` to its new value.
*/
export class ApiKey extends Disposable {
private _apiKey: Observable<string>;
private _onDeleteCB: () => Promise<void>;
private _onCreateCB: () => Promise<void>;
private _anonymous: boolean;
private _loading = observable(false);
constructor(options: IWidgetOptions) {
super();
this._apiKey = options.apiKey;
this._onDeleteCB = options.onDelete;
this._onCreateCB = options.onCreate;
this._anonymous = Boolean(options.anonymous);
}
public buildDom() {
return dom('div', testId('container'), dom.style('position', 'relative'),
dom.maybe(this._apiKey, (apiKey) => dom('div',
cssRow(
cssInput(
{readonly: true, value: this._apiKey.get()}, testId('key'),
dom.on('click', (ev, el) => el.select())
),
cssTextBtn(
cssBillingIcon('Remove'), 'Remove',
dom.on('click', () => this._showRemoveKeyModal()),
testId('delete'),
dom.boolAttr('disabled', (use) => use(this._loading) || this._anonymous)
),
),
description(this._getDescription(), testId('description')),
)),
dom.maybe((use) => !(use(this._apiKey) || this._anonymous), () => [
basicButton('Create', dom.on('click', () => this._onCreate()), testId('create'),
dom.boolAttr('disabled', this._loading)),
description('By generating an API key, you will be able to make API calls '
+ 'for your own account.', testId('description')),
]),
);
}
// Switch the `_loading` flag to `true` and later, once promise resolves, switch it back to
// `false`.
private async _switchLoadingFlag(promise: Promise<any>) {
this._loading.set(true);
try {
await promise;
} finally {
this._loading.set(false);
}
}
private _onDelete(): Promise<void> {
return this._switchLoadingFlag(this._onDeleteCB());
}
private _onCreate(): Promise<void> {
return this._switchLoadingFlag(this._onCreateCB());
}
private _getDescription(): string {
if (!this._anonymous) {
return 'This API key can be used to access your account via the API. '
+ 'Dont share your API key with anyone.';
} else {
return 'This API key can be used to access this account anonymously via the API.';
}
}
private _showRemoveKeyModal(): void {
confirmModal(
`Remove API Key`, 'Remove',
() => this._onDelete(),
`You're about to delete an API key. This will cause all future ` +
`requests using this API key to be rejected. Do you still want to delete?`
);
}
}
const description = styled('div', `
color: #8a8a8a;
font-size: 13px;
`);
const cssInput = styled('input', `
outline: none;
flex: 1 0 0;
`);
const cssRow = styled('div', `
display: flex;
`);
const cssTextBtn = styled(billingPageCss.billingTextBtn, `
width: 90px;
margin-left: 16px;
`);
const cssBillingIcon = billingPageCss.billingIcon;

48
app/client/ui/App.css Normal file
View File

@@ -0,0 +1,48 @@
html {
height: 100%;
overflow: hidden;
}
body {
height: 100%;
font-family: sans-serif;
font-size: 1.2rem;
margin: 0;
padding: 0;
}
#grist-app {
height: 100%;
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
-webkit-flex-wrap: nowrap;
flex-wrap: nowrap;
}
.g-help {
position: absolute;
top: 10%;
left: 10%;
height: 80%;
width: 80%;
z-index: 999;
padding: 1rem;
background-color: rgba(0, 0, 0, .8);
-webkit-border-radius: 1rem;
-moz-border-radius: 1rem;
border-radius: 1rem;
color: #fff;
font-size: 1.4rem;
overflow: auto;
}
.g-help-table {
width: 100%;
margin-bottom: 2rem;
}

244
app/client/ui/App.ts Normal file
View File

@@ -0,0 +1,244 @@
import {ClientScope} from 'app/client/components/ClientScope';
import * as Clipboard from 'app/client/components/Clipboard';
import {Comm} from 'app/client/components/Comm';
import * as commandList from 'app/client/components/commandList';
import * as commands from 'app/client/components/commands';
import * as Login from 'app/client/components/Login';
import {unsavedChanges} from 'app/client/components/UnsavedChanges';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {isDesktop} from 'app/client/lib/browserInfo';
import * as koUtil from 'app/client/lib/koUtil';
import {reportError, TopAppModel, TopAppModelImpl} from 'app/client/models/AppModel';
import * as DocListModel from 'app/client/models/DocListModel';
import {setUpErrorHandling} from 'app/client/models/errors';
import {createAppUI} from 'app/client/ui/AppUI';
import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {BaseAPI} from 'app/common/BaseAPI';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {fetchFromHome} from 'app/common/urlUtils';
import {ISupportedFeatures} from 'app/common/UserConfig';
import {dom, DomElementMethod} from 'grainjs';
import * as ko from 'knockout';
// tslint:disable:no-console
const G = getBrowserGlobals('document', 'window');
type DocListModel = any;
type Login = any;
/**
* Main Grist App UI component.
*/
export class App extends DisposableWithEvents {
// Used by #newui code to avoid a dependency on commands.js, and by tests to issue commands.
public allCommands = commands.allCommands;
// Whether new UI should be produced by code that can do either old or new.
public readonly useNewUI: true = true;
public comm = this.autoDispose(Comm.create());
public clientScope: ClientScope;
public features: ko.Computed<ISupportedFeatures>;
public login: Login;
public topAppModel: TopAppModel; // Exposed because used by test/nbrowser/gristUtils.
public docListModel: DocListModel;
private _settings: ko.Observable<{features?: ISupportedFeatures}>;
// Track the version of the server we are communicating with, so that if it changes
// we can choose to refresh the client also.
private _serverVersion: string|null = null;
constructor(private _appDiv: HTMLElement) {
super();
commands.init(); // Initialize the 'commands' module using the default command list.
// Create the notifications box, and use it for reporting errors we can catch.
setUpErrorHandling(reportError, koUtil);
this.clientScope = this.autoDispose(ClientScope.create());
// Settings, initialized by initSettings event triggered by a server message.
this._settings = ko.observable({});
this.features = ko.computed(() => this._settings().features || {});
// Creates a Login instance which handles building the login form, login/signup, logout,
// and refreshing tokens. Uses .features, so instantiated after that.
this.login = this.autoDispose(Login.create(this));
if (isDesktop()) {
this.autoDispose(Clipboard.create(this));
}
this.topAppModel = this.autoDispose(TopAppModelImpl.create(null, G.window));
this.docListModel = this.autoDispose(DocListModel.create(this));
const isHelpPaneVisible = ko.observable(false);
G.document.querySelector('#grist-logo-wrapper').remove();
// Help pop-up pane
const helpDiv = this._appDiv.appendChild(
dom('div.g-help',
dom.show(isHelpPaneVisible),
dom('table.g-help-table',
dom('thead',
dom('tr',
dom('th', 'Key'),
dom('th', 'Description')
)
),
dom.forEach(commandList.groups, (group: any) => {
const cmds = group.commands.filter((cmd: any) => Boolean(cmd.desc && cmd.keys.length));
return cmds.length > 0 ?
dom('tbody',
dom('tr',
dom('td', {colspan: 2}, group.group)
),
dom.forEach(cmds, (cmd: any) =>
dom('tr',
dom('td', commands.allCommands[cmd.name].getKeysDom()),
dom('td', cmd.desc)
)
)
) : null;
})
)
)
);
this.onDispose(() => { dom.domDispose(helpDiv); helpDiv.remove(); });
this.autoDispose(commands.createGroup({
help() { G.window.open('help', '_blank').focus(); },
shortcuts() { isHelpPaneVisible(true); },
historyBack() { G.window.history.back(); },
historyForward() { G.window.history.forward(); },
}, this, true));
this.autoDispose(commands.createGroup({
cancel() { isHelpPaneVisible(false); },
help() { isHelpPaneVisible(false); },
}, this, isHelpPaneVisible));
this.listenTo(this.comm, 'clientConnect', (message) => {
console.log(`App clientConnect event: resetClientId ${message.resetClientId} version ${message.serverVersion}`);
this._settings(message.settings);
this.login.updateProfileFromServer(message.profile);
if (message.serverVersion === 'dead' || (this._serverVersion && this._serverVersion !== message.serverVersion)) {
console.log("Upgrading...");
// Server has upgraded. Upgrade client. TODO: be gentle and polite.
return this.reload();
}
this._serverVersion = message.serverVersion;
// If the clientId changed, then we need to reload any open documents. We'll simply reload the
// active component of the App regardless of what it is.
if (message.resetClientId) {
this.reloadPane();
}
});
this.listenTo(this.comm, 'connectState', (isConnected: boolean) => {
this.topAppModel.notifier.setConnectState(isConnected);
});
this.listenTo(this.comm, 'profileFetch', (message) => {
this.login.updateProfileFromServer(message.data);
});
this.listenTo(this.comm, 'clientLogout', () => this.login.onLogout());
this.listenTo(this.comm, 'docShutdown', () => {
console.log("Received docShutdown");
// Reload on next tick, to let other objects process 'docShutdown' before they get disposed.
setTimeout(() => this.reloadPane(), 0);
});
// When the document is unloaded, dispose the app, allowing it to do any needed
// cleanup (e.g. Document on disposal triggers closeDoc message to the server). It needs to be
// in 'beforeunload' rather than 'unload', since websocket is closed by the time of 'unload'.
G.window.addEventListener('beforeunload', (ev: BeforeUnloadEvent) => {
if (unsavedChanges.haveUnsavedChanges()) {
// Following https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
const msg = 'You have some unsaved changes';
ev.returnValue = msg;
ev.preventDefault();
return msg;
}
this.dispose();
});
this.comm.initialize(null);
// Add the cssRootVars class to enable the variables in cssVars.
attachCssRootVars(this.topAppModel.productFlavor);
this.autoDispose(createAppUI(this.topAppModel, this));
}
// We want to test erors from Selenium, but errors we can trigger using driver.executeScript()
// will be impossible for the application to report properly (they seem to be considered not of
// "same-origin"). So this silly callback is for tests to generate a fake error.
public testTriggerError(msg: string) { throw new Error(msg); }
public reloadPane() {
console.log("reloadPane");
this.topAppModel.reload();
}
// When called as a dom method, adds the "newui" class when ?newui=1 is set. For example
// dom('div.some-old-class', this.app.addNewUIClass(), ...)
// Then you may overridde newui styles in CSS by using selectors like:
// .some-old-class.newui { ... }
public addNewUIClass(): DomElementMethod {
return (elem) => { if (this.useNewUI) { elem.classList.add('newui'); } };
}
// Intended to be used by tests to enable specific features.
public enableFeature(featureName: keyof ISupportedFeatures, onOff: boolean) {
const features = this.features();
features[featureName] = onOff;
this._settings(Object.assign(this._settings(), { features }));
}
public getServerVersion() {
return this._serverVersion;
}
public reload() {
G.window.location.reload(true);
return true;
}
/**
* Returns the UntrustedContentOrigin use settings. Throws if not defined. The configured
* UntrustedContentOrign should not include the port, it is defined at runtime.
*/
public getUntrustedContentOrigin() {
if (G.window.isRunningUnderElectron) {
// when loaded within webviews it is safe to serve plugin's content from the same domain
return "";
}
const origin = G.window.gristConfig.pluginUrl;
if (!origin) {
throw new Error("Missing untrustedContentOrigin configuration");
}
if (origin.match(/:[0-9]+$/)) {
// Port number already specified, no need to add.
return origin;
}
return origin + ":" + G.window.location.port;
}
// Get the user profile for testing purposes
public async testGetProfile(): Promise<any> {
const resp = await fetchFromHome('/api/profile/user', {credentials: 'include'});
return resp.json();
}
public testNumPendingApiRequests(): number {
return BaseAPI.numPendingRequests();
}
}

View File

@@ -0,0 +1,60 @@
import {urlState} from 'app/client/models/gristUrlState';
import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes';
import {cssLeftPane} from 'app/client/ui/PagePanels';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import * as version from 'app/common/version';
import {BindableValue, dom, styled} from "grainjs";
export function appHeader(orgName: BindableValue<string>, productFlavor: ProductFlavor) {
const theme = getTheme(productFlavor);
return cssAppHeader(
urlState().setLinkUrl({}),
cssAppHeader.cls('-widelogo', theme.wideLogo || false),
// Show version when hovering over the application icon.
cssAppLogo({title: `Ver ${version.version} (${version.gitcommit})`}),
cssOrgName(dom.text(orgName)),
testId('dm-org'),
);
}
const cssAppHeader = styled('a', `
display: flex;
width: 100%;
height: 100%;
align-items: center;
&, &:hover, &:focus {
text-decoration: none;
outline: none;
color: ${colors.dark};
}
`);
const cssAppLogo = styled('div', `
flex: none;
height: 48px;
width: 48px;
background-image: var(--icon-GristLogo);
background-size: 22px 22px;
background-repeat: no-repeat;
background-position: center;
background-color: ${vars.logoBg};
.${cssAppHeader.className}-widelogo & {
width: 100%;
background-size: contain;
background-origin: content-box;
padding: 8px;
}
.${cssLeftPane.className}-open .${cssAppHeader.className}-widelogo & {
background-image: var(--icon-GristWideLogo, var(--icon-GristLogo));
}
`);
const cssOrgName = styled('div', `
padding: 0px 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.${cssAppHeader.className}-widelogo & {
display: none;
}
`);

151
app/client/ui/AppUI.ts Normal file
View File

@@ -0,0 +1,151 @@
import {domAsync} from 'app/client/lib/domAsync';
import {loadBillingPage} from 'app/client/lib/imports';
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
import {AppModel, TopAppModel} from 'app/client/models/AppModel';
import {DocPageModelImpl} from 'app/client/models/DocPageModel';
import {HomeModelImpl} from 'app/client/models/HomeModel';
import {App} from 'app/client/ui/App';
import {appHeader} from 'app/client/ui/AppHeader';
import {createDocMenu} from 'app/client/ui/DocMenu';
import {createForbiddenPage, createNotFoundPage, createOtherErrorPage} from 'app/client/ui/errorPages';
import {createHomeLeftPane} from 'app/client/ui/HomeLeftPane';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {pagePanels} from 'app/client/ui/PagePanels';
import {RightPanel} from 'app/client/ui/RightPanel';
import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar';
import {WelcomePage} from 'app/client/ui/WelcomePage';
import {testId} from 'app/client/ui2018/cssVars';
import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent, subscribe} from 'grainjs';
// When integrating into the old app, we might in theory switch between new-style and old-style
// content. This function allows disposing the created content by old-style code.
// TODO once #newui is gone, we don't need to worry about this being disposable.
// appObj is the App object from app/client/ui/App.ts
export function createAppUI(topAppModel: TopAppModel, appObj: App): IDisposable {
const content = dom.maybe(topAppModel.appObs, (appModel) => [
createMainPage(appModel, appObj),
buildSnackbarDom(appModel.notifier, appModel),
]);
dom.update(document.body, content, {
// Cancel out bootstrap's overrides.
style: 'font-family: inherit; font-size: inherit; line-height: inherit;'
});
function dispose() {
// Return value of dom.maybe() / dom.domComputed() is a pair of markers with a function that
// replaces content between them when an observable changes. It's uncommon to dispose the set
// with the markers, and grainjs doesn't provide a helper, but we can accomplish it by
// disposing the markers. They will automatically trigger the disposal of the included
// content. This avoids the need to wrap the contents in another layer of a dom element.
const [beginMarker, endMarker] = content;
replaceContent(beginMarker, endMarker, null);
dom.domDispose(beginMarker);
dom.domDispose(endMarker);
document.body.removeChild(beginMarker);
document.body.removeChild(endMarker);
}
return {dispose};
}
function createMainPage(appModel: AppModel, appObj: App) {
if (!appModel.currentOrg && appModel.pageType.get() !== 'welcome') {
const err = appModel.orgError;
if (err && err.status === 404) {
return createNotFoundPage(appModel);
} else if (err && (err.status === 401 || err.status === 403)) {
// Generally give access denied error.
// The exception is for document pages, where we want to allow access to documents
// shared publically without being shared specifically with the current user.
if (appModel.pageType.get() !== 'doc') {
return createForbiddenPage(appModel);
}
} else {
return createOtherErrorPage(appModel, err && err.error);
}
}
return dom.domComputed(appModel.pageType, (pageType) => {
if (pageType === 'home') {
return dom.create(pagePanelsHome, appModel);
} else if (pageType === 'billing') {
return domAsync(loadBillingPage().then(bp => dom.create(bp.BillingPage, appModel)));
} else if (pageType === 'welcome') {
return dom.create(WelcomePage, appModel);
} else {
return dom.create(pagePanelsDoc, appModel, appObj);
}
});
}
function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel) {
const pageModel = HomeModelImpl.create(owner, appModel);
const leftPanelOpen = Observable.create(owner, true);
// Set document title to strings like "Home - Grist" or "Org Name - Grist".
owner.autoDispose(subscribe(pageModel.currentPage, pageModel.currentWS, (use, page, ws) => {
const name = (
page === 'trash' ? 'Trash' :
ws ? ws.name : appModel.currentOrgName
);
document.title = `${name} - Grist`;
}));
return pagePanels({
leftPanel: {
panelWidth: Observable.create(owner, 240),
panelOpen: leftPanelOpen,
hideOpener: true,
header: appHeader(appModel.currentOrgName, appModel.topAppModel.productFlavor),
content: createHomeLeftPane(leftPanelOpen, pageModel),
},
headerMain: createTopBarHome(appModel),
contentMain: createDocMenu(pageModel),
});
}
function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) {
const pageModel = DocPageModelImpl.create(owner, appObj, appModel);
// To simplify manual inspection in the common case, keep the most recently created
// DocPageModel available as a global variable.
(window as any).gristDocPageModel = pageModel;
const leftPanelOpen = createSessionObs<boolean>(owner, "leftPanelOpen", true, isBoolean);
const rightPanelOpen = createSessionObs<boolean>(owner, "rightPanelOpen", false, isBoolean);
const leftPanelWidth = createSessionObs<number>(owner, "leftPanelWidth", 240, isNumber);
const rightPanelWidth = createSessionObs<number>(owner, "rightPanelWidth", 240, isNumber);
// The RightPanel component gets created only when an instance of GristDoc is set in pageModel.
// use.owner is a feature of grainjs to make the new RightPanel owned by the computed itself:
// each time the gristDoc observable changes (and triggers the callback), the previously-created
// instance of RightPanel will get disposed.
const rightPanel = Computed.create(owner, pageModel.gristDoc, (use, gristDoc) =>
gristDoc ? RightPanel.create(use.owner, gristDoc, rightPanelOpen) : null);
// Set document title to strings like "DocName - Grist"
owner.autoDispose(subscribe(pageModel.currentDocTitle, (use, docName) => {
document.title = `${docName} - Grist`;
}));
// Called after either panel is closed, opened, or resized.
function onResize() {
const gristDoc = pageModel.gristDoc.get();
if (gristDoc) { gristDoc.resizeEmitter.emit(); }
}
return pagePanels({
leftPanel: {
panelWidth: leftPanelWidth,
panelOpen: leftPanelOpen,
header: appHeader(appModel.currentOrgName || pageModel.currentOrgName, appModel.topAppModel.productFlavor),
content: pageModel.createLeftPane(leftPanelOpen),
},
rightPanel: {
panelWidth: rightPanelWidth,
panelOpen: rightPanelOpen,
header: dom.maybe(rightPanel, (panel) => panel.header),
content: dom.maybe(rightPanel, (panel) => panel.content),
},
headerMain: dom.create(createTopBarDoc, appModel, pageModel, appObj.allCommands),
contentMain: dom.maybe(pageModel.gristDoc, (gristDoc) => gristDoc.buildDom()),
onResize,
testId,
});
}

View File

@@ -0,0 +1,459 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {reportError} from 'app/client/models/AppModel';
import * as css from 'app/client/ui/BillingPageCss';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {formSelect} from 'app/client/ui2018/menus';
import {IBillingAddress, IBillingCard, IBillingOrgSettings} from 'app/common/BillingAPI';
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
import * as roles from 'app/common/roles';
import {Organization} from 'app/common/UserAPI';
import {Disposable, dom, DomArg, IDisposableOwnerT, makeTestId, Observable} from 'grainjs';
const G = getBrowserGlobals('Stripe', 'window');
const testId = makeTestId('test-bp-');
// TODO: When countries other than the US are supported, the state entry must not be limited
// by a dropdown.
const states = [
'AK', 'AL', 'AR', 'AS', 'AZ', 'CA', 'CO', 'CT', 'DC', 'DE', 'FL', 'FM', 'GA', 'GU', 'HI',
'IA', 'ID', 'IL', 'IN', 'KS', 'KY', 'LA', 'MA', 'MD', 'ME', 'MH', 'MI', 'MN', 'MO', 'MP',
'MS', 'MT', 'NC', 'ND', 'NE', 'NH', 'NJ', 'NM', 'NV', 'NY', 'OH', 'OK', 'OR', 'PA', 'PR',
'PW', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VA', 'VI', 'VT', 'WA', 'WI', 'WV', 'WY'
];
export interface IFormData {
address?: IBillingAddress;
card?: IBillingCard;
token?: string;
settings?: IBillingOrgSettings;
}
// Optional autofill vales to pass in to the BillingForm constructor.
interface IAutofill {
address?: Partial<IBillingAddress>;
settings?: Partial<IBillingOrgSettings>;
// Note that the card name is the only value that may be initialized, since the other card
// information is sensitive.
card?: Partial<IBillingCard>;
}
// An object containing a function to check the validity of its observable value.
// The get function should return the observable value or throw an error if it is invalid.
interface IValidated<T> {
value: Observable<T>;
checkValidity: (value: T) => void|Promise<void>; // Should throw with message on invalid values.
isInvalid: Observable<boolean>;
get: () => T|Promise<T>;
}
export class BillingForm extends Disposable {
private readonly _address: BillingAddressForm|null;
private readonly _payment: BillingPaymentForm|null;
private readonly _settings: BillingSettingsForm|null;
constructor(
org: Organization|null,
isDomainAvailable: (domain: string) => Promise<boolean>,
options: {payment: boolean, address: boolean, settings: boolean, domain: boolean},
autofill: IAutofill = {}
) {
super();
// Get the number of forms - if more than one is present subheaders should be visible.
const count = [options.settings, options.address, options.payment]
.reduce((acc, x) => acc + (x ? 1 : 0), 0);
// Org settings form.
this._settings = options.settings ? new BillingSettingsForm(org, isDomainAvailable, {
showHeader: count > 1,
showDomain: options.domain,
autofill: autofill.settings
}) : null;
// Address form.
this._address = options.address ? new BillingAddressForm({
showHeader: count > 1,
autofill: autofill.address
}) : null;
// Payment form.
this._payment = options.payment ? new BillingPaymentForm({
showHeader: count > 1,
autofill: autofill.card
}) : null;
}
public buildDom() {
return [
this._settings ? this._settings.buildDom() : null,
this._address ? this._address.buildDom() : null,
this._payment ? this._payment.buildDom() : null
];
}
// Note that this will throw if any values are invalid.
public async getFormData(): Promise<IFormData> {
const settings = this._settings ? await this._settings.getSettings() : undefined;
const address = this._address ? await this._address.getAddress() : undefined;
const cardInfo = this._payment ? await this._payment.getCardAndToken() : undefined;
return {
settings,
address,
token: cardInfo ? cardInfo.token : undefined,
card: cardInfo ? cardInfo.card : undefined
};
}
}
// Abstract class which includes helper functions for creating a form whose values are verified.
abstract class BillingSubForm extends Disposable {
protected readonly formError: Observable<string> = Observable.create(this, '');
constructor() {
super();
}
// Creates an input whose value is validated on blur. Input text turns red and the validation
// error is shown on negative validation.
protected billingInput(validated: IValidated<string>, ...args: Array<DomArg<any>>) {
return css.billingInput(validated.value, {onInput: true},
css.billingInput.cls('-invalid', validated.isInvalid),
dom.on('blur', () => this._onBlur(validated)),
...args
);
}
protected async _onBlur(validated: IValidated<string>): Promise<void> {
// Do not show empty input errors on blur.
if (validated.value.get().length === 0) { return; }
try {
await validated.get();
this.formError.set('');
} catch (e) {
this.formError.set(e.message);
}
}
}
/**
* Creates the payment card entry form using Stripe Elements.
*/
class BillingPaymentForm extends BillingSubForm {
private readonly _stripe: any;
private readonly _elements: any;
// Stripe Element fields. Set when the elements are mounted to the dom.
private readonly _numberElement: Observable<any> = Observable.create(this, null);
private readonly _expiryElement: Observable<any> = Observable.create(this, null);
private readonly _cvcElement: Observable<any> = Observable.create(this, null);
private readonly _name: IValidated<string> = createValidated(this, 'Name');
constructor(private readonly _options: {
showHeader: boolean;
autofill?: Partial<IBillingCard>;
}) {
super();
const autofill = this._options.autofill;
const stripeAPIKey = (G.window as any).gristConfig.stripeAPIKey;
try {
this._stripe = G.Stripe(stripeAPIKey);
this._elements = this._stripe.elements();
} catch (err) {
reportError(err);
}
if (autofill) {
this._name.value.set(autofill.name || '');
}
}
public buildDom() {
return this._stripe ? css.paymentBlock(
this._options.showHeader ? css.paymentSubHeader('Payment Method') : null,
css.paymentRow(
css.paymentField(
css.paymentLabel('Cardholder Name'),
this.billingInput(this._name, testId('card-name')),
)
),
css.paymentRow(
css.paymentField(
css.paymentLabel({for: 'number-element'}, 'Card Number'),
css.stripeInput({id: 'number-element'}), // A Stripe Element will be inserted here.
testId('card-number')
)
),
css.paymentRow(
css.paymentField(
css.paymentLabel({for: 'expiry-element'}, 'Expiry Date'),
css.stripeInput({id: 'expiry-element'}), // A Stripe Element will be inserted here.
testId('card-expiry')
),
css.paymentSpacer(),
css.paymentField(
css.paymentLabel({for: 'cvc-element'}, 'CVC / CVV Code'),
css.stripeInput({id: 'cvc-element'}), // A Stripe Element will be inserted here.
testId('card-cvc')
)
),
css.inputError(
dom.text(this.formError),
testId('payment-form-error')
),
() => { setTimeout(() => this._mountStripeUI(), 0); }
) : null;
}
public async getCardAndToken(): Promise<{card: IBillingCard, token: string}> {
// Note that we call createToken using only the card number element as the first argument
// in accordance with the Stripe API:
//
// "If applicable, the Element pulls data from other Elements youve created on the same
// instance of elements to tokenize—you only need to supply one element as the parameter."
//
// Source: https://stripe.com/docs/stripe-js/reference#stripe-create-token
try {
const result = await this._stripe.createToken(this._numberElement.get(), {name: await this._name.get()});
if (result.error) { throw new Error(result.error.message); }
return {
card: result.token.card,
token: result.token.id
};
} catch (e) {
this.formError.set(e.message);
throw e;
}
}
private _mountStripeUI() {
// Mount Stripe Element fields.
this._mountStripeElement(this._numberElement, 'cardNumber', 'number-element');
this._mountStripeElement(this._expiryElement, 'cardExpiry', 'expiry-element');
this._mountStripeElement(this._cvcElement, 'cardCvc', 'cvc-element');
}
private _mountStripeElement(elemObs: Observable<any>, stripeName: string, elementId: string): void {
// For details on applying custom styles to Stripe Elements, see:
// https://stripe.com/docs/stripe-js/reference#element-options
const classes = {base: css.stripeInput.className};
const style = {
base: {
'::placeholder': {
color: colors.slate.value
},
'fontSize': vars.mediumFontSize.value,
'fontFamily': vars.fontFamily.value
}
};
if (!elemObs.get()) {
const stripeInst = this._elements.create(stripeName, {classes, style});
stripeInst.addEventListener('change', (event: any) => {
if (event.error) { this.formError.set(event.error.message); }
});
elemObs.set(stripeInst);
}
elemObs.get().mount(`#${elementId}`);
}
}
/**
* Creates the company address entry form. Used by BillingPaymentForm when billing address is needed.
*/
class BillingAddressForm extends BillingSubForm {
private readonly _address1: IValidated<string> = createValidated(this, 'Address');
private readonly _address2: IValidated<string> = createValidated(this, 'Suite/unit', () => undefined);
private readonly _city: IValidated<string> = createValidated(this, 'City');
private readonly _state: IValidated<string> = createValidated(this, 'State');
private readonly _postal: IValidated<string> = createValidated(this, 'Zip code');
constructor(private readonly _options: {
showHeader: boolean;
autofill?: Partial<IBillingAddress>;
}) {
super();
const autofill = this._options.autofill;
if (autofill) {
this._address1.value.set(autofill.line1 || '');
this._address2.value.set(autofill.line2 || '');
this._city.value.set(autofill.city || '');
this._state.value.set(autofill.state || '');
this._postal.value.set(autofill.postal_code || '');
}
}
public buildDom() {
return css.paymentBlock(
this._options.showHeader ? css.paymentSubHeader('Company Address') : null,
css.paymentRow(
css.paymentField(
css.paymentLabel('Street Address'),
this.billingInput(this._address1, testId('address-street'))
)
),
css.paymentRow(
css.paymentField(
css.paymentLabel('Suite / Unit'),
this.billingInput(this._address2, testId('address-suite'))
)
),
css.paymentRow(
css.paymentField(
css.paymentLabel('City'),
this.billingInput(this._city, testId('address-city'))
),
css.paymentSpacer(),
css.paymentField({style: 'flex: 0.5 1 0;'},
css.paymentLabel('State'),
formSelect(this._state.value, states),
testId('address-state')
)
),
css.paymentRow(
css.paymentField(
css.paymentLabel('Zip Code'),
this.billingInput(this._postal, testId('address-zip'))
)
),
css.inputError(
dom.text(this.formError),
testId('address-form-error')
)
);
}
// Throws if any value is invalid. Returns a customer address as accepted by the customer
// object in stripe.
// For reference: https://stripe.com/docs/api/customers/object#customer_object-address
public async getAddress(): Promise<IBillingAddress|undefined> {
try {
return {
line1: await this._address1.get(),
line2: await this._address2.get(),
city: await this._city.get(),
state: await this._state.get(),
postal_code: await this._postal.get(),
country: 'US' // TODO: Support more countries.
};
} catch (e) {
this.formError.set(e.message);
throw e;
}
}
}
/**
* Creates the billing settings form, including the org name and the org subdomain values.
*/
class BillingSettingsForm extends BillingSubForm {
private readonly _name: IValidated<string> = createValidated(this, 'Company name');
// Only verify the domain if it is shown.
private readonly _domain: IValidated<string> = createValidated(this, 'URL',
this._options.showDomain ? d => this._verifyDomain(d) : () => undefined);
constructor(
private readonly _org: Organization|null,
private readonly _isDomainAvailable: (domain: string) => Promise<boolean>,
private readonly _options: {
showHeader: boolean;
showDomain: boolean;
autofill?: Partial<IBillingOrgSettings>;
}
) {
super();
const autofill = this._options.autofill;
if (autofill) {
this._name.value.set(autofill.name || '');
this._domain.value.set(autofill.domain || '');
}
}
public buildDom() {
const noEditAccess = Boolean(this._org && !roles.canEdit(this._org.access));
return css.paymentBlock(
this._options.showHeader ? css.paymentSubHeader('Team Site') : null,
css.paymentRow(
css.paymentField(
css.paymentLabel('Company Name'),
this.billingInput(this._name,
dom.boolAttr('disabled', () => noEditAccess),
testId('settings-name')
),
noEditAccess ? css.paymentFieldInfo('Organization edit access is required',
testId('settings-name-info')
) : null
)
),
this._options.showDomain ? css.paymentRow(
css.paymentField(
css.paymentLabel('URL'),
this.billingInput(this._domain,
dom.boolAttr('disabled', () => noEditAccess),
testId('settings-domain')
),
// Note that we already do not allow editing the domain after it is initially set
// anyway, this is just here for consistency.
noEditAccess ? css.paymentFieldInfo('Organization edit access is required',
testId('settings-domain-info')
) : null
),
css.paymentField({style: 'flex: 0 1 0;'},
css.inputHintLabel('.getgrist.com')
)
) : null,
css.inputError(
dom.text(this.formError),
testId('settings-form-error')
)
);
}
// Throws if any value is invalid.
public async getSettings(): Promise<IBillingOrgSettings|undefined> {
try {
return {
name: await this._name.get(),
domain: await this._domain.get()
};
} catch (e) {
this.formError.set(e.message);
throw e;
}
}
// Throws if the entered domain contains any invalid characters or is already taken.
private async _verifyDomain(domain: string): Promise<void> {
checkSubdomainValidity(domain);
const isAvailable = await this._isDomainAvailable(domain);
if (!isAvailable) { throw new Error('Domain is already taken.'); }
}
}
// Creates a validated object, which includes an observable and a function to check
// if the current observable value is valid.
function createValidated(
owner: IDisposableOwnerT<any>,
propertyName: string,
validationFn?: (value: string) => void
): IValidated<string> {
const checkValidity = validationFn || ((_value: string) => {
if (!_value) { throw new Error(`${propertyName} is required.`); }
});
const value = Observable.create(owner, '');
const isInvalid = Observable.create<boolean>(owner, false);
owner.autoDispose(value.addListener(() => { isInvalid.set(false); }));
return {
value,
isInvalid,
checkValidity,
get: async () => {
const _value = value.get();
try {
await checkValidity(_value);
} catch (e) {
isInvalid.set(true);
throw e;
}
isInvalid.set(false);
return _value;
}
};
}

View File

@@ -0,0 +1,783 @@
import {beaconOpenMessage} from 'app/client/lib/helpScout';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {BillingModel, BillingModelImpl, ISubscriptionModel} from 'app/client/models/BillingModel';
import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
import {appHeader} from 'app/client/ui/AppHeader';
import {BillingForm, IFormData} from 'app/client/ui/BillingForm';
import * as css from 'app/client/ui/BillingPageCss';
import {BillingPlanManagers} from 'app/client/ui/BillingPlanManagers';
import {createForbiddenPage} from 'app/client/ui/errorPages';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {confirmModal} from 'app/client/ui2018/modals';
import {BillingSubPage, BillingTask, IBillingAddress, IBillingCard, IBillingPlan} from 'app/common/BillingAPI';
import {capitalize} from 'app/common/gutil';
import {Organization} from 'app/common/UserAPI';
import {Disposable, dom, IAttrObj, IDomArgs, makeTestId, Observable} from 'grainjs';
const testId = makeTestId('test-bp-');
const taskActions = {
signUp: 'Sign Up',
updatePlan: 'Update Plan',
addCard: 'Add Payment Method',
updateCard: 'Update Payment Method',
updateAddress: 'Update Address'
};
/**
* Creates the billing page where a user can manager their subscription and payment card.
*/
export class BillingPage extends Disposable {
private readonly _model: BillingModel = new BillingModelImpl(this._appModel);
private _form: BillingForm|undefined = undefined;
private _formData: IFormData = {};
// Indicates whether the payment page is showing the confirmation page or the data entry form.
// If _showConfirmation includes the entered form data, the confirmation page is shown.
// A null value indicates the data entry form is being shown.
private readonly _showConfirmPage: Observable<boolean> = Observable.create(this, false);
// Indicates that the payment page submit button has been clicked to prevent repeat requests.
private readonly _isSubmitting: Observable<boolean> = Observable.create(this, false);
constructor(private _appModel: AppModel) {
super();
}
public buildDom() {
return dom.domComputed(this._model.isUnauthorized, (isUnauthorized) => {
if (isUnauthorized) {
return createForbiddenPage(this._appModel,
'Only billing plan managers may view billing account information. Plan managers may ' +
'be added in the billing summary by existing plan managers.');
} else {
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen,
hideOpener: true,
header: appHeader(this._appModel.currentOrgName, this._appModel.topAppModel.productFlavor),
content: leftPanelBasic(this._appModel, panelOpen),
},
headerMain: this._createTopBarBilling(),
contentMain: this.buildCurrentPageDom()
});
}
});
}
/**
* Builds the contentMain dom for the current billing page.
*/
public buildCurrentPageDom() {
return css.billingWrapper(
dom.domComputed(this._model.currentSubpage, (subpage) => {
if (!subpage) {
return this.buildSummaryPage();
} else if (subpage === 'payment') {
return this.buildPaymentPage();
} else if (subpage === 'plans') {
return this.buildPlansPage();
}
})
);
}
public buildSummaryPage() {
const org = this._appModel.currentOrg;
// Fetch plan and card data.
this._model.fetchData(true).catch(reportError);
return css.billingPage(
css.cardBlock(
css.billingHeader('Account'),
dom.domComputed(this._model.subscription, sub => [
this._buildDomainSummary(org && org.domain),
this._buildCompanySummary(org && org.name, sub ? (sub.address || {}) : null),
dom.domComputed(this._model.card, card =>
this._buildCardSummary(card, !sub, [
css.billingTextBtn(css.billingIcon('Settings'), 'Change',
urlState().setLinkUrl({
billing: 'payment',
params: { billingTask: 'updateCard' }
}),
testId('update-card')
),
css.billingTextBtn(css.billingIcon('Remove'), 'Remove',
dom.on('click', () => this._showRemoveCardModal()),
testId('remove-card')
)
])
)
]),
// If this is not a personal org, create the plan manager list dom.
org && !org.owner ? dom.frag(
css.billingHeader('Plan Managers', {style: 'margin: 32px 0 16px 0;'}),
css.billingHintText(
'You may add additional billing contacts (for example, your accounting department). ' +
'All billing-related emails will be sent to this list of contacts.'
),
dom.create(BillingPlanManagers, this._model, org, this._appModel.currentValidUser)
) : null
),
css.summaryBlock(
css.billingHeader('Billing Summary'),
dom.maybe(this._model.subscription, sub => {
const plans = this._model.plans.get();
const moneyPlan = sub.upcomingPlan || sub.activePlan;
const changingPlan = sub.upcomingPlan && sub.upcomingPlan.amount > 0;
const cancellingPlan = sub.upcomingPlan && sub.upcomingPlan.amount === 0;
const validPlan = sub.isValidPlan;
const planId = validPlan ? sub.activePlan.id : sub.lastPlanId;
return [
css.summaryFeatures(
validPlan ? [
makeSummaryFeature(['You are subscribed to the ', sub.activePlan.nickname, ' plan']),
] : [
makeSummaryFeature(['This team site is not in good standing'],
{isBad: true}),
],
// If the plan is changing, include the date the current plan ends
// and the plan that will be in effect afterwards.
changingPlan ? [
makeSummaryFeature(['Your current plan ends on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature(['On this date, you will be subscribed to the ',
sub.upcomingPlan!.nickname, ' plan'])
] : null,
cancellingPlan ? [
makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature(['On this date, your team site will become ', 'read-only',
' for one month, then removed'])
] : null,
moneyPlan.amount ? [
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
` member${sub.userCount > 1 ? 's' : ''}`]),
makeSummaryFeature([`Your ${moneyPlan.interval}ly subtotal is `,
getPriceString(moneyPlan.amount * sub.userCount)]),
sub.discountName ? makeSummaryFeature([`You receive the `, sub.discountName]) : null,
// When on a free trial, Stripe reports trialEnd time, but it seems to always
// match periodEnd for a trialing subscription, so we just use that.
sub.isInTrial ? makeSummaryFeature(['Your free trial ends on ', dateFmtFull(sub.periodEnd)]) : null,
makeSummaryFeature([`Your next invoice is `, getPriceString(sub.nextTotal),
' on ', dateFmt(sub.periodEnd)]),
] : null,
getSubscriptionProblem(sub),
testId('summary')
),
(sub.lastInvoiceUrl ?
dom('div',
css.billingTextBtn({ style: 'margin: 10px 0;' },
cssBreadcrumbsLink(
css.billingIcon('Page'), 'View last invoice',
{ href: sub.lastInvoiceUrl, target: '_blank' },
testId('invoice-link')
)
)
) :
null
),
(moneyPlan.amount === 0 && planId) ? css.billingTextBtn({ style: 'margin: 10px 0;' },
// If the plan was cancellled, make the text indicate that changing the plan will
// renew the subscription (abort the cancellation).
css.billingIcon('Settings'), 'Renew subscription',
urlState().setLinkUrl({
billing: 'payment',
params: {
billingTask: 'updatePlan',
billingPlan: planId
}
}),
testId('update-plan')
) : null,
// Do not show the cancel subscription option if it was already cancelled.
plans.length > 0 && moneyPlan.amount > 0 ? css.billingTextBtn({ style: 'margin: 10px 0;' },
css.billingIcon('Settings'), 'Cancel subscription',
urlState().setLinkUrl({
billing: 'payment',
params: {
billingTask: 'updatePlan',
billingPlan: plans[0].id
}
}),
testId('cancel-subscription')
) : null
];
})
)
);
}
public buildPlansPage() {
// Fetch plan and card data if not already present.
this._model.fetchData().catch(reportError);
return css.plansPage(
css.billingHeader('Choose a plan'),
css.billingText('Give your team the features they need to succeed'),
this._buildPlanCards()
);
}
public buildPaymentPage() {
const org = this._appModel.currentOrg;
const isSharedOrg = org && org.billingAccount && !org.billingAccount.individual;
// Fetch plan and card data if not already present.
this._model.fetchData().catch(this._model.reportBlockingError);
return css.billingPage(
dom.maybe(this._model.currentTask, task => {
const pageText = taskActions[task];
return [
css.cardBlock(
css.billingHeader(pageText),
dom.domComputed((use) => {
const err = use(this._model.error);
if (err) {
return css.errorBox(err, dom('br'), dom('br'), reportLink(this._appModel, "Report problem"));
}
const sub = use(this._model.subscription);
const card = use(this._model.card);
const newPlan = use(this._model.signupPlan);
if (newPlan && newPlan.amount === 0) {
// If the selected plan is free, the user is cancelling their subscription.
return [
css.paymentBlock(
'On the subscription end date, your team site will remain available in ' +
'read-only mode for one month.',
),
css.paymentBlock(
'After the one month grace period, your team site will be removed along ' +
'with all documents inside.'
),
css.paymentBlock('Are you sure you would like to cancel the subscription?'),
this._buildPaymentBtns('Cancel Subscription')
];
} else if (sub && card && newPlan && sub.activePlan && newPlan.amount <= sub.activePlan.amount) {
// If the user already has a card entered and the plan costs less money than
// the current plan, show the card summary only (no payment required yet)
return [
isSharedOrg ? this._buildDomainSummary(org && org.domain) : null,
this._buildCardSummary(card, !sub, [
css.billingTextBtn(css.billingIcon('Settings'), 'Update Card',
// Clear the fetched card to display the card input form.
dom.on('click', () => this._model.card.set(null)),
testId('update-card')
)
]),
this._buildPaymentBtns(pageText)
];
} else {
return dom.domComputed(this._showConfirmPage, (showConfirm) => {
if (showConfirm) {
return [
this._buildPaymentConfirmation(this._formData),
this._buildPaymentBtns(pageText)
];
} else if (!sub) {
return css.spinnerBox(loadingSpinner());
} else if (!newPlan && (task === 'signUp' || task === 'updatePlan')) {
return css.errorBox('Unknown plan selected. Please check the URL, or ',
reportLink(this._appModel, 'report this issue'), '.');
} else {
return this._buildBillingForm(org, sub.address, task);
}
});
}
})
),
css.summaryBlock(
css.billingHeader('Summary'),
css.summaryFeatures(
this._buildPaymentSummary(task),
testId('summary')
)
)
];
})
);
}
private _buildBillingForm(org: Organization|null, address: IBillingAddress|null, task: BillingTask) {
const isSharedOrg = org && org.billingAccount && !org.billingAccount.individual;
const currentSettings = isSharedOrg ? { name: org!.name } : this._formData.settings;
const currentAddress = address || this._formData.address;
const pageText = taskActions[task];
// If there is an immediate charge required, require re-entering the card info.
// Show all forms on sign up.
this._form = new BillingForm(org, (...args) => this._model.isDomainAvailable(...args), {
payment: task !== 'updateAddress',
address: task === 'signUp' || task === 'updateAddress',
settings: task === 'signUp' || task === 'updateAddress',
domain: task === 'signUp'
}, { address: currentAddress, settings: currentSettings, card: this._formData.card });
return dom('div',
dom.onDispose(() => {
if (this._form) {
this._form.dispose();
this._form = undefined;
}
}),
isSharedOrg ? this._buildDomainSummary(org && org.domain) : null,
this._form.buildDom(),
this._buildPaymentBtns(pageText)
);
}
private _buildPaymentConfirmation(formData: IFormData) {
const settings = formData.settings || null;
return [
this._buildDomainSummary(settings && settings.domain),
this._buildCompanySummary(settings && settings.name, formData.address || null, false),
this._buildCardSummary(formData.card || null)
];
}
private _createTopBarBilling() {
const org = this._appModel.currentOrg;
return dom.frag(
cssBreadcrumbs({ style: 'margin-left: 16px;' },
cssBreadcrumbsLink(
urlState().setLinkUrl({}),
'Home',
testId('home')
),
separator(' / '),
dom.domComputed(this._model.currentSubpage, (subpage) => {
if (subpage) {
return [
// Prevent navigating to the summary page if these pages are not associated with an org.
org && !org.owner ? cssBreadcrumbsLink(
urlState().setLinkUrl({ billing: 'billing' }),
'Billing',
testId('billing')
) : dom('span', 'Billing'),
separator(' / '),
dom('span', capitalize(subpage))
];
} else {
return dom('span', 'Billing');
}
})
),
createTopBarHome(this._appModel),
);
}
private _buildPlanCards() {
const org = this._appModel.currentOrg;
const isSharedOrg = org && org.billingAccount && !org.billingAccount.individual;
const attr = {style: 'margin: 12px 0 12px 0;'}; // Feature attributes
return css.plansContainer(
dom.maybe(this._model.plans, (plans) => {
// Do not show the free plan inside the paid org plan options.
return plans.filter(plan => !isSharedOrg || plan.amount > 0).map(plan => {
const priceStr = plan.amount === 0 ? 'Free' : getPriceString(plan.amount);
const meta = plan.metadata;
const maxDocs = meta.maxDocs ? `up to ${meta.maxDocs}` : `unlimited`;
const maxUsers = meta.maxUsersPerDoc ?
`Share with ${meta.maxUsersPerDoc} collaborators per doc` :
`Share and collaborate with any number of team members`;
return css.planBox(
css.billingHeader(priceStr, { style: `display: inline-block;` }),
css.planInterval(plan.amount === 0 ? '' : `/ user / ${plan.interval}`),
css.billingSubHeader(plan.nickname),
makeSummaryFeature(`Create ${maxDocs} docs`, {attr}),
makeSummaryFeature(maxUsers, {attr}),
makeSummaryFeature('Workspaces to organize docs and users', {
isMissingFeature: !meta.workspaces,
attr
}),
makeSummaryFeature(`Access to support`, {
isMissingFeature: !meta.supportAvailable,
attr
}),
makeSummaryFeature(`Unthrottled API access`, {
isMissingFeature: !meta.unthrottledApi,
attr
}),
makeSummaryFeature(`Custom Grist subdomain`, {
isMissingFeature: !meta.customSubdomain,
attr
}),
plan.trial_period_days ? makeSummaryFeature(['', `${plan.trial_period_days} day free trial`],
{attr}) : css.summarySpacer(),
// Add the upgrade buttons once the user plan information has loaded
dom.domComputed(this._model.subscription, sub => {
const activePrice = sub ? sub.activePlan.amount : 0;
const selectedPlan = sub && (sub.upcomingPlan || sub.activePlan);
// URL state for the payment page to update the plan or sign up.
const payUrlState = {
billing: 'payment' as BillingSubPage,
params: {
billingTask: activePrice > 0 ? 'updatePlan' : 'signUp' as BillingTask,
billingPlan: plan.id
}
};
if (!this._appModel.currentValidUser && plan.amount === 0) {
// If the user is not logged in and selects the free plan, provide a login link that
// redirects back to the free org.
return css.upgradeBtn('Sign up',
{href: getLoginUrl(getMainOrgUrl())},
testId('plan-btn')
);
} else if ((!selectedPlan && plan.amount === 0) || (selectedPlan && plan.id === selectedPlan.id)) {
return css.currentBtn('Current plan',
testId('plan-btn')
);
} else {
// Sign up / update plan.
// Show 'Create' if this is not a paid org to indicate that an org will be created.
const upgradeText = isSharedOrg ? 'Upgrade' : 'Create team site';
return css.upgradeBtn(plan.amount > activePrice ? upgradeText : 'Select',
urlState().setLinkUrl(payUrlState),
testId('plan-btn')
);
}
}),
testId('plan')
);
});
})
);
}
private _buildDomainSummary(domain: string|null) {
return css.summaryItem(
css.summaryHeader(
css.billingBoldText('Billing Info'),
),
domain ? [
css.summaryRow(
css.billingText(`Your team site URL: `,
dom('span', {style: 'font-weight: bold'}, domain),
`.getgrist.com`,
testId('org-domain')
)
)
] : null
);
}
private _buildCompanySummary(orgName: string|null, address: Partial<IBillingAddress>|null, showEdit: boolean = true) {
return css.summaryItem({style: 'min-height: 118px;'},
css.summaryHeader(
css.billingBoldText(`Company Name & Address`),
showEdit ? css.billingTextBtn(css.billingIcon('Settings'), 'Change',
urlState().setLinkUrl({
billing: 'payment',
params: { billingTask: 'updateAddress' }
}),
testId('update-address')
) : null
),
orgName && css.summaryRow(
css.billingText(orgName,
testId('org-name')
)
),
address ? [
css.summaryRow(
css.billingText(address.line1,
testId('company-address-1')
)
),
address.line2 ? css.summaryRow(
css.billingText(address.line2,
testId('company-address-2')
)
) : null,
address.city && address.state ? css.summaryRow(
css.billingText(`${address.city}, ${address.state} ${address.postal_code || ''}`,
testId('company-address-3')
)
) : null,
] : 'Fetching address...'
);
}
private _buildCardSummary(card: IBillingCard|null, fetching?: boolean, btns?: IDomArgs) {
if (fetching) {
// If the subscription data has not yet been fetched.
return css.summaryItem({style: 'min-height: 102px;'},
css.summaryHeader(
css.billingBoldText(`Payment Card`),
),
'Fetching card preview...'
);
} else if (card) {
// There is a card attached to the account.
const brand = card.brand ? `${card.brand.toUpperCase()} ` : '';
return css.summaryItem(
css.summaryHeader(
css.billingBoldText(
// The header indicates the card type (Credit/Debit/Prepaid/Unknown)
`${capitalize(card.funding || 'payment')} Card`,
testId('card-funding')
),
btns
),
css.billingText(card.name,
testId('card-name')
),
css.billingText(`${brand}**** **** **** ${card.last4}`,
testId('card-preview')
)
);
} else {
return css.summaryItem(
css.summaryHeader(
css.billingBoldText(`Payment Card`),
css.billingTextBtn(css.billingIcon('Settings'), 'Add',
urlState().setLinkUrl({
billing: 'payment',
params: { billingTask: 'addCard' }
}),
testId('add-card')
),
),
// TODO: Warn user when a payment method will be required and decide
// what happens if it is not added.
css.billingText('Your account has no payment method', testId('card-preview'))
);
}
}
// Builds the list of summary items indicating why the user is being prompted with
// the payment method page and what will happen when the card information is submitted.
private _buildPaymentSummary(task: BillingTask) {
if (task === 'signUp' || task === 'updatePlan') {
return dom.maybe(this._model.signupPlan, _plan => this._buildPlanPaymentSummary(_plan, task));
} else if (task === 'addCard' || task === 'updateCard') {
return makeSummaryFeature('You are updating the default payment method');
} else if (task === 'updateAddress') {
return makeSummaryFeature('You are updating the company name and address');
} else {
return null;
}
}
private _buildPlanPaymentSummary(plan: IBillingPlan, task: BillingTask) {
return dom.domComputed(this._model.subscription, sub => {
let stubSub: ISubscriptionModel|undefined;
if (sub && !sub.periodEnd) {
// Stripe subscriptions have a defined end.
// If the periodEnd is unknown, that means there is as yet no stripe subscription,
// and the user is signing up or renewing an expired subscription as opposed to upgrading.
stubSub = sub;
sub = undefined;
}
if (plan.amount === 0) {
// User is cancelling their subscription.
return [
makeSummaryFeature(['You are cancelling the subscription']),
sub ? makeSummaryFeature(['Your subscription will end on ', dateFmt(sub.periodEnd)]) : null
];
} else if (sub && sub.activePlan && plan.amount < sub.activePlan.amount) {
// User is downgrading their plan.
return [
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
makeSummaryFeature(['Your plan will change on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature('You will not be charged until the plan changes'),
makeSummaryFeature([`Your new ${plan.interval}ly subtotal is `,
getPriceString(plan.amount * sub.userCount)])
];
} else if (!sub) {
const planPriceStr = getPriceString(plan.amount);
const subtotal = plan.amount * (stubSub?.userCount || 1);
const subTotalPriceStr = getPriceString(subtotal);
const totalPriceStr = getPriceString(subtotal, stubSub?.taxRate || 0);
// This is a new subscription, either a fresh sign ups, or renewal after cancellation.
// The server will allow the trial period only for fresh sign ups.
const trialSummary = (plan.trial_period_days && task === 'signUp') ?
makeSummaryFeature([`The plan is free for `, `${plan.trial_period_days} days`]) : null;
return [
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
dom.domComputed(this._showConfirmPage, confirmPage => {
if (confirmPage) {
return [
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]),
// Note that on sign up, the number of users in the new org is always one.
trialSummary || makeSummaryFeature(['You will be charged ', totalPriceStr, ' to start'])
];
} else {
return [
// Note that on sign up, the number of users in the new org is always one.
makeSummaryFeature([`Your price is `, planPriceStr, ` per user per ${plan.interval}`]),
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]),
trialSummary
];
}
})
];
} else if (plan.amount > sub.activePlan.amount) {
const refund = sub.valueRemaining || 0;
// User is upgrading their plan.
return [
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `,
getPriceString(plan.amount * sub.userCount)]),
makeSummaryFeature(['You will be charged ',
getPriceString((plan.amount * sub.userCount) - refund, sub.taxRate), ' to start']),
refund > 0 ? makeSummaryFeature(['Your charge is prorated based on the remaining plan time']) : null,
];
} else {
// User is cancelling their decision to downgrade their plan.
return [
makeSummaryFeature(['You will remain subscribed to the ', plan.nickname, ' plan']),
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `,
getPriceString(plan.amount * sub.userCount)]),
makeSummaryFeature(['Your next payment will be on ', dateFmt(sub.periodEnd)])
];
}
});
}
private _buildPaymentBtns(submitText: string) {
const task = this._model.currentTask.get();
this._isSubmitting.set(false); // Reset status on build.
return css.paymentBtnRow(
bigBasicButton('Back',
dom.on('click', () => window.history.back()),
dom.show((use) => task !== 'signUp' || !use(this._showConfirmPage)),
dom.boolAttr('disabled', this._isSubmitting),
testId('back')
),
bigBasicButtonLink('Edit',
dom.show(this._showConfirmPage),
dom.on('click', () => this._showConfirmPage.set(false)),
dom.boolAttr('disabled', this._isSubmitting),
testId('edit')
),
bigPrimaryButton({style: 'margin-left: 10px;'},
dom.text((use) => (task !== 'signUp' || use(this._showConfirmPage)) ? submitText : 'Continue'),
dom.boolAttr('disabled', this._isSubmitting),
dom.on('click', () => this._doSubmit(task)),
testId('submit')
)
);
}
// Submit the active form.
private async _doSubmit(task?: BillingTask): Promise<void> {
if (this._isSubmitting.get()) { return; }
this._isSubmitting.set(true);
try {
// If the form is built, fetch the form data.
if (this._form) {
this._formData = await this._form.getFormData();
}
// In general, submit data to the server. In the case of signup, get the tax rate
// and show confirmation data before submitting.
if (task !== 'signUp' || this._showConfirmPage.get()) {
await this._model.submitPaymentPage(this._formData);
// On submit, reset confirm page and form data.
this._showConfirmPage.set(false);
this._formData = {};
} else {
if (this._model.signupTaxRate === undefined) {
await this._model.fetchSignupTaxRate(this._formData);
}
this._showConfirmPage.set(true);
this._isSubmitting.set(false);
}
} catch (err) {
// Note that submitPaymentPage/fetchSignupTaxRate are responsible for reporting errors.
// On failure the submit button isSubmitting state should be returned to false.
if (!this.isDisposed()) {
this._isSubmitting.set(false);
this._showConfirmPage.set(false);
}
}
}
private _showRemoveCardModal(): void {
confirmModal(`Remove Payment Card`, 'Remove',
() => this._model.removeCard(),
`This is the only payment method associated with the account.\n\n` +
`If removed, another payment method will need to be added before the ` +
`next payment is due.`);
}
}
const statusText: {[key: string]: string} = {
incomplete: 'incomplete',
incomplete_expired: 'incomplete',
past_due: 'past due',
canceled: 'canceled',
unpaid: 'unpaid',
};
function getSubscriptionProblem(sub: ISubscriptionModel) {
const text = sub.status && statusText[sub.status];
if (!text) { return null; }
const result = [['Your subscription is ', text]];
if (sub.lastChargeError) {
const when = sub.lastChargeTime ? `on ${timeFmt(sub.lastChargeTime)} ` : '';
result.push([`Last charge attempt ${when} failed: ${sub.lastChargeError}`]);
}
return result.map(msg => makeSummaryFeature(msg, {isBad: true}));
}
function getPriceString(priceCents: number, taxRate: number = 0): string {
// TODO: Add functionality for other currencies.
return ((priceCents / 100) * (taxRate + 1)).toLocaleString('en-US', {
style: "currency",
currency: "USD",
minimumFractionDigits: 2
});
}
/**
* Make summary feature to include in:
* - Plan cards for describing features of the plan.
* - Summary lists describing what is being paid for and how much will be charged.
* - Summary lists describing the current subscription.
*
* Accepts text as an array where strings at every odd numbered index are bolded for emphasis.
* If isMissingFeature is set, no text is bolded and the optional attribute object is not applied.
* If isBad is set, a cross is used instead of a tick
*/
function makeSummaryFeature(
text: string|string[],
options: { isMissingFeature?: boolean, isBad?: boolean, attr?: IAttrObj } = {}
) {
const textArray = Array.isArray(text) ? text : [text];
if (options.isMissingFeature) {
return css.summaryMissingFeature(
textArray,
testId('summary-line')
);
} else {
return css.summaryFeature(options.attr,
options.isBad ? css.billingBadIcon('CrossBig') : css.billingIcon('Tick'),
textArray.map((str, i) => (i % 2) ? css.focusText(str) : css.summaryText(str)),
testId('summary-line')
);
}
}
function reportLink(appModel: AppModel, text: string): HTMLElement {
return dom('a', {href: '#'}, text,
dom.on('click', (ev) => { ev.preventDefault(); beaconOpenMessage({appModel}); })
);
}
function dateFmt(timestamp: number|null): string {
if (!timestamp) { return "unknown"; }
return new Date(timestamp).toLocaleDateString('default', {month: 'long', day: 'numeric'});
}
function dateFmtFull(timestamp: number|null): string {
if (!timestamp) { return "unknown"; }
return new Date(timestamp).toLocaleDateString('default', {month: 'short', day: 'numeric', year: 'numeric'});
}
function timeFmt(timestamp: number): string {
return new Date(timestamp).toLocaleString('default',
{month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric'});
}

View File

@@ -0,0 +1,271 @@
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {input, styled} from 'grainjs';
// Note that the special settings remove the zip code number spinner
export const inputStyle = `
font-size: ${vars.mediumFontSize};
height: 42px;
line-height: 16px;
width: 100%;
padding: 13px;
border: 1px solid #D9D9D9;
border-radius: 3px;
outline: none;
&-invalid {
color: red;
}
&[type=number] {
-moz-appearance: textfield;
}
&[type=number]::-webkit-inner-spin-button,
&[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
`;
export const billingInput = styled(input, inputStyle);
export const stripeInput = styled('div', inputStyle);
export const billingWrapper = styled('div', `
overflow-y: auto;
`);
export const plansPage = styled('div', `
margin: 60px 10%;
`);
export const plansContainer = styled('div', `
display: flex;
justify-content: space-around;
flex-wrap: wrap;
margin: 45px -1%;
`);
export const planBox = styled('div', `
flex: 1 1 0;
max-width: 295px;
border: 1px solid ${colors.mediumGrey};
border-radius: 1px;
padding: 40px;
margin: 0 1% 30px 1%;
&:last-child {
border: 1px solid ${colors.lightGreen};
}
`);
export const planInterval = styled('div', `
display: inline-block;
color: ${colors.slate};
font-weight: ${vars.headerControlTextWeight};
font-size: ${vars.mediumFontSize};
margin-left: 8px;
`);
export const summaryFeature = styled('div', `
color: ${colors.dark};
margin: 24px 0 24px 20px;
text-indent: -20px;
`);
export const summaryMissingFeature = styled('div', `
color: ${colors.slate};
margin: 12px 0 12px 20px;
`);
export const summarySpacer = styled('div', `
height: 28px;
`);
export const upgradeBtn = styled(bigPrimaryButtonLink, `
width: 100%;
margin: 15px 0 0 0;
text-align: center;
`);
export const currentBtn = styled(bigBasicButton, `
width: 100%;
margin: 20px 0 0 0;
cursor: default;
`);
export const billingPage = styled('div', `
display: flex;
max-width: 1000px;
margin: auto;
`);
export const billingHeader = styled('div', `
height: 32px;
line-height: 32px;
margin: 0 0 16px 0;
color: ${colors.dark};
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
.${planBox.className}:last-child > & {
color: ${colors.lightGreen};
}
`);
export const billingSubHeader = styled('div', `
margin: 16px 0 24px 0;
color: ${colors.dark};
font-size: ${vars.mediumFontSize};
font-weight: bold;
`);
export const billingText = styled('div', `
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
`);
export const billingBoldText = styled(billingText, `
font-weight: bold;
`);
export const billingHintText = styled('div', `
font-size: ${vars.mediumFontSize};
color: ${colors.slate};
`);
// TODO: Adds a style for when the button is disabled.
export const billingTextBtn = styled('button', `
font-size: ${vars.mediumFontSize};
color: ${colors.lightGreen};
cursor: pointer;
margin-left: 24px;
background-color: transparent;
border: none;
padding: 0;
text-align: left;
&:hover {
color: ${colors.darkGreen};
}
`);
export const billingIcon = styled(icon, `
background-color: ${colors.lightGreen};
margin: 0 4px 2px 0;
.${billingTextBtn.className}:hover > & {
background-color: ${colors.darkGreen};
}
`);
export const billingBadIcon = styled(icon, `
background-color: ${colors.error};
margin: 0 4px 2px 0;
`);
export const summaryItem = styled('div', `
padding: 12px 0 26px 0;
`);
export const summaryFeatures = styled('div', `
margin: 40px 0;
`);
export const summaryText = styled('span', `
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
`);
export const focusText = styled('span', `
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
font-weight: bold;
`);
export const cardBlock = styled('div', `
flex: 1 1 60%;
margin: 60px;
`);
export const summaryRow = styled('div', `
display: flex;
`);
export const summaryHeader = styled(summaryRow, `
margin-bottom: 16px;
`);
export const summaryBlock = styled('div', `
flex: 1 1 40%;
margin: 60px;
float: left;
`);
export const flexSpace = styled('div', `
flex: 1 1 0px;
`);
export const paymentSubHeader = styled('div', `
font-weight: ${vars.headerControlTextWeight};
font-size: ${vars.xxlargeFontSize};
color: ${colors.dark};
line-height: 60px;
`);
export const paymentField = styled('div', `
display: block;
flex: 1 1 0;
margin: 4px 0;
min-width: 120px;
`);
export const paymentFieldInfo = styled('div', `
color: #929299;
margin: 10px 0;
`);
export const paymentSpacer = styled('div', `
width: 38px;
`);
export const paymentLabel = styled('label', `
font-weight: ${vars.headerControlTextWeight};
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
line-height: 38px;
`);
export const inputHintLabel = styled('div', `
margin: 50px 5px 10px 5px;
`);
export const paymentBlock = styled('div', `
margin: 0 0 20px 0;
`);
export const paymentRow = styled('div', `
display: flex;
`);
export const paymentBtnRow = styled('div', `
display: flex;
margin-top: 30px;
justify-content: flex-end;
`);
export const inputError = styled('div', `
height: 16px;
color: red;
`);
export const spinnerBox = styled('div', `
margin: 60px;
text-align: center;
`);
export const errorBox = styled('div', `
margin: 60px 0;
`);

View File

@@ -0,0 +1,178 @@
import {getHomeUrl, reportError} from 'app/client/models/AppModel';
import {BillingModel} from 'app/client/models/BillingModel';
import {createUserImage} from 'app/client/ui/UserImage';
import * as um from 'app/client/ui/UserManager';
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars';
import {normalizeEmail} from 'app/common/emails';
import {FullUser} from 'app/common/LoginSessionAPI';
import {Organization, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {Computed, Disposable, dom, obsArray, ObsArray, Observable, styled} from 'grainjs';
import pick = require('lodash/pick');
export class BillingPlanManagers extends Disposable {
private readonly _userAPI: UserAPI = new UserAPIImpl(getHomeUrl());
private readonly _email: Observable<string> = Observable.create(this, "");
private readonly _managers = this.autoDispose(obsArray<FullUser>([]));
private readonly _orgMembers: ObsArray<FullUser> = this.autoDispose(obsArray<FullUser>([]));
private readonly _isValid: Observable<boolean> = Observable.create(this, false);
private readonly _loading: Observable<boolean> = Observable.create(this, true);
private _emailElem: HTMLInputElement;
constructor(
private readonly _model: BillingModel,
private readonly _currentOrg: Organization,
private readonly _currentValidUser: FullUser|null
) {
super();
this._initialize().catch(reportError);
}
public buildDom() {
const enableAdd: Computed<boolean> = Computed.create(null, (use) =>
Boolean(use(this._email) && use(this._isValid) && !use(this._loading)));
return dom('div',
dom.autoDispose(enableAdd),
cssMemberList(
dom.forEach(this._managers, manager => this._buildManagerRow(manager)),
),
cssEmailInputRow(
um.cssEmailInputContainer({style: `flex: 1 1 0; margin: 0 7px 0 0;`},
um.cssMailIcon('Mail'),
this._emailElem = um.cssEmailInput(this._email, {onInput: true, isValid: this._isValid},
{type: "email", placeholder: "Enter email address"},
dom.on('keyup', (e: KeyboardEvent) => {
switch (e.keyCode) {
case 13: return this._commit();
default: return this._update();
}
}),
dom.boolAttr('disabled', this._loading)
),
um.cssEmailInputContainer.cls('-green', enableAdd),
um.cssEmailInputContainer.cls('-disabled', this._loading),
testId('bpm-manager-new')
),
bigPrimaryButton('Add Billing Contact',
dom.on('click', () => this._commit()),
dom.boolAttr('disabled', (use) => !use(enableAdd)),
testId('bpm-manager-add')
)
)
);
}
private _buildManagerRow(manager: FullUser) {
const isCurrentUser = this._currentValidUser && manager.id === this._currentValidUser.id;
return um.cssMemberListItem({style: 'width: auto;'},
um.cssMemberImage(
createUserImage(manager, 'large')
),
um.cssMemberText(
um.cssMemberPrimary(manager.name || dom('span', manager.email, testId('bpm-email'))),
manager.name ? um.cssMemberSecondary(manager.email, testId('bpm-email')) : null
),
um.cssMemberBtn(
um.cssRemoveIcon('Remove', testId('bpm-manager-delete')),
um.cssMemberBtn.cls('-disabled', (use) => Boolean(use(this._loading) || isCurrentUser)),
// Click handler.
dom.on('click', () => this._loading.get() || isCurrentUser || this._remove(manager))
),
testId('bpm-manager')
);
}
private async _initialize(): Promise<void> {
if (this._currentValidUser) {
const managers = await this._model.fetchManagers();
const {users} = await this._userAPI.getOrgAccess(this._currentOrg.id);
// This next line is here primarily for tests, where pages may be opened and closed
// rapidly and we only want to log "real" errors.
if (this.isDisposed()) { return; }
const fullUsers = users.filter(u => u.access).map(u => pick(u, ['id', 'name', 'email', 'picture']));
this._managers.set(managers);
this._orgMembers.set(fullUsers);
this._loading.set(false);
}
}
// Add the currently entered email if valid, or trigger a validation message if not.
private async _commit() {
await this._update();
if (this._email.get() && this._isValid.get()) {
try {
await this._add(this._email.get());
this._email.set("");
this._emailElem.focus();
} catch (e) {
this._emailElem.setCustomValidity(e.message);
}
}
(this._emailElem as any).reportValidity();
}
private async _update() {
this._emailElem.setCustomValidity("");
this._isValid.set(this._emailElem.checkValidity());
}
// Add the user with the given email as a plan manager.
private async _add(email: string): Promise<void> {
email = normalizeEmail(email);
const member = this._managers.get().find((m) => m.email === email);
const possible = this._orgMembers.get().find((m) => m.email === email);
// These errors should be reported by the email validity checker in _commit().
if (member) { throw new Error("This user is already in the list"); }
// TODO: Allow adding non-members of the org as billing plan managers with confirmation.
if (!possible) { throw new Error("Only members of the org can be billing plan managers"); }
this._loading.set(true);
await this._doAddManager(possible);
this._loading.set(false);
}
// Remove the user from the list of plan managers.
private async _remove(manager: FullUser): Promise<void> {
this._loading.set(true);
try {
await this._model.removeManager(manager.email);
const index = this._managers.get().findIndex((m) => m.id === manager.id);
this._managers.splice(index, 1);
} catch (e) {
// TODO: Report error in a friendly way.
reportError(e);
}
this._loading.set(false);
}
// TODO: Use to confirm adding non-org members as plan managers.
// private _showConfirmAdd(orgName: string, user: FullUser) {
// const nameSpaced = user.name ? `${user.name} ` : '';
// return confirmModal('Add Plan Manager', 'Add', () => this._doAddManager(user),
// `User ${nameSpaced}with email ${user.email} is not a member of organization ${orgName}. ` +
// `Add user to ${orgName}?`)
// }
private async _doAddManager(user: FullUser) {
try {
await this._model.addManager(user.email);
this._managers.push(user);
} catch (e) {
// TODO: Report error in a friendly way.
reportError(e);
}
}
}
const cssMemberList = styled('div', `
flex: 1 1 0;
margin: 20px 0;
width: 100%;
overflow-y: auto;
`);
const cssEmailInputRow = styled('div', `
display: flex;
margin: 28px 0;
`);

View File

@@ -0,0 +1,288 @@
/**
* Creates a UI for column filter menu given a columnFilter model, a mapping of cell values to counts, and an onClose
* callback that's triggered on Apply or on Cancel. Changes to the UI result in changes to the underlying model,
* but on Cancel the model is reset to its initial state prior to menu closing.
*/
import {allInclusive, ColumnFilter} from 'app/client/models/ColumnFilter';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {FilteredRowSource} from 'app/client/models/rowset';
import {SectionFilter} from 'app/client/models/SectionFilter';
import {TableData} from 'app/client/models/TableData';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {cssCheckboxSquare, cssLabel, cssLabelText} from 'app/client/ui2018/checkbox';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menuCssClass, menuDivider, menuIcon} from 'app/client/ui2018/menus';
import {CellValue} from 'app/common/DocActions';
import {nativeCompare} from 'app/common/gutil';
import {Computed, dom, input, makeTestId, Observable, styled} from 'grainjs';
import escapeRegExp = require('lodash/escapeRegExp');
import identity = require('lodash/identity');
import {IOpenController} from 'popweasel';
interface IFilterCount {
label: string;
count: number;
}
interface IFilterMenuOptions {
columnFilter: ColumnFilter;
valueCounts: Map<CellValue, IFilterCount>;
doSave: (reset: boolean) => void;
onClose: () => void;
}
export function columnFilterMenu({ columnFilter, valueCounts, doSave, onClose }: IFilterMenuOptions): HTMLElement {
// Save the initial state to allow reverting back to it on Cancel
const initialStateJson = columnFilter.makeFilterJson();
const testId = makeTestId('test-filter-menu-');
// Computed boolean reflecting whether current filter state is all-inclusive.
const includesAll: Computed<boolean> = Computed.create(null, columnFilter.filterFunc, () => {
const spec = columnFilter.makeFilterJson();
return spec === allInclusive;
});
// Map to keep track of displayed checkboxes
const checkboxMap: Map<CellValue, HTMLInputElement> = new Map();
// Listen for changes to filterFunc, and update checkboxes accordingly
const filterListener = columnFilter.filterFunc.addListener(func => {
for (const [value, elem] of checkboxMap) {
elem.checked = func(value);
}
});
const valueCountArr: Array<[CellValue, IFilterCount]> = Array.from(valueCounts);
const openSearch = Observable.create(null, false);
const searchValueObs = Observable.create(null, '');
const filteredValues = Computed.create(null, openSearch, searchValueObs, (_use, isOpen, searchValue) => {
const searchRegex = new RegExp(escapeRegExp(searchValue), 'i');
return valueCountArr.filter(([key]) => !isOpen || searchRegex.test(key as string))
.sort((a, b) => nativeCompare(a[1].label, b[1].label));
});
let searchInput: HTMLInputElement;
let reset = false;
const filterMenu: HTMLElement = cssMenu(
{ tabindex: '-1' }, // Allow menu to be focused
testId('wrapper'),
dom.cls(menuCssClass),
dom.autoDispose(includesAll),
dom.autoDispose(filterListener),
dom.autoDispose(openSearch),
dom.autoDispose(searchValueObs),
dom.autoDispose(filteredValues),
(elem) => { setTimeout(() => elem.focus(), 0); }, // Grab focus on open
dom.onDispose(() => doSave(reset)), // Save on disposal, which should always happen as part of closing.
dom.onKeyDown({
Enter: () => onClose(),
Escape: () => onClose()
}),
cssMenuHeader(
cssSelectAll(testId('select-all'),
dom.hide(openSearch),
dom.on('click', () => includesAll.get() ? columnFilter.clear() : columnFilter.selectAll()),
dom.domComputed(includesAll, yesNo => [
menuIcon(yesNo ? 'CrossSmall' : 'Tick'),
yesNo ? 'Select none' : 'Select all'
])
),
dom.maybe(openSearch, () => { return [
cssLabel(
cssCheckboxSquare({type: 'checkbox', checked: includesAll.get()}, testId('search-select'),
dom.on('change', (_ev, elem) => {
if (!searchValueObs.get()) { // If no search has been entered, treat select/deselect as Select All
elem.checked ? columnFilter.selectAll() : columnFilter.clear();
} else { // Otherwise, add/remove specific matched values
filteredValues.get()
.forEach(([key]) => elem.checked ? columnFilter.add(key) : columnFilter.delete(key));
}
})
)
),
searchInput = cssSearch(searchValueObs, { onInput: true },
testId('search-input'),
{ type: 'search', placeholder: 'Search values' },
dom.show(openSearch),
dom.onKeyDown({
Enter: () => undefined,
Escape: () => {
setTimeout(() => filterMenu.focus(), 0); // Give focus back to menu
openSearch.set(false);
}
})
)
]; }),
dom.domComputed(openSearch, isOpen => isOpen ?
cssSearchIcon('CrossBig', testId('search-close'), dom.on('click', () => {
openSearch.set(false);
searchValueObs.set('');
})) :
cssSearchIcon('Search', testId('search-open'), dom.on('click', () => {
openSearch.set(true);
setTimeout(() => searchInput.focus(), 0);
}))
)
),
cssMenuDivider(),
cssItemList(
testId('list'),
dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults('No matching values')),
dom.forEach(filteredValues, ([key, value]) => cssMenuItem(
cssLabel(
cssCheckboxSquare({type: 'checkbox'},
dom.on('change', (_ev, elem) =>
elem.checked ? columnFilter.add(key) : columnFilter.delete(key)),
(elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }),
cssItemValue(value.label === undefined ? key as string : value.label),
),
cssItemCount(value.count.toLocaleString())) // Include comma separator
)
),
cssMenuDivider(),
cssMenuFooter(
cssApplyButton('Apply', testId('apply-btn'),
dom.on('click', () => { reset = true; onClose(); })),
basicButton('Cancel', testId('cancel-btn'),
dom.on('click', () => { columnFilter.setState(initialStateJson); onClose(); } )))
);
return filterMenu;
}
/**
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
*/
export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, field: ViewFieldRec,
rowSource: FilteredRowSource, tableData: TableData) {
// Go through all of our shown and hidden rows, and count them up by the values in this column.
const valueGetter = tableData.getRowPropFunc(field.column().colId())!;
const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!;
const formatter = field.createVisibleColFormatter();
const valueMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId));
const valueCounts: Map<CellValue, {label: string, count: number}> = new Map();
addCountsToMap(valueCounts, rowSource.getAllRows() as Iterable<number>, valueGetter, valueMapFunc);
addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable<number>, valueGetter, valueMapFunc);
const columnFilter = ColumnFilter.create(openCtl, field.activeFilter.peek());
sectionFilter.setFilterOverride(field.getRowId(), columnFilter); // Will be removed on menu disposal
return columnFilterMenu({
columnFilter,
valueCounts,
onClose: () => openCtl.close(),
doSave: (reset: boolean = false) => {
const spec = columnFilter.makeFilterJson();
field.activeFilter(spec === allInclusive ? '' : spec);
if (reset) {
sectionFilter.resetTemporaryRows();
}
},
});
}
/**
* For each value in Iterable, adds a key mapped with `keyMapFunc` and a value object with a `label` mapped
* with `labelMapFunc` and a `count` representing the total number of times the key has been encountered.
*/
function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, values: Iterable<CellValue>,
keyMapFunc: (v: any) => any = identity, labelMapFunc: (v: any) => any = identity) {
for (const v of values) {
let key = keyMapFunc(v);
// For complex values, serialize the value to allow them to be properly stored
if (Array.isArray(key)) { key = JSON.stringify(key); }
if (valueMap.get(key)) {
valueMap.get(key)!.count++;
} else {
valueMap.set(key, { label: labelMapFunc(v), count: 1 });
}
}
}
const cssMenu = styled('div', `
display: flex;
flex-direction: column;
min-width: 400px;
max-width: 400px;
max-height: 90vh;
outline: none;
background-color: white;
`);
const cssMenuHeader = styled('div', `
flex-shrink: 0;
display: flex;
align-items: center;
margin: 0 8px;
`);
const cssSelectAll = styled('div', `
display: flex;
color: ${colors.lightGreen};
cursor: default;
user-select: none;
`);
const cssMenuDivider = styled(menuDivider, `
flex-shrink: 0;
margin: 8px 0;
`);
const cssItemList = styled('div', `
flex-shrink: 1;
overflow: auto;
padding-right: 8px; /* Space for scrollbar */
min-height: 80px;
`);
const cssMenuItem = styled('div', `
display: flex;
padding: 4px 8px;
`);
const cssItemValue = styled(cssLabelText, `
margin-right: 12px;
color: ${colors.dark};
white-space: nowrap;
`);
const cssItemCount = styled('div', `
flex-grow: 1;
align-self: normal;
text-align: right;
color: ${colors.slate};
`);
const cssMenuFooter = styled('div', `
display: flex;
margin: 0 8px;
flex-shrink: 0;
`);
const cssApplyButton = styled(primaryButton, `
margin-right: 4px;
`);
const cssSearch = styled(input, `
flex-grow: 1;
min-width: 1px;
-webkit-appearance: none;
-moz-appearance: none;
font-size: ${vars.controlFontSize};
margin: 0px 16px 0px 8px;
padding: 0px;
border: none;
outline: none;
`);
const cssSearchIcon = styled(icon, `
flex-shrink: 0;
margin-left: auto;
margin-right: 4px;
`);
const cssNoResults = styled(cssMenuItem, `
font-style: italic;
color: ${colors.slate};
justify-content: center;
`);

View File

@@ -0,0 +1,59 @@
import {GristLoadConfig} from 'app/common/gristUrls';
import {styled} from 'grainjs';
export type ProductFlavor = 'grist' | 'efcr' | 'fieldlink';
export interface CustomTheme {
bodyClassName?: string;
wideLogo?: boolean; // Stretch the logo and hide the org name.
}
export function getFlavor(org?: string): ProductFlavor {
// Using a URL parameter e.g. __themeOrg=fieldlink allows overriding the org used for custom
// theming, for testing.
const themeOrg = new URLSearchParams(window.location.search).get('__themeOrg');
if (themeOrg) { org = themeOrg; }
if (!org) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
org = gristConfig && gristConfig.org;
}
if (org === 'fieldlink') {
return 'fieldlink';
} else if (org && /^nioxus(-.*)?$/.test(org)) {
return 'efcr';
}
return 'grist';
}
export function getTheme(flavor: ProductFlavor): CustomTheme {
switch (flavor) {
case 'fieldlink':
return {
wideLogo: true,
bodyClassName: cssFieldLinkBody.className,
};
case 'efcr':
return {bodyClassName: cssEfcrBody.className};
default:
return {};
}
}
const cssEfcrBody = styled('body', `
--icon-GristLogo: url("icons/logo-efcr.png");
--grist-logo-bg: #009975;
--grist-color-light-green: #009975;
--grist-color-dark-green: #007F61;
--grist-primary-fg: #009975;
--grist-primary-fg-hover: #007F61;
--grist-control-fg: #009975;
--grist-color-darker-green: #004C38;
--grist-color-dark-bg: #004C38;
`);
const cssFieldLinkBody = styled('body', `
--icon-GristLogo: url("icons/logo-fieldlink.png");
--icon-GristWideLogo: url("icons/logo-fieldlink.png");
--grist-logo-bg: white;
`);

125
app/client/ui/DocHistory.ts Normal file
View File

@@ -0,0 +1,125 @@
import {createSessionObs} from 'app/client/lib/sessionObs';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {reportError} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow} from 'app/client/models/HomeModel';
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 {StringUnion} from 'app/common/StringUnion';
import {DocSnapshot} from 'app/common/UserAPI';
import {Disposable, dom, IDomComponent, MultiHolder, Observable, styled} from 'grainjs';
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);
constructor(private _docPageModel: DocPageModel, private _actionLog: IDomComponent) {
super();
}
public buildDom() {
const tabs = [
{value: 'activity', label: 'Activity'},
{value: 'snapshots', label: 'Snapshots'},
];
return [
cssSubTabs(
buttonSelect(this._subTab, tabs, {}, testId('doc-history-tabs')),
),
dom.domComputed(this._subTab, (subTab) =>
buildConfigContainer(
subTab === 'activity' ? this._actionLog.buildDom() :
subTab === 'snapshots' ? dom.create(this._buildSnapshots.bind(this)) :
null
)
),
];
}
private _buildSnapshots(owner: MultiHolder) {
// Fetch snapshots, and render.
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.'));
}
const snapshots = Observable.create<DocSnapshot[]>(owner, []);
const userApi = this._docPageModel.appModel.api;
const docApi = userApi.getDocAPI(doc.id);
docApi.getSnapshots().then(result => snapshots.set(result.snapshots)).catch(reportError);
return dom('div',
dom.forEach(snapshots, (snapshot) => {
const modified = moment(snapshot.lastModified);
return cssSnapshot(
cssSnapshotTime(getTimeFromNow(snapshot.lastModified)),
cssSnapshotCard(
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}),
testId('doc-history-snapshot-menu'),
),
),
testId('doc-history-snapshot'),
);
}),
);
}
}
const cssSubTabs = styled('div', `
padding: 16px;
border-bottom: 1px solid ${colors.mediumGrey};
`);
const cssSnapshot = styled('div', `
margin: 8px 16px;
`);
const cssSnapshotTime = styled('div', `
text-align: right;
color: ${colors.slate};
font-size: ${vars.smallFontSize};
`);
const cssSnapshotCard = styled('div', `
border: 1px solid ${colors.mediumGrey};
padding: 8px;
background: white;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
`);
const cssDatePart = styled('span', `
display: inline-block;
`);
const cssMenuDots = styled('div', `
flex: none;
margin: 0 4px 0 auto;
height: 24px;
width: 24px;
padding: 4px;
line-height: 0px;
border-radius: 3px;
cursor: default;
--icon-color: ${colors.slate};
&:hover, &.weasel-popup-open {
background-color: ${colors.mediumGrey};
--icon-color: ${colors.slate};
}
`);

431
app/client/ui/DocMenu.ts Normal file
View File

@@ -0,0 +1,431 @@
/**
* This module exports a DocMenu component, consisting of an organization dropdown, a sidepane
* of workspaces, and a doc list. The organization and workspace selectors filter the doc list.
* Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.
*/
import {loadUserManager} from 'app/client/lib/imports';
import {reportError} from 'app/client/models/AppModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'app/client/models/HomeModel';
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import * as css from 'app/client/ui/DocMenuCss';
import {buildExampleList, buildExampleListBody, buildHomeIntro} from 'app/client/ui/HomeIntro';
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {transition} from 'app/client/ui/transitions';
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
import {confirmModal, saveModal} from 'app/client/ui2018/modals';
import {IHomePage} from 'app/common/gristUrls';
import {SortPref, ViewPref} from 'app/common/Prefs';
import * as roles from 'app/common/roles';
import {Document, Workspace} from 'app/common/UserAPI';
import {Computed, computed, dom, DomContents, makeTestId, Observable, observable} from 'grainjs';
import sortBy = require('lodash/sortBy');
const testId = makeTestId('test-dm-');
/**
* The DocMenu is the main area of the home page, listing all docs.
*
* Usage:
* dom('div', createDocMenu(homeModel))
*/
export function createDocMenu(home: HomeModel) {
return dom.domComputed(home.loading, loading => (
loading === 'slow' ? css.spinner(loadingSpinner()) :
loading ? null :
createLoadedDocMenu(home)
));
}
function createLoadedDocMenu(home: HomeModel) {
const flashDocId = observable<string|null>(null);
return css.docList(
dom.maybe(!home.app.currentFeatures.workspaces, () => [
css.docListHeader('This service is not available right now'),
dom.text('(The organization needs a paid plan)')
]),
// currentWS and showIntro observables change together. We capture both in one domComputed call.
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
([page, workspace, showIntro]) => {
const viewSettings: ViewSettings =
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
workspace ? makeLocalViewSettings(home, workspace.id) :
home;
return [
// Hide the sort option when only showing examples, since we keep them in a specific order.
buildPrefs(viewSettings, {hideSort: Boolean(showIntro || workspace?.isSupportWorkspace)}),
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded or
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
css.docListHeader(css.docHeaderIconDark('PinBig'), 'Pinned Documents'),
createPinnedDocs(home),
]),
dom.maybe(home.available, () => [
(showIntro && page === 'all' ?
null :
css.docListHeader(
(
page === 'all' ? 'All Documents' :
page === 'trash' ? 'Trash' :
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
),
testId('doc-header'),
)
),
(
(page === 'all') ?
dom('div',
showIntro ? buildHomeIntro(home) : null,
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
) :
(page === 'trash') ?
dom('div',
css.docBlock('Documents stay in Trash for 30 days, after which they get deleted permanently.'),
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
css.docBlock('Trash is empty.')
),
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
) :
workspace ?
(workspace.isSupportWorkspace ?
buildExampleListBody(home, workspace, viewSettings) :
css.docBlock(
buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
testId('doc-block')
)
) : css.docBlock('Workspace not found')
)
]),
];
}),
testId('doclist')
);
}
function buildAllDocsBlock(
home: HomeModel, workspaces: Observable<Workspace[]>,
showIntro: boolean, flashDocId: Observable<string|null>, viewSettings: ViewSettings,
) {
const org = home.app.currentOrg;
return dom.forEach(workspaces, (ws) => {
const isPersonalOrg = Boolean(org && org.owner);
if (ws.isSupportWorkspace) {
// Show the example docs in the "All Documents" list for all personal orgs
// and for non-personal orgs when showing intro.
if (!isPersonalOrg && !showIntro) { return null; }
return buildExampleList(home, ws, viewSettings);
} else {
// Show docs in regular workspaces. For empty orgs, we show the intro and skip
// the empty workspace headers. Workspaces are still listed in the left panel.
if (showIntro) { return null; }
return css.docBlock(
css.docBlockHeaderLink(
css.wsLeft(
css.docHeaderIcon('Folder'),
workspaceName(home.app, ws),
),
(ws.removedAt ?
[
css.docRowUpdatedAt(`Deleted ${getTimeFromNow(ws.removedAt)}`),
css.docMenuTrigger(icon('Dots')),
menu(() => makeRemovedWsOptionsMenu(home, ws),
{placement: 'bottom-end', parentSelectorToMark: '.' + css.docRowWrapper.className}),
] :
urlState().setLinkUrl({ws: ws.id})
),
dom.hide((use) => Boolean(getWorkspaceInfo(home.app, ws).isDefault &&
use(home.singleWorkspace))),
testId('ws-header'),
),
buildWorkspaceDocBlock(home, ws, flashDocId, viewSettings),
testId('doc-block')
);
}
});
}
/**
* Build the widget for selecting sort and view mode options.
* If hideSort is true, will hide the sort dropdown: it has no effect on the list of examples, so
* best to hide when those are the only docs shown.
*/
function buildPrefs(viewSettings: ViewSettings, options: {hideSort: boolean}): DomContents {
return css.prefSelectors(
// The Sort selector.
options.hideSort ? null : dom.update(
select<SortPref>(viewSettings.currentSort, [
{value: 'name', label: 'By Name'},
{value: 'date', label: 'By Date Modified'},
],
{ buttonCssClass: css.sortSelector.className },
),
testId('sort-mode'),
),
// The View selector.
buttonSelect<ViewPref>(viewSettings.currentView, [
{value: 'icons', icon: 'TypeTable'},
{value: 'list', icon: 'TypeCardList'},
],
cssButtonSelect.cls("-light"),
testId('view-mode')
),
);
}
function buildWorkspaceDocBlock(home: HomeModel, workspace: Workspace, flashDocId: Observable<string|null>,
viewSettings: ViewSettings) {
const renaming = observable<Document|null>(null);
function renderDocs(sort: 'date'|'name', view: "list"|"icons") {
// Docs are sorted by name in HomeModel, we only re-sort if we want a different order.
let docs = workspace.docs;
if (sort === 'date') {
// Note that timestamps are ISO strings, which can be sorted without conversions.
docs = sortBy(docs, (doc) => doc.removedAt || doc.updatedAt).reverse();
}
return dom.forEach(docs, doc => {
if (view === 'icons') {
return dom.update(
buildPinnedDoc(home, doc),
testId('doc'),
);
}
// TODO: Introduce a "SwitchSelector" pattern to avoid the need for N computeds (and N
// recalculations) to select one of N items.
const isRenaming = computed((use) => use(renaming) === doc);
const flash = computed((use) => use(flashDocId) === doc.id);
return css.docRowWrapper(
dom.autoDispose(isRenaming),
dom.autoDispose(flash),
css.docRowLink(
doc.removedAt ? null : urlState().setLinkUrl(docUrl(doc)),
dom.hide(isRenaming),
css.docRowLink.cls('-no-access', !roles.canView(doc.access)),
css.docLeft(
css.docName(doc.name, testId('doc-name')),
css.docPinIcon('PinSmall', dom.show(doc.isPinned)),
doc.public ? css.docPublicIcon('Public', testId('public')) : null,
),
css.docRowUpdatedAt(
(doc.removedAt ?
`Deleted ${getTimeFromNow(doc.removedAt)}` :
`Edited ${getTimeFromNow(doc.updatedAt)}`),
testId('doc-time')
),
(doc.removedAt ?
[
// For deleted documents, attach the menu to the entire doc row, and include the
// "Dots" icon just to clarify that there are options.
menu(() => makeRemovedDocOptionsMenu(home, doc, workspace),
{placement: 'bottom-end', parentSelectorToMark: '.' + css.docRowWrapper.className}),
css.docMenuTrigger(icon('Dots'), testId('doc-options')),
] :
css.docMenuTrigger(icon('Dots'),
menu(() => makeDocOptionsMenu(home, doc, renaming),
{placement: 'bottom-start', parentSelectorToMark: '.' + css.docRowWrapper.className}),
// Clicks on the menu trigger shouldn't follow the link that it's contained in.
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
testId('doc-options'),
)
),
// The flash value may change to true, and then immediately to false. We highlight it
// using a transition, and scroll into view, when it turns back to false.
transition(flash, {
prepare(elem, val) { if (!val) { elem.style.backgroundColor = colors.slate.toString(); }},
run(elem, val) { if (!val) { elem.style.backgroundColor = ''; scrollIntoViewIfNeeded(elem); }},
})
),
css.docRowWrapper.cls('-renaming', isRenaming),
dom.maybe(isRenaming, () =>
css.docRowLink(
css.docEditorInput({
initialValue: doc.name || '',
save: (val) => doRename(home, doc, val, flashDocId),
close: () => renaming.set(null),
}, testId('doc-name-editor')),
css.docRowUpdatedAt(`Edited ${getTimeFromNow(doc.updatedAt)}`, testId('doc-time')),
),
),
testId('doc')
);
});
}
const {currentSort, currentView} = viewSettings;
return [
dom.domComputed(
(use) => ({sort: use(currentSort), view: use(currentView)}),
(opts) => renderDocs(opts.sort, opts.view)),
css.docBlock.cls((use) => '-' + use(currentView)),
];
}
async function doRename(home: HomeModel, doc: Document, val: string, flashDocId: Observable<string|null>) {
if (val !== doc.name) {
try {
await home.renameDoc(doc.id, val);
// "Flash" the doc.id: setting and immediately resetting flashDocId will cause on of the
// "flash" observables in buildWorkspaceDocBlock() to change to true and immediately to false
// (resetting to normal state), triggering a highlight transition.
flashDocId.set(doc.id);
flashDocId.set(null);
} catch (err) {
reportError(err);
}
}
}
// TODO rebuilds of big page chunks (all workspace) cause screen position to jump, sometimes
// losing the doc that was e.g. just renamed.
// Exported because also used by the PinnedDocs component.
export function makeDocOptionsMenu(home: HomeModel, doc: Document, renaming: Observable<Document|null>) {
const org = home.app.currentOrg;
const orgAccess: roles.Role|null = org ? org.access : null;
function deleteDoc() {
confirmModal(`Delete "${doc.name}"?`, 'Delete',
() => home.deleteDoc(doc.id, false).catch(reportError),
'Document will be moved to Trash.');
}
async function manageUsers() {
const api = home.app.api;
const user = home.app.currentUser;
(await loadUserManager()).showUserManagerModal(api, {
permissionData: api.getDocAccess(doc.id),
activeEmail: user ? user.email : null,
resourceType: 'document',
resourceId: doc.id
});
}
return [
menuItem(() => renaming.set(doc), "Rename",
dom.cls('disabled', !roles.canEdit(doc.access)),
testId('rename-doc')
),
menuItem(() => showMoveDocModal(home, doc), 'Move',
// Note that moving the doc requires ACL access on the doc. Moving a doc to a workspace
// that confers descendant ACL access could otherwise increase the user's access to the doc.
// By requiring the user to have ACL edit access on the doc to move it prevents using this
// as a tool to gain greater access control over the doc.
// Having ACL edit access on the doc means the user is also powerful enough to remove
// the doc, so this is the only access check required to move the doc out of this workspace.
// The user must also have edit access on the destination, however, for the move to work.
dom.cls('disabled', !roles.canEditAccess(doc.access)),
testId('move-doc')
),
menuItem(deleteDoc, 'Remove',
dom.cls('disabled', !roles.canDelete(doc.access)),
testId('delete-doc')
),
menuItem(() => home.pinUnpinDoc(doc.id, !doc.isPinned).catch(reportError),
doc.isPinned ? "Unpin Document" : "Pin Document",
dom.cls('disabled', !roles.canEdit(orgAccess)),
testId('pin-doc')
),
menuItem(manageUsers, "Manage Users",
dom.cls('disabled', !roles.canEditAccess(doc.access)),
testId('doc-access')
),
];
}
function makeRemovedDocOptionsMenu(home: HomeModel, doc: Document, workspace: Workspace) {
function hardDeleteDoc() {
confirmModal(`Permanently Delete "${doc.name}"?`, 'Delete Forever',
() => home.deleteDoc(doc.id, true).catch(reportError),
'Document will be permanently deleted.');
}
return [
menuItem(() => home.restoreDoc(doc), 'Restore',
dom.cls('disabled', !roles.canDelete(doc.access) || !!workspace.removedAt),
testId('doc-restore')
),
menuItem(hardDeleteDoc, 'Delete Forever',
dom.cls('disabled', !roles.canDelete(doc.access)),
testId('doc-delete-forever')
),
(workspace.removedAt ?
menuText('To restore this document, restore the workspace first.') :
null
)
];
}
function makeRemovedWsOptionsMenu(home: HomeModel, ws: Workspace) {
return [
menuItem(() => home.restoreWorkspace(ws), 'Restore',
dom.cls('disabled', !roles.canDelete(ws.access)),
testId('ws-restore')
),
menuItem(() => home.deleteWorkspace(ws.id, true), 'Delete Forever',
dom.cls('disabled', !roles.canDelete(ws.access) || ws.docs.length > 0),
testId('ws-delete-forever')
),
(ws.docs.length > 0 ?
menuText('You may delete a workspace forever once it has no documents in it.') :
null
)
];
}
function showMoveDocModal(home: HomeModel, doc: Document) {
saveModal((ctl, owner) => {
const selected: Observable<number|null> = Observable.create(owner, null);
const body = css.moveDocModalBody(
shadowScroll(
dom.forEach(home.workspaces, ws => {
if (ws.isSupportWorkspace) { return null; }
const isCurrent = Boolean(ws.docs.find(_doc => _doc.id === doc.id));
const isEditable = roles.canEdit(ws.access);
const disabled = isCurrent || !isEditable;
return css.moveDocListItem(
css.moveDocListText(workspaceName(home.app, ws)),
isCurrent ? css.moveDocListHintText('Current workspace') : null,
!isEditable ? css.moveDocListHintText('Requires edit permissions') : null,
css.moveDocListItem.cls('-disabled', disabled),
css.moveDocListItem.cls('-selected', (use) => use(selected) === ws.id),
dom.on('click', () => disabled || selected.set(ws.id)),
testId('dest-ws')
);
})
)
);
return {
title: `Move ${doc.name} to workspace`,
body,
saveDisabled: Computed.create(owner, (use) => !use(selected)),
saveFunc: async () => !selected.get() || home.moveDoc(doc.id, selected.get()!).catch(reportError),
saveLabel: 'Move'
};
});
}
// Scrolls an element into view only if it's above or below the screen.
// TODO move to some common utility
function scrollIntoViewIfNeeded(target: Element) {
const rect = target.getBoundingClientRect();
if (rect.bottom > window.innerHeight) {
target.scrollIntoView(false);
}
if (rect.top < 0) {
target.scrollIntoView(true);
}
}

239
app/client/ui/DocMenuCss.ts Normal file
View File

@@ -0,0 +1,239 @@
import {transientInput} from 'app/client/ui/transientInput';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {styled} from 'grainjs';
// The "&:after" clause forces some padding below all docs.
export const docList = styled('div', `
height: 100%;
padding: 32px 64px 24px 64px;
overflow-y: auto;
position: relative;
&:after {
content: "";
display: block;
height: 64px;
}
`);
export const docListHeader = styled('div', `
height: 32px;
line-height: 32px;
margin-bottom: 24px;
color: ${colors.dark};
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
`);
export const docBlock = styled('div', `
max-width: 550px;
min-width: 300px;
margin-bottom: 28px;
&-icons {
max-width: unset;
}
`);
export const docHeaderIconDark = styled(icon, `
margin-right: 8px;
margin-top: -3px;
`);
export const docHeaderIcon = styled(docHeaderIconDark, `
--icon-color: ${colors.slate};
`);
export const docBlockHeaderLink = styled('a', `
display: flex;
align-items: center;
height: 40px;
line-height: 40px;
margin-bottom: 8px;
margin-right: -16px;
color: ${colors.dark};
font-size: ${vars.mediumFontSize};
font-weight: bold;
&, &:hover, &:focus {
text-decoration: none;
outline: none;
color: inherit;
}
`);
export const wsLeft = styled('div', `
flex: 1 0 50%;
min-width: 0px;
margin-right: 24px;
`);
export const docRowWrapper = styled('div', `
position: relative;
margin: 0px -16px 8px -16px;
border-radius: 3px;
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
--icon-color: ${colors.slate};
&:hover, &.weasel-popup-open, &-renaming {
background-color: ${colors.mediumGrey};
}
`);
export const docRowLink = styled('a', `
display: flex;
align-items: center;
height: 40px;
line-height: 40px;
border-radius: 3px;
outline: none;
transition: background-color 2s;
&, &:hover, &:focus {
text-decoration: none;
outline: none;
color: inherit;
}
&-no-access, &-no-access:hover, &-no-access:focus {
color: ${colors.slate};
cursor: not-allowed;
}
`);
export const docLeft = styled('div', `
flex: 1 0 50%;
min-width: 0px;
margin: 0 16px;
display: flex;
align-items: center;
`);
export const docName = styled('div', `
flex: 0 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`);
export const docPinIcon = styled(icon, `
flex: none;
margin-left: 4px;
--icon-color: ${colors.lightGreen};
`);
export const docPublicIcon = styled(icon, `
flex: none;
margin-left: auto;
--icon-color: ${colors.lightGreen};
`);
export const docEditorInput = styled(transientInput, `
flex: 1 0 50%;
min-width: 0px;
margin: 0 16px;
color: initial;
font-size: inherit;
line-height: initial;
`);
export const docRowUpdatedAt = styled('div', `
flex: 1 1 50%;
color: ${colors.slate};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: normal;
`);
export const docMenuTrigger = styled('div', `
flex: none;
margin: 0 4px 0 auto;
height: 24px;
width: 24px;
padding: 4px;
line-height: 0px;
border-radius: 3px;
cursor: default;
--icon-color: ${colors.darkGrey};
.${docRowLink.className}:hover > & {
--icon-color: ${colors.slate};
}
&:hover, &.weasel-popup-open {
background-color: ${colors.darkGrey};
--icon-color: ${colors.slate};
}
`);
export const moveDocModalBody = styled('div', `
display: flex;
flex-direction: column;
border-bottom: 1px solid ${colors.darkGrey};
margin: 0 -64px;
height: 200px;
`);
export const moveDocListItem = styled('div', `
display: flex;
justify-content: space-between;
width: 100%;
height: 32px;
padding: 12px 64px;
cursor: pointer;
font-size: ${vars.mediumFontSize};
&-selected {
background-color: ${colors.lightGreen};
color: white;
}
&-disabled {
color: ${colors.darkGrey};
cursor: default;
}
`);
export const moveDocListText = styled('div', `
display: flex;
flex: 1 1 0;
flex-direction: column;
justify-content: center;
`);
export const moveDocListHintText = styled(moveDocListText, `
text-align: right;
`);
export const spinner = styled('div', `
display: flex;
align-items: center;
height: 80px;
margin: auto;
margin-top: 80px;
`);
export const prefSelectors = styled('div', `
position: absolute;
top: 32px;
right: 64px;
display: flex;
align-items: center;
`);
export const sortSelector = styled('div', `
margin-right: 24px;
/* negate the styles of a select that normally looks like a button */
border: none;
display: inline-flex;
height: unset;
line-height: unset;
align-items: center;
border-radius: ${vars.controlBorderRadius};
color: ${colors.lightGreen};
--icon-color: ${colors.lightGreen};
&:focus, &:hover {
outline: none;
box-shadow: none;
background-color: ${colors.mediumGrey};
}
`);

View File

@@ -0,0 +1,32 @@
#doc_window {
overflow: hidden;
height: 100%;
}
#doc_pane {
overflow: hidden;
z-index: 7;
background-color: #f4f4f4;
}
#doc_pane.newui {
background-color: white;
}
#view_pane {
position: relative;
min-width: 240px;
background-color: #f4f4f4;
margin: 3px;
}
#doc_pane.newui > #view_pane {
background-color: white;
margin: 0px;
padding: 12px;
}
#view_content {
position: relative;
}

View File

@@ -0,0 +1,76 @@
/**
* This module export a component for editing some document settings consisting of the timezone,
* (new settings to be added here ...).
*/
import { dom, IOptionFull, select, styled } from 'grainjs';
import { Computed, Observable } from 'grainjs';
import { loadMomentTimezone, MomentTimezone } from 'app/client/lib/imports';
import { DocInfoRec } from 'app/client/models/DocModel';
import { DocPageModel } from 'app/client/models/DocPageModel';
import { testId, vars } from 'app/client/ui2018/cssVars';
import { saveModal } from 'app/client/ui2018/modals';
import { nativeCompare } from 'app/common/gutil';
/**
* Returns the ordered list of offsets for names at time timestamp. See timezoneOptions for details
* on the sorting order.
*/
// exported for testing
export function timezoneOptionsImpl(
timestamp: number, names: string[], moment: MomentTimezone
): Array<IOptionFull<string>> {
// What we want is moment(timestamp) but the dynamic import with our compiling settings produces
// "moment is not a function". The following is equivalent, and easier than fixing import setup.
const m = moment.unix(timestamp / 1000);
const options = names.map((value) => ({
value,
label: `(GMT${m.tz(value).format('Z')}) ${value}`,
// A quick test reveal that it is a bit more efficient (~0.02ms) to get the offset using
// `moment.tz.Zone#parse` than creating a Moment instance for each zone and then getting the
// offset with `moment#utcOffset`.
offset: -moment.tz.zone(value)!.parse(timestamp)
}));
options.sort((a, b) => nativeCompare(a.offset, b.offset) || nativeCompare(a.value, b.value));
return options.map(({value, label}) => ({value, label}));
}
/**
* Returns the array of IOptionFull<string> expected by `select` to create the list of timezones
* options. The returned list is sorted based on the current offset (GMT-11:00 before GMT-10:00),
* and then on alphabetical order of the name.
*/
function timezoneOptions(moment: MomentTimezone): Array<IOptionFull<string>> {
return timezoneOptionsImpl(Date.now(), moment.tz.names(), moment);
}
/**
* Builds a simple saveModal for saving settings.
*/
export async function showDocSettingsModal(docInfo: DocInfoRec, docPageModel: DocPageModel): Promise<void> {
const moment = await loadMomentTimezone();
return saveModal((ctl, owner) => {
const timezone = Observable.create(owner, docInfo.timezone.peek());
return {
title: 'Document Settings',
body: [
cssDataRow("This document's ID (for API use):"),
cssDataRow(dom('tt', docPageModel.currentDocId.get())),
cssDataRow('Time Zone:'),
cssDataRow(select(timezone, timezoneOptions(moment)), testId('ds-tz')),
],
// At this point, we only need to worry about saving this one setting.
saveFunc: () => docInfo.timezone.saveOnly(timezone.get()),
// If timezone hasn't changed, there is nothing to save, so disable the Save button.
saveDisabled: Computed.create(owner, (use) => use(timezone) === docInfo.timezone.peek()),
};
});
}
// This matches the style used in showProfileModal in app/client/ui/AccountWidget.
const cssDataRow = styled('div', `
margin: 16px 0px;
font-size: ${vars.largeFontSize};
`);

View File

@@ -0,0 +1,173 @@
import {AppModel} from 'app/client/models/AppModel';
import {getUserOrgPrefObs} from 'app/client/models/UserPrefs';
import {IExampleInfo} from 'app/client/ui/ExampleInfo';
import {prepareForTransition, TransitionWatcher} from 'app/client/ui/transitions';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {dom, Observable, styled} from 'grainjs';
// Open a popup with a card introducing this example, if the user hasn't dismissed it in the past.
export function showExampleCard(
example: IExampleInfo, appModel: AppModel, btnElem: HTMLElement, reopen: boolean = false
) {
const prefObs: Observable<number[]|undefined> = getUserOrgPrefObs(appModel, 'seenExamples');
const seenExamples = prefObs.get() || [];
// If this example was previously dismissed, don't show the card, unless the user is reopening it.
if (!reopen && seenExamples.includes(example.id)) {
return;
}
// When an example card is closed, if it's the first time it's dismissed, save this fact, to avoid
// opening the card again in the future.
async function markAsSeen() {
if (!seenExamples.includes(example.id)) {
const seen = new Set(seenExamples);
seen.add(example.id);
prefObs.set([...seen].sort());
}
}
// Close the example card.
function close() {
collapseAndRemoveCard(cardElem, btnElem.getBoundingClientRect());
// If we fail to save this preference, it's probably not worth alerting the user about,
// so just log to console.
// tslint:disable-next-line:no-console
markAsSeen().catch((err) => console.warn("Failed to save userPref", err));
}
const card = example.welcomeCard;
if (!card) { return null; }
const cardElem = cssCard(
cssImage({src: example.imgUrl}),
cssBody(
cssTitle(card.title),
cssInfo(card.text),
cssButtons(
cssLinkBtn(cssLinkIcon('Page'), card.tutorialName,
{href: example.tutorialUrl, target: '_blank'},
),
// TODO: Add a link to the overview video (as popup or to a support page that shows the
// video). Also include a 'Video' icon.
// cssLinkBtn(cssLinkIcon('Video'), 'Grist Video Tour'),
)
),
cssCloseButton(cssBigIcon('CrossBig'),
dom.on('click', close),
testId('example-card-close'),
),
testId('example-card'),
);
document.body.appendChild(cardElem);
// When reopening, open the card smoothly, for a nicer-looking effect.
if (reopen) {
expandCard(cardElem, btnElem.getBoundingClientRect());
}
}
// When closing the card, collapse it visually into the button that can open it again, to hint to
// the user where to find that button. Remove the card after the animation.
function collapseAndRemoveCard(card: HTMLElement, collapsedRect: DOMRect) {
const watcher = new TransitionWatcher(card);
watcher.onDispose(() => card.remove());
collapseCard(card, collapsedRect);
}
// Implements the collapsing animation by simply setting a scale transform with a suitable origin.
function collapseCard(card: HTMLElement, collapsedRect: DOMRect) {
const rect = card.getBoundingClientRect();
const originX = (collapsedRect.left + collapsedRect.width / 2) - rect.left;
const originY = (collapsedRect.top + collapsedRect.height / 2) - rect.top;
Object.assign(card.style, {
transform: `scale(${collapsedRect.width / rect.width}, ${collapsedRect.height / rect.height})`,
transformOrigin: `${originX}px ${originY}px`,
opacity: '0',
});
}
// To expand the card visually, we reverse the process by collapsing it first with transitions
// disabled, then resetting properties to their defaults with transitions enabled again.
function expandCard(card: HTMLElement, collapsedRect: DOMRect) {
prepareForTransition(card, () => collapseCard(card, collapsedRect));
Object.assign(card.style, {
transform: '',
opacity: '',
visibility: 'visible',
});
}
const cssCard = styled('div', `
position: absolute;
left: 24px;
bottom: 24px;
margin-right: 24px;
max-width: 624px;
padding: 32px 56px 32px 32px;
background-color: white;
box-shadow: 0 2px 18px 0 rgba(31,37,50,0.31), 0 0 1px 0 rgba(76,86,103,0.24);
display: flex;
overflow: hidden;
transition-property: opacity, transform;
transition-duration: 0.5s;
transition-timing-func: ease-in;
`);
const cssImage = styled('img', `
flex: none;
width: 180px;
height: 140px;
margin: 0 -8px;
`);
const cssBody = styled('div', `
margin-left: 24px;
min-width: 0px;
`);
const cssTitle = styled('div', `
font-size: ${vars.headerControlFontSize};
font-weight: ${vars.headerControlTextWeight};
margin-bottom: 16px;
`);
const cssInfo = styled('div', `
margin: 16px 0 24px 0;
line-height: 1.6;
`);
const cssButtons = styled('div', `
display: flex;
`);
const cssLinkBtn = styled(cssLink, `
&:not(:last-child) {
margin-right: 32px;
}
`);
const cssLinkIcon = styled(icon, `
margin-right: 8px;
margin-top: -2px;
`);
const cssCloseButton = styled('div', `
position: absolute;
top: 8px;
right: 8px;
padding: 4px;
border-radius: 4px;
cursor: pointer;
--icon-color: ${colors.slate};
&:hover {
background-color: ${colors.mediumGreyOpaque};
}
`);
const cssBigIcon = styled(icon, `
padding: 12px;
`);

View File

@@ -0,0 +1,62 @@
import {DomContents} from 'grainjs';
export interface IExampleInfo {
id: number;
matcher: RegExp;
title: string;
imgUrl: string;
tutorialUrl: string;
bgColor: string;
desc: () => DomContents;
welcomeCard: WelcomeCard;
}
interface WelcomeCard {
title: string;
text: string;
tutorialName: string;
}
export const examples: IExampleInfo[] = [{
id: 1, // Identifies the example in UserPrefs.seenExamples
matcher: /Lightweight CRM/,
title: 'Lightweight CRM',
imgUrl: 'https://www.getgrist.com/themes/grist/assets/images/use-cases/lightweight-crm.png',
tutorialUrl: 'https://support.getgrist.com/lightweight-crm/',
bgColor: '#FDEDD7',
desc: () => 'CRM template and example for linking data, and creating productive layouts.',
welcomeCard: {
title: 'Welcome to the Lightweight CRM template',
text: 'Check out our related tutorial for how to link data, and create ' +
'high-productivity layouts.',
tutorialName: 'Tutorial: Create a CRM',
},
}, {
id: 2, // Identifies the example in UserPrefs.seenExamples
matcher: /Investment Research/,
title: 'Investment Research',
imgUrl: 'https://www.getgrist.com/themes/grist/assets/images/use-cases/data-visualization.png',
tutorialUrl: 'https://support.getgrist.com/investment-research/',
bgColor: '#CEF2E4',
desc: () => 'Example for analyzing and visualizing with summary tables and linked charts.',
welcomeCard: {
title: 'Welcome to the Investment Research template',
text: 'Check out our related tutorial to learn how to create summary tables and charts, ' +
'and to link charts dynamically.',
tutorialName: 'Tutorial: Analyze & Visualize',
},
}, {
id: 3, // Identifies the example in UserPrefs.seenExamples
matcher: /Afterschool Program/,
title: 'Afterschool Program',
imgUrl: 'https://www.getgrist.com/themes/grist/assets/images/use-cases/business-management.png',
tutorialUrl: 'https://support.getgrist.com/afterschool-program/',
bgColor: '#D7E3F5',
desc: () => 'Example for how to model business data, use formulas, and manage complexity.',
welcomeCard: {
title: 'Welcome to the Afterschool Program template',
text: 'Check out our related tutorial for how to model business data, use formulas, ' +
'and manage complexity.',
tutorialName: 'Tutorial: Manage Business Data',
},
}];

View File

@@ -0,0 +1,17 @@
import {menuItem, menuSubHeader} from 'app/client/ui2018/menus';
interface IFieldOptions {
useSeparate: () => void;
saveAsCommon: () => void;
revertToCommon: () => void;
}
export function FieldSettingsMenu(useColOptions: boolean, actions: IFieldOptions) {
return [
menuSubHeader(`Using ${useColOptions ? 'common' : 'separate'} settings`),
useColOptions ? menuItem(actions.useSeparate, 'Use separate settings') : [
menuItem(actions.saveAsCommon, 'Save as common settings'),
menuItem(actions.revertToCommon, 'Revert to common settings'),
]
];
}

View File

@@ -0,0 +1,83 @@
/**
* Utility to simplify file uploads via the browser-provided file picker. It takes care of
* maintaining an invisible <input type=file>, to make usage very simple:
*
* FileDialog.open({ multiple: true }, files => { do stuff with files });
*
* Promise interface allows this:
*
* const fileList = await FileDialog.openFilePicker({multiple: true});
*
* (Note that in either case, it's possible for the callback to never be called, or for the
* Promise to never resolve; see comments for openFilePicker.)
*
* Note that interacting with a file dialog is difficult with WebDriver, but
* test/browser/gristUtils.js provides a `gu.fileDialogUpload()` to make it easy.
*/
import * as browserGlobals from 'app/client/lib/browserGlobals';
import * as dom from 'app/client/lib/dom';
const G = browserGlobals.get('document', 'window');
export interface FileDialogOptions {
multiple?: boolean; // Whether multiple files may be selected.
accept?: string; // Comma-separated list of content-type specifiers,
// e.g. ".jpg,.png", "text/plain", "audio/*", "video/*", "image/*".
}
type FilesCB = (files: File[]) => void;
function noop() { /* no-op */ }
let _fileForm: HTMLFormElement;
let _fileInput: HTMLInputElement;
let _currentCB: FilesCB = noop;
/**
* Opens the file picker dialog, and returns a Promise for the list of selected files.
* WARNING: The Promise might NEVER resolve. If the user dismisses the dialog without picking a
* file, there is no good way to detect that in order to resolve the promise.
* Do NOT rely on the promise resolving, e.g. on .finally() getting called.
* The implementation MAY resolve with an empty list in this case, when possible.
*
* This does not cause indefinite memory leaks. If the dialog is opened again, the reference to
* the previous callback is cleared, and GC can collect the forgotten promise and related memory.
*
* Ideally we'd know when the dialog is dismissed without a selection, but that seems impossible
* today. See https://stackoverflow.com/questions/4628544/how-to-detect-when-cancel-is-clicked-on-file-input
* (tricks using click, focus, blur, etc are unreliable even in one browser, much less cross-platform).
*/
export function openFilePicker(options: FileDialogOptions): Promise<File[]> {
return new Promise(resolve => open(options, resolve));
}
/**
* Opens the file picker dialog. If files are selected, calls the provided callback.
* If no files are seleced, will call the callback with an empty list if possible, or more
* typically not call it at all.
*/
export function open(options: FileDialogOptions, callback: FilesCB): void {
if (!_fileInput) {
// The IDs are only needed for the sake of browser tests.
_fileForm = dom('form#file_dialog_form', {style: 'position: absolute; top: 0; display: none'},
_fileInput = dom('input#file_dialog_input', {type: 'file'}));
G.document.body.appendChild(_fileForm);
_fileInput.addEventListener('change', (ev) => {
_currentCB(_fileInput.files ? Array.from(_fileInput.files) : []);
_currentCB = noop;
});
}
// Clear the input, to make sure that selecting the same file as previously still
// triggers a 'change' event.
_fileForm.reset();
_fileInput.multiple = Boolean(options.multiple);
_fileInput.accept = options.accept || '';
_currentCB = callback;
// .click() is a well-supported shorthand for dispatching a mouseclick event on input elements.
// We do it in a separate tick to work around a rare Firefox bug.
setTimeout(() => _fileInput.click(), 0);
}

View File

@@ -0,0 +1,60 @@
import { ViewSectionRec } from "app/client/models/DocModel";
import { KoSaveableObservable, setSaveValue } from "app/client/models/modelUtil";
import { cssLabel, cssRow } from "app/client/ui/RightPanel";
import { squareCheckbox } from "app/client/ui2018/checkbox";
import { testId } from "app/client/ui2018/cssVars";
import { Computed, Disposable, dom, IDisposableOwner, styled } from "grainjs";
/**
* Builds the grid options.
*/
export class GridOptions extends Disposable {
constructor(private _section: ViewSectionRec) {
super();
}
public buildDom() {
const section = this._section;
return [
cssLabel('Grid Options'),
dom('div', [
cssRow(
checkbox(setSaveValueFromKo(this, section.optionsObj.prop('verticalGridlines'))),
'Vertical Gridlines',
testId('v-grid-button')
),
cssRow(
checkbox(setSaveValueFromKo(this, section.optionsObj.prop('horizontalGridlines'))),
'Horizontal Gridlines',
testId('h-grid-button')
),
cssRow(
checkbox(setSaveValueFromKo(this, section.optionsObj.prop('zebraStripes'))),
'Zebra Stripes',
testId('zebra-stripe-button')
),
testId('grid-options')
]),
];
}
}
// Returns a grainjs observable that reflects the value of obs a knockout saveable observable. The
// returned observable will set and save obs to the given value when written. If the obs.save() call
// fails, then it gets reset to its previous value.
function setSaveValueFromKo<T>(owner: IDisposableOwner, obs: KoSaveableObservable<T>) {
const ret = Computed.create(null, (use) => use(obs));
ret.onWrite(async (val) => {
await setSaveValue(obs, val);
});
return ret;
}
const checkbox = styled(squareCheckbox, `
margin-right: 8px;
`);

View File

@@ -0,0 +1,239 @@
import {allCommands} from 'app/client/components/commands';
import {testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menuDivider, menuItem, menuItemCmd} from 'app/client/ui2018/menus';
import {dom, DomElementArg, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
interface IView {
addNewColumn: () => void;
showColumn: (colId: number, atIndex: number) => void;
}
interface IViewSection {
viewFields: any;
hiddenColumns: any;
}
/**
* Creates a menu to add a new column. Should be used only when there are hidden columns to display,
* otherwise there is no need for this menu.
*/
export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) {
return [
menuItem(() => gridView.addNewColumn(), 'Add Column'),
menuDivider(),
...viewSection.hiddenColumns().map((col: any) => menuItem(
() => {
gridView.showColumn(col.id(), viewSection.viewFields().peekLength);
// .then(() => gridView.scrollPaneRight());
}, `Show column ${col.label()}`))
];
}
interface IRowContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
}
export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: IRowContextMenu) {
const result: Element[] = [];
if (isViewSorted) {
// When the view is sorted, any newly added records get shifts instantly at the top or
// bottom. It could be very confusing for users who might expect the record to stay above or
// below the active row. Thus in this case we show a single `insert row` command.
result.push(
menuItemCmd(allCommands.insertRecordAfter, 'Insert row',
dom.cls('disabled', disableInsert)),
);
} else {
result.push(
menuItemCmd(allCommands.insertRecordBefore, 'Insert row above',
dom.cls('disabled', disableInsert)),
menuItemCmd(allCommands.insertRecordAfter, 'Insert row below',
dom.cls('disabled', disableInsert)),
);
}
result.push(
menuDivider(),
menuItemCmd(allCommands.deleteRecords, 'Delete',
dom.cls('disabled', disableDelete)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.copyLink, 'Copy anchor link'));
return result;
}
interface IColumnContextMenu {
disableModify: boolean;
filterOpenFunc: () => void;
useNewUI: boolean;
sortSpec: number[];
colId: number;
isReadonly: boolean;
}
export function ColumnContextMenu(options: IColumnContextMenu) {
const { disableModify, filterOpenFunc, useNewUI, colId, sortSpec, isReadonly } = options;
if (useNewUI) {
const addToSortLabel = getAddToSortLabel(sortSpec, colId);
return [
menuItemCmd(allCommands.fieldTabOpen, 'Column Options'),
menuItem(filterOpenFunc, 'Filter Data'),
menuDivider({style: 'margin-bottom: 0;'}),
cssRowMenuItem(
customMenuItem(
allCommands.sortAsc.run,
dom('span', 'Sort', {style: 'flex: 1 0 auto; margin-right: 8px;'},
testId('sort-label')),
icon('Sort', dom.style('transform', 'scaley(-1)')),
'A-Z',
dom.style('flex', ''),
cssCustomMenuItem.cls('-selected', isEqual(sortSpec, [colId])),
testId('sort-asc'),
),
customMenuItem(
allCommands.sortDesc.run,
icon('Sort'),
'Z-A',
cssCustomMenuItem.cls('-selected', isEqual(sortSpec, [-colId])),
testId('sort-dsc'),
),
testId('sort'),
),
menuDivider({style: 'margin-bottom: 0; margin-top: 0;'}),
addToSortLabel ? [
cssRowMenuItem(
customMenuItem(
allCommands.addSortAsc.run,
cssRowMenuLabel(addToSortLabel, testId('add-to-sort-label')),
icon('Sort', dom.style('transform', 'scaley(-1)')),
'A-Z',
cssCustomMenuItem.cls('-selected', sortSpec.includes(colId)),
testId('add-to-sort-asc'),
),
customMenuItem(
allCommands.addSortDesc.run,
icon('Sort'),
'Z-A',
cssCustomMenuItem.cls('-selected', sortSpec.includes(-colId)),
testId('add-to-sort-dsc'),
),
testId('add-to-sort'),
),
menuDivider({style: 'margin-top: 0;'}),
] : null,
menuItemCmd(allCommands.renameField, 'Rename column',
dom.cls('disabled', disableModify || isReadonly)),
menuItemCmd(allCommands.hideField, 'Hide column',
dom.cls('disabled', isReadonly)),
menuItemCmd(allCommands.deleteFields, 'Delete column',
dom.cls('disabled', disableModify || isReadonly)),
testId('column-menu'),
// TODO: this piece should be removed after adding the new way to add column
menuDivider(),
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left',
dom.cls('disabled', isReadonly)),
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right',
dom.cls('disabled', isReadonly)),
];
} else {
return [
menuItemCmd(allCommands.fieldTabOpen, 'FieldOptions'),
menuDivider(),
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left'),
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right'),
menuDivider(),
menuItemCmd(allCommands.renameField, 'Rename column',
dom.cls('disabled', disableModify)),
menuItemCmd(allCommands.hideField, 'Hide column'),
menuItemCmd(allCommands.deleteFields, 'Delete column',
dom.cls('disabled', disableModify)),
menuItem(filterOpenFunc, 'Filter'),
menuDivider(),
menuItemCmd(allCommands.sortAsc, 'Sort ascending'),
menuItemCmd(allCommands.sortDesc, 'Sort descending'),
menuItemCmd(allCommands.addSortAsc, 'Add to sort as ascending'),
menuItemCmd(allCommands.addSortDesc, 'Add to sort as descending'),
];
}
}
interface IMultiColumnContextMenu {
isReadonly: boolean;
}
export function MultiColumnMenu(options: IMultiColumnContextMenu) {
const {isReadonly} = options;
return [
menuItemCmd(allCommands.insertFieldBefore, 'Insert column to the left',
dom.cls('disabled', isReadonly)),
menuItemCmd(allCommands.insertFieldAfter, 'Insert column to the right',
dom.cls('disabled', isReadonly)),
menuDivider(),
menuItemCmd(allCommands.deleteFields, 'Delete columns',
dom.cls('disabled', isReadonly)),
];
}
// Returns 'Add to sort' is there are columns in the sort spec but colId is not part of it. Returns
// undefined if colId is the only column in the spec. Otherwise returns `Sorted (#N)` where #N is
// the position (1 based) of colId in the spec.
function getAddToSortLabel(sortSpec: number[], colId: number): string|undefined {
const columnsInSpec = sortSpec.map((n) => Math.abs(n));
if (sortSpec.length !== 0 && !isEqual(columnsInSpec, [colId])) {
const index = columnsInSpec.indexOf(colId);
if (index > -1) {
return `Sorted (#${index + 1})`;
} else {
return 'Add to sort';
}
}
}
const cssRowMenuItem = styled((...args: DomElementArg[]) => dom('li', {tabindex: '-1'}, ...args), `
display: flex;
outline: none;
`);
const cssRowMenuLabel = styled('div', `
margin-right: 8px;
flex: 1 0 auto;
`);
const cssCustomMenuItem = styled('div', `
padding: 8px 8px;
display: flex;
&:not(:hover) {
background-color: white;
color: black;
--icon-color: black;
}
&:last-of-type {
padding-right: 24px;
flex: 0 0 auto;
}
&:first-of-type {
padding-left: 24px;
flex: 1 0 auto;
}
&-selected, &-selected:not(:hover) {
background-color: ${vars.primaryBg};
color: white;
--icon-color: white;
}
`);
function customMenuItem(action: () => void, ...args: DomElementArg[]) {
const element: HTMLElement = cssCustomMenuItem(
...args,
dom.on('click', () => action()),
);
return element;
}

View File

@@ -0,0 +1,112 @@
import {guessTimezone} from 'app/client/lib/guessTimezone';
import {IMPORTABLE_EXTENSIONS, uploadFiles} from 'app/client/lib/uploads';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {IProgress} from 'app/client/models/NotifyModel';
import {openFilePicker} from 'app/client/ui/FileDialog';
import {byteString} from 'app/common/gutil';
import {Disposable} from 'grainjs';
/**
* Imports a document and returns its docId, or null if no files were selected.
*/
export async function docImport(app: AppModel, workspaceId: number|"unsaved"): Promise<string|null> {
// We use openFilePicker() and uploadFiles() separately, rather than the selectFiles() helper,
// because we only want to connect to a docWorker if there are in fact any files to upload.
// Start selecting files. This needs to start synchronously to be seen as a user-initiated
// popup, or it would get blocked by default in a typical browser.
const files: File[] = await openFilePicker({
multiple: false,
accept: IMPORTABLE_EXTENSIONS.join(","),
});
if (!files.length) { return null; }
// There is just one file (thanks to {multiple: false} above).
const progressUI = app.notifier.createProgressIndicator(files[0].name, byteString(files[0].size));
const progress = ImportProgress.create(progressUI, progressUI, files[0]);
try {
const timezone = await guessTimezone();
if (workspaceId === "unsaved") {
function onUploadProgress(ev: ProgressEvent) {
if (ev.lengthComputable) {
progress.setUploadProgress(ev.loaded / ev.total * 100); // percentage complete
}
}
return await app.api.importUnsavedDoc(files[0], {timezone, onUploadProgress});
} else {
// Connect to a docworker. Imports share some properties of documents but not all. In place of
// docId, for the purposes of work allocation, we use the special assigmentId `import`.
const docWorker = await app.api.getWorkerAPI('import');
// This uploads to the docWorkerUrl saved in window.gristConfig
const uploadResult = await uploadFiles(files, {docWorkerUrl: docWorker.url, sizeLimit: 'import'},
(p) => progress.setUploadProgress(p));
const importResult = await docWorker.importDocToWorkspace(uploadResult!.uploadId, workspaceId, {timezone});
return importResult.id;
}
} catch (err) {
reportError(err);
return null;
} finally {
progress.finish();
// Dispose the indicator UI and the progress timer owned by it.
progressUI.dispose();
}
}
export class ImportProgress extends Disposable {
// Import does upload first, then import. We show a single indicator, estimating which fraction
// of the time should be given to upload (whose progress we can report well), and which to the
// subsequent import (whose progress indicator is mostly faked).
private _uploadFraction: number;
private _estImportSeconds: number;
private _importTimer: null | ReturnType<typeof setInterval> = null;
private _importStart: number = 0;
constructor(private _progressUI: IProgress, file: File) {
super();
// We'll assume that for .grist files, the upload takes 90% of the total time, and for other
// files, 40%.
this._uploadFraction = file.name.endsWith(".grist") ? 0.9 : 0.4;
// TODO: Import step should include a progress callback, to be combined with upload progress.
// Without it, we estimate import to take 2s per MB (non-scientific unreliable estimate), and
// use an asymptotic indicator which keeps moving without ever finishing. Not terribly useful,
// but does slow down for larger files, and is more comforting than a stuck indicator.
this._estImportSeconds = file.size / 1024 / 1024 * 2;
this._progressUI.setProgress(0);
this.onDispose(() => this._importTimer && clearInterval(this._importTimer));
}
// Once this reaches 100, the import stage begins.
public setUploadProgress(percentage: number) {
this._progressUI.setProgress(percentage * this._uploadFraction);
if (percentage >= 100 && !this._importTimer) {
this._importStart = Date.now();
this._importTimer = setInterval(() => this._onImportTimer(), 100);
}
}
public finish() {
if (this._importTimer) {
clearInterval(this._importTimer);
}
this._progressUI.setProgress(100);
}
/**
* Calls _progressUI.setProgress(percent) with percentage increasing from 0 and asymptotically
* approaching 100, reaching 50% after estSeconds. It's intended to look reasonable when the
* estimate is good, and to keep showing slowing progress even if it's not.
*/
private _onImportTimer() {
const elapsedSeconds = (Date.now() - this._importStart) / 1000;
const importProgress = elapsedSeconds / (elapsedSeconds + this._estImportSeconds);
const progress = this._uploadFraction + importProgress * (1 - this._uploadFraction);
this._progressUI.setProgress(100 * progress);
}
}

204
app/client/ui/HomeIntro.ts Normal file
View File

@@ -0,0 +1,204 @@
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {docUrl, getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {HomeModel, ViewSettings} from 'app/client/models/HomeModel';
import * as css from 'app/client/ui/DocMenuCss';
import {examples} from 'app/client/ui/ExampleInfo';
import {createDocAndOpen, importDocAndOpen} from 'app/client/ui/HomeLeftPane';
import {buildPinnedDoc} from 'app/client/ui/PinnedDocs';
import {bigBasicButton} from 'app/client/ui2018/buttons';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {commonUrls} from 'app/common/gristUrls';
import {Document, Workspace} from 'app/common/UserAPI';
import {dom, DomContents, DomCreateFunc, styled} from 'grainjs';
export function buildHomeIntro(homeModel: HomeModel): DomContents {
const user = homeModel.app.currentValidUser;
if (user) {
return [
css.docListHeader(`Welcome to Grist, ${user.name}!`, testId('welcome-title')),
cssIntroSplit(
cssIntroLeft(
cssIntroImage({src: 'https://www.getgrist.com/themes/grist/assets/images/empty-folder.png'}),
testId('intro-image'),
),
cssIntroRight(
cssParagraph(
'Watch video on ',
cssLink({href: 'https://support.getgrist.com/creating-doc/', target: '_blank'}, 'creating a document'),
'.', dom('br'),
'Learn more in our ', cssLink({href: commonUrls.help, target: '_blank'}, 'Help Center'), '.',
testId('welcome-text')
),
makeCreateButtons(homeModel),
),
),
];
} else {
return [
cssIntroSplit(
cssIntroLeft(
cssLink({href: 'https://support.getgrist.com/creating-doc/', target: '_blank'},
cssIntroImage({src: 'https://www.getgrist.com/themes/grist/assets/images/video-create-doc.png'}),
),
testId('intro-image'),
),
cssIntroRight(
css.docListHeader('Welcome to Grist!', testId('welcome-title')),
cssParagraph(
'You can explore and experiment without logging in. ',
'To save your work, however, youll need to ',
cssLink({href: getLoginOrSignupUrl()}, 'sign up'), '.', dom('br'),
'Learn more in our ', cssLink({href: commonUrls.help, target: '_blank'}, 'Help Center'), '.',
testId('welcome-text')
),
makeCreateButtons(homeModel),
),
),
];
}
}
function makeCreateButtons(homeModel: HomeModel) {
return cssBtnGroup(
cssBtn(cssBtnIcon('Import'), 'Import Document', testId('intro-import-doc'),
dom.on('click', () => importDocAndOpen(homeModel)),
),
cssBtn(cssBtnIcon('Page'), 'Create Empty Document', testId('intro-create-doc'),
dom.on('click', () => createDocAndOpen(homeModel)),
),
);
}
export function buildExampleList(home: HomeModel, workspace: Workspace, viewSettings: ViewSettings) {
const hideExamplesObs = localStorageBoolObs('hide-examples');
return cssDocBlock(
dom.autoDispose(hideExamplesObs),
cssDocBlockHeader(css.docBlockHeaderLink.cls(''), css.docHeaderIcon('FieldTable'), 'Examples & Templates',
dom.domComputed(hideExamplesObs, (collapsed) =>
collapsed ? cssCollapseIcon('Expand') : cssCollapseIcon('Collapse')
),
dom.on('click', () => hideExamplesObs.set(!hideExamplesObs.get())),
testId('examples-header'),
),
dom.maybe((use) => !use(hideExamplesObs), () => _buildExampleListDocs(home, workspace, viewSettings)),
css.docBlock.cls((use) => '-' + use(home.currentView)),
testId('examples-list'),
);
}
export function buildExampleListBody(home: HomeModel, workspace: Workspace, viewSettings: ViewSettings) {
return cssDocBlock(
_buildExampleListDocs(home, workspace, viewSettings),
css.docBlock.cls((use) => '-' + use(viewSettings.currentView)),
testId('examples-body'),
);
}
function _buildExampleListDocs(home: HomeModel, workspace: Workspace, viewSettings: ViewSettings) {
return [
cssParagraph(
'Explore these examples, read tutorials based on them, or use any of them as a template.',
testId('examples-desc'),
),
dom.domComputed(viewSettings.currentView, (view) =>
dom.forEach(workspace.docs, doc => buildExampleItem(doc, home, view))
),
];
}
function buildExampleItem(doc: Document, home: HomeModel, view: 'list'|'icons') {
const ex = examples.find((e) => e.matcher.test(doc.name));
if (view === 'icons') {
return buildPinnedDoc(home, doc, ex);
} else {
return css.docRowWrapper(
cssDocRowLink(
urlState().setLinkUrl(docUrl(doc)),
cssDocName(ex?.title || doc.name, testId('examples-doc-name')),
ex ? cssItemDetails(ex.desc, testId('examples-desc')) : null,
),
testId('examples-doc'),
);
}
}
const cssIntroSplit = styled(css.docBlock, `
display: flex;
align-items: center;
`);
const cssIntroLeft = styled('div', `
flex: 0.4 1 0px;
overflow: hidden;
max-height: 150px;
text-align: center;
`);
const cssIntroRight = styled('div', `
flex: 0.6 1 0px;
overflow: auto;
margin-left: 8px;
`);
const cssParagraph = styled(css.docBlock, `
line-height: 1.6;
`);
const cssBtnGroup = styled('div', `
display: inline-flex;
flex-direction: column;
align-items: stretch;
margin-top: -16px;
`);
const cssBtn = styled(bigBasicButton, `
display: block;
margin-right: 16px;
margin-top: 16px;
text-align: left;
`);
const cssBtnIcon = styled(icon, `
margin-right: 8px;
`);
const cssDocRowLink = styled(css.docRowLink, `
display: block;
height: unset;
line-height: 1.6;
padding: 8px 0;
`);
const cssDocName = styled(css.docName, `
margin: 0 16px;
`);
const cssItemDetails = styled('div', `
margin: 0 16px;
line-height: 1.6;
color: ${colors.slate};
`);
const cssDocBlock = styled(css.docBlock, `
margin-top: 32px;
`);
const cssDocBlockHeader = styled('div', `
cursor: pointer;
`);
const cssCollapseIcon = styled(css.docHeaderIcon, `
margin-left: 8px;
`);
// Helper to create an image scaled down to half of its intrinsic size.
// Based on https://stackoverflow.com/a/25026615/328565
const cssIntroImage: DomCreateFunc<HTMLDivElement> =
(...args) => _cssImageWrap1(_cssImageWrap2(_cssImageScaled(...args)));
const _cssImageWrap1 = styled('div', `width: 200%; margin-left: -50%;`);
const _cssImageWrap2 = styled('div', `display: inline-block;`);
const _cssImageScaled = styled('img', `width: 50%;`);

View File

@@ -0,0 +1,237 @@
import {loadUserManager} from 'app/client/lib/imports';
import {reportError} from 'app/client/models/AppModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {HomeModel} from 'app/client/models/HomeModel';
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
import {docImport} from 'app/client/ui/HomeImports';
import {createHelpTools, cssLeftPanel, cssScrollPane, cssSectionHeader, cssTools} from 'app/client/ui/LeftPanelCommon';
import {cssLinkText, cssPageEntry, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon';
import {transientInput} from 'app/client/ui/transientInput';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuIcon, menuItem, upgradableMenuItem, upgradeText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import * as roles from 'app/common/roles';
import {Workspace} from 'app/common/UserAPI';
import {computed, dom, DomElementArg, Observable, observable, styled} from 'grainjs';
export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: HomeModel) {
const creating = observable<boolean>(false);
const renaming = observable<Workspace|null>(null);
const samplesWorkspace = computed<Workspace|undefined>((use) =>
use(home.workspaces).find((ws) => Boolean(ws.isSupportWorkspace)));
return cssContent(
dom.autoDispose(creating),
dom.autoDispose(renaming),
dom.autoDispose(samplesWorkspace),
addNewButton(leftPanelOpen,
menu(() => addMenu(home, creating), {
placement: 'bottom-start',
// "Add New" menu should have the same width as the "Add New" button that opens it.
stretchToSelector: `.${cssAddNewButton.className}`
}),
testId('dm-add-new')
),
cssScrollPane(
cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "all"),
cssPageLink(cssPageIcon('Home'),
cssLinkText('All Documents'),
urlState().setLinkUrl({ws: undefined, homePage: undefined}),
testId('dm-all-docs'),
),
),
dom.maybe(use => !use(home.singleWorkspace), () =>
cssSectionHeader('Workspaces',
// Give it a testId, because it's a good element to simulate "click-away" in tests.
testId('dm-ws-label')
),
),
dom.forEach(home.workspaces, (ws) => {
if (ws.isSupportWorkspace) { return null; }
const isTrivial = computed((use) => Boolean(getWorkspaceInfo(home.app, ws).isDefault &&
use(home.singleWorkspace)));
// TODO: Introduce a "SwitchSelector" pattern to avoid the need for N computeds (and N
// recalculations) to select one of N items.
const isRenaming = computed((use) => use(renaming) === ws);
return cssPageEntry(
dom.autoDispose(isRenaming),
dom.autoDispose(isTrivial),
dom.hide(isTrivial),
cssPageEntry.cls('-selected', (use) => use(home.currentWSId) === ws.id),
cssPageLink(cssPageIcon('Folder'), cssLinkText(workspaceName(home.app, ws)),
dom.hide(isRenaming),
urlState().setLinkUrl({ws: ws.id}),
cssMenuTrigger(icon('Dots'),
menu(() => workspaceMenu(home, ws, renaming),
{placement: 'bottom-start', parentSelectorToMark: '.' + cssPageEntry.className}),
// Clicks on the menu trigger shouldn't follow the link that it's contained in.
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
testId('dm-workspace-options'),
),
testId('dm-workspace'),
),
cssPageEntry.cls('-renaming', isRenaming),
dom.maybe(isRenaming, () =>
cssPageLink(cssPageIcon('Folder'),
cssEditorInput({
initialValue: ws.name || '',
save: async (val) => (val !== ws.name) ? home.renameWorkspace(ws.id, val) : undefined,
close: () => renaming.set(null),
}, testId('dm-ws-name-editor'))
)
),
);
}),
dom.maybe(creating, () => cssPageEntry(
cssPageLink(cssPageIcon('Folder'),
cssEditorInput({
initialValue: '',
save: async (val) => (val !== '') ? home.createWorkspace(val) : undefined,
close: () => creating.set(false),
}, testId('dm-ws-name-editor'))
)
)),
cssTools(
dom.maybe(samplesWorkspace, (ws) =>
cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(home.currentWSId) === ws.id),
cssPageLink(cssPageIcon('FieldTable'), cssLinkText(workspaceName(home.app, ws)),
urlState().setLinkUrl({ws: ws.id}),
testId('dm-samples-workspace'),
),
),
),
cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(home.currentPage) === "trash"),
cssPageLink(cssPageIcon('Remove'), cssLinkText("Trash"),
urlState().setLinkUrl({homePage: "trash"}),
testId('dm-trash'),
),
),
createHelpTools(home.app),
)
)
);
}
export async function createDocAndOpen(home: HomeModel) {
const destWS = home.newDocWorkspace.get();
if (!destWS) { return; }
try {
const docId = await home.createDoc("Untitled document", destWS === "unsaved" ? "unsaved" : destWS.id);
// Fetch doc information including urlId.
// TODO: consider changing API to return same response as a GET when creating an
// object, which is a semi-standard.
const doc = await home.app.api.getDoc(docId);
await urlState().pushUrl(docUrl(doc));
} catch (err) {
reportError(err);
}
}
export async function importDocAndOpen(home: HomeModel) {
const destWS = home.newDocWorkspace.get();
if (!destWS) { return; }
const docId = await docImport(home.app, destWS === "unsaved" ? "unsaved" : destWS.id);
if (docId) {
const doc = await home.app.api.getDoc(docId);
await urlState().pushUrl(docUrl(doc));
}
}
function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[] {
const org = home.app.currentOrg;
const orgAccess: roles.Role|null = org ? org.access : null;
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
return [
menuItem(() => createDocAndOpen(home), menuIcon('Page'), "Create Empty Document",
dom.cls('disabled', !home.newDocWorkspace.get()),
testId("dm-new-doc")
),
menuItem(() => importDocAndOpen(home), menuIcon('Import'), "Import Document",
dom.cls('disabled', !home.newDocWorkspace.get()),
testId("dm-import")
),
// For workspaces: if ACL says we can create them, but product says we can't,
// then offer an upgrade link.
upgradableMenuItem(needUpgrade, () => creating.set(true), menuIcon('Folder'), "Create Workspace",
dom.cls('disabled', (use) => !roles.canEdit(orgAccess) || !use(home.available)),
testId("dm-new-workspace")
),
upgradeText(needUpgrade),
];
}
function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Workspace|null>) {
function deleteWorkspace() {
confirmModal(`Delete ${ws.name} and all included documents?`, 'Delete',
() => home.deleteWorkspace(ws.id, false),
'Workspace will be moved to Trash.');
}
async function manageWorkspaceUsers() {
const api = home.app.api;
const user = home.app.currentUser;
(await loadUserManager()).showUserManagerModal(api, {
permissionData: api.getWorkspaceAccess(ws.id),
activeEmail: user ? user.email : null,
resourceType: 'workspace',
resourceId: ws.id
});
}
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
return [
upgradableMenuItem(needUpgrade, () => renaming.set(ws), "Rename",
dom.cls('disabled', !roles.canEdit(ws.access)),
testId('dm-rename-workspace')),
upgradableMenuItem(needUpgrade, deleteWorkspace, "Delete",
dom.cls('disabled', user => !roles.canEdit(ws.access)),
testId('dm-delete-workspace')),
upgradableMenuItem(needUpgrade, manageWorkspaceUsers, "Manage Users",
dom.cls('disabled', !roles.canEditAccess(ws.access)),
testId('dm-workspace-access')),
upgradeText(needUpgrade),
];
}
// Below are all the styled elements.
const cssContent = styled(cssLeftPanel, `
--page-icon-margin: 12px;
`);
export const cssEditorInput = styled(transientInput, `
height: 24px;
flex: 1 1 0px;
min-width: 0px;
color: initial;
margin-right: 16px;
font-size: inherit;
`);
const cssMenuTrigger = styled('div', `
margin: 0 4px 0 auto;
height: 24px;
width: 24px;
padding: 4px;
line-height: 0px;
border-radius: 3px;
cursor: default;
display: none;
.${cssPageLink.className}:hover > &, &.weasel-popup-open {
display: block;
}
&:hover, &.weasel-popup-open {
background-color: ${colors.darkGrey};
}
.${cssPageEntry.className}-selected &:hover, .${cssPageEntry.className}-selected &.weasel-popup-open {
background-color: ${colors.slate};
}
`);

View File

@@ -0,0 +1,148 @@
/**
* These styles are used in HomeLeftPanel, and in Tools for the document left panel.
* They work in a structure like this:
*
* import * as css from 'app/client/ui/LeftPanelStyles';
* css.cssLeftPanel(
* css.cssScrollPane(
* css.cssTools(
* css.cssSectionHeader(...),
* css.cssPageEntry(css.cssPageLink(cssPageIcon(...), css.cssLinkText(...))),
* css.cssPageEntry(css.cssPageLink(cssPageIcon(...), css.cssLinkText(...))),
* )
* )
* )
*/
import {beaconOpenMessage} from 'app/client/lib/helpScout';
import {AppModel} from 'app/client/models/AppModel';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {commonUrls} from 'app/common/gristUrls';
import {dom, DomContents, Observable, styled} from 'grainjs';
/**
* Creates the "help tools", a button/link to open HelpScout beacon, and one to open the
* HelpCenter in a new tab.
*/
export function createHelpTools(appModel: AppModel, spacer = true): DomContents {
const isEfcr = (appModel.topAppModel.productFlavor === 'efcr');
return [
spacer ? cssSpacer() : null,
cssPageEntry(
cssPageLink(cssPageIcon('Feedback'), cssLinkText('Give Feedback'),
dom.on('click', () => beaconOpenMessage({appModel}))),
dom.hide(isEfcr),
testId('left-feedback'),
),
cssPageEntry(
cssPageLink(cssPageIcon('Help'), {href: commonUrls.help, target: '_blank'}, cssLinkText('Help Center')),
dom.hide(isEfcr),
),
];
}
/**
* Creates a basic left panel, used in error and billing pages. It only contains the help tools.
*/
export function leftPanelBasic(appModel: AppModel, panelOpen: Observable<boolean>) {
return cssLeftPanel(
cssScrollPane(
cssTools(
cssTools.cls('-collapsed', (use) => !use(panelOpen)),
createHelpTools(appModel),
)
)
);
}
export const cssLeftPanel = styled('div', `
flex: 1 1 0px;
font-size: ${vars.mediumFontSize};
display: flex;
flex-direction: column;
`);
export const cssScrollPane = styled('div', `
flex: 1 1 0px;
overflow: hidden auto;
display: flex;
flex-direction: column;
`);
export const cssTools = styled('div', `
flex: none;
margin-top: auto;
padding: 16px 0 16px 0;
`);
export const cssSectionHeader = styled('div', `
margin: 24px 0 8px 24px;
color: ${colors.slate};
text-transform: uppercase;
font-weight: 500;
font-size: ${vars.xsmallFontSize};
letter-spacing: 1px;
.${cssTools.className}-collapsed > & {
visibility: hidden;
}
`);
export const cssPageEntry = styled('div', `
margin: 0px 16px 0px 0px;
border-radius: 0 3px 3px 0;
color: ${colors.dark};
--icon-color: ${colors.slate};
cursor: default;
&:hover, &.weasel-popup-open, &-renaming {
background-color: ${colors.mediumGrey};
}
&-selected, &-selected:hover, &-selected.weasel-popup-open {
background-color: ${colors.darkBg};
color: ${colors.light};
--icon-color: ${colors.light};
}
.${cssTools.className}-collapsed > & {
margin-right: 0;
}
`);
export const cssPageLink = styled('a', `
display: flex;
align-items: center;
height: 32px;
line-height: 32px;
padding-left: 24px;
outline: none;
cursor: pointer;
&, &:hover, &:focus {
text-decoration: none;
outline: none;
color: inherit;
}
.${cssTools.className}-collapsed & {
padding-left: 16px;
}
`);
export const cssLinkText = styled('span', `
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.${cssTools.className}-collapsed & {
display: none;
}
`);
export const cssPageIcon = styled(icon, `
flex: none;
margin-right: var(--page-icon-margin, 8px);
.${cssTools.className}-collapsed & {
margin-right: 0;
}
`);
export const cssSpacer = styled('div', `
height: 18px;
`);

View 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;
`);

View File

@@ -0,0 +1,157 @@
/**
* Usage in Grist: Multi-selector component serves as the base class for Sorting, Filtering,
* Group-By, Linking, and possibly other widgets in Grist.
*
* The multi-selector component allows the user to create an ordered list of items selected from a
* unique list. Visually it shows a list of Items, and a link to add a new item. Each item shows a
* trash-can icon to remove it. Optionally, items may be reordered relative to each other using a
* dragger. Each Item contains a dropdown which shows a fixed set of options (e.g. column names).
* Options already selected (present as other Items) are omitted from the list.
*
* Using the link to add a new item creates an "Empty Item" row, which then gets added to
* the list and becomes a real item when the user chooses a value. Note that the Empty Item
* uses the empty string '' as its value, so it is not a valid value in the list of items.
*
* The MultiSelect class may be extended to be used with enhanced items, to show additional UI (to
* control additional properties of items, e.g. ascending/descending for sorting), and to provide
* custom implementation for changes (e.g. adding an item may involve a request to the server).
*
* TODO: Implement optional reordering of items
* TODO: Optionally omit selected items from list
*/
import { computed, MutableObsArray, ObsArray, observable, Observable } from 'grainjs';
import { Disposable, dom, makeTestId, select, styled } from 'grainjs';
import { button1 } from './buttons';
export interface BaseItem {
value: any;
label: string;
}
const testId = makeTestId('test-ms-');
export abstract class MultiItemSelector<Item extends BaseItem> extends Disposable {
constructor(private _incItems: MutableObsArray<Item>, private _allItems: ObsArray<Item>,
private _options: {
addItemLabel: string,
addItemText: string
}) {
super();
}
public buildDom() {
return cssMultiSelectorWrapper(
cssItemList(testId('list'),
dom.forEach(this._incItems, item => this.buildItemDom(item)),
this.buildAddItemDom(this._options.addItemLabel, this._options.addItemText)
),
);
}
// Must be overridden to return list of available items. Items already present in the items
// array (according to value) may be safely included, and will not be shown in the select-box.
// The default implementations update items array, but may be overridden.
protected async add(item: Item): Promise<void> {
this._incItems.push(item);
}
// Called with an item from `_allItems`
protected async remove(item: Item): Promise<void> {
const idx = this.findIncIndex(item);
if (idx === -1) { return; }
this._incItems.splice(idx, 1);
}
// TODO: Called with an item in the items array
protected async reorder(item: Item, nextItem: Item): Promise<void> { return; }
// Replaces an existing item (if found) with a new one
protected async changeItem(item: Item, newItem: Item): Promise<void> {
const idx = this.findIncIndex(item);
if (idx === -1) { return; }
this._incItems.splice(idx, 1, newItem);
}
// Exposed for use by custom buildItemDom().
protected buildDragHandle(item: Item): Element { return new Element(); }
protected buildSelectBox(selectedValue: string,
selectCb: (newItem: Item) => void,
selectOptions?: {}): Element {
const obs = computed(use => selectedValue).onWrite(async value => {
const newItem = this.findItemByValue(value);
if (newItem) {
selectCb(newItem);
}
});
const result = select(
obs,
this._allItems,
selectOptions
);
dom.autoDisposeElem(result, obs);
return result;
}
protected buildRemoveButton(removeCb: () => void): Element {
return cssItemRemove(testId('remove-btn'),
dom.on('click', removeCb),
'✖'
);
}
// May be overridden for custom-looking items.
protected buildItemDom(item: Item): Element {
return dom('li', testId('item'),
// this.buildDragHandle(item), TODO: once dragging is implemented
this.buildSelectBox(item.value, async newItem => this.changeItem(item, newItem)),
this.buildRemoveButton(() => this.remove(item))
);
}
// Returns the index (order) of the item if it's been included, or -1 otherwise.
private findIncIndex(item: Item): number {
return this._incItems.get().findIndex(_item => _item === item);
}
// Returns the item object given it's value, or undefined if not found.
private findItemByValue(value: string): Item | undefined {
return this._allItems.get().find(_item => _item.value === value);
}
// Builds the about-to-be-added item
private buildAddItemDom(defLabel: string, defText: string): Element {
const addNewItem: Observable<boolean> = observable(false);
return dom('li', testId('add-item'),
dom.domComputed(addNewItem, isAdding => isAdding
? dom.frag(
this.buildSelectBox('', async newItem => {
await this.add(newItem);
addNewItem.set(false);
}, { defLabel }),
this.buildRemoveButton(() => addNewItem.set(false)))
: button1(defText, testId('add-btn'),
dom.on('click', () => addNewItem.set(true)))
)
);
}
}
const cssMultiSelectorWrapper = styled('div', `
border: 1px solid blue;
`);
const cssItemList = styled('ul', `
list-style-type: none;
`);
const cssItemRemove = styled('span', `
padding: 0 .5rem;
vertical-align: middle;
cursor: pointer;
`);

320
app/client/ui/NotifyUI.ts Normal file
View File

@@ -0,0 +1,320 @@
import {beaconOpenMessage, IBeaconOpenOptions} from 'app/client/lib/helpScout';
import {AppModel} from 'app/client/models/AppModel';
import {ConnectState} from 'app/client/models/ConnectState';
import {urlState} from 'app/client/models/gristUrlState';
import {Expirable, IAppError, Notification, Notifier, NotifyAction, Progress} from 'app/client/models/NotifyModel';
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menuCssClass} from 'app/client/ui2018/menus';
import {commonUrls} from 'app/common/gristUrls';
import {dom, makeTestId, styled} from 'grainjs';
import {cssMenu, defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel';
const testId = makeTestId('test-notifier-');
function buildAction(action: NotifyAction, item: Notification, options: IBeaconOpenOptions): HTMLElement|null {
const appModel = options.appModel;
switch (action) {
case 'upgrade':
return dom('a', cssToastAction.cls(''), 'Upgrade Plan', {target: '_blank'},
{href: commonUrls.plans});
case 'renew':
// If already on the billing page, nothing to return.
if (urlState().state.get().billing === 'billing') { return null; }
// If not a billing manager, nothing to return.
if (appModel && appModel.currentOrg && appModel.currentOrg.billingAccount &&
!appModel.currentOrg.billingAccount.isManager) { return null; }
// Otherwise return a link to the billing page.
return dom('a', cssToastAction.cls(''), 'Renew', {target: '_blank'},
{href: urlState().makeUrl({billing: 'billing'})});
case 'report-problem':
return cssToastAction('Report a problem',
dom.on('click', () => beaconOpenMessage({...options, includeAppErrors: true})));
case 'ask-for-help': {
const errors: IAppError[] = [{
error: new Error(item.options.message as string),
timestamp: item.options.timestamp,
}];
return cssToastAction('Ask for help',
dom.on('click', () => beaconOpenMessage({...options, includeAppErrors: true, errors})));
}
}
}
function buildNotificationDom(item: Notification, options: IBeaconOpenOptions) {
return cssToastWrapper(testId('toast-wrapper'),
cssToastWrapper.cls(use => `-${use(item.status)}`),
cssToastBody(
item.options.title ? cssToastTitle(item.options.title) : null,
cssToastText(testId('toast-message'),
item.options.message,
),
item.options.actions.length ? cssToastActions(
item.options.actions.map((action) => buildAction(action, item, options))
) : null,
),
dom.maybe(item.options.canUserClose, () =>
cssToastClose(testId('toast-close'),
'✕',
dom.on('click', () => item.dispose())
)
)
);
}
function buildProgressDom(item: Progress) {
return cssToastWrapper(testId('progress-wrapper'),
cssToastBody(
cssToastText(testId('progress-message'),
dom.text(item.options.name),
dom.maybe(item.options.size, size => cssProgressBarSize(` (${size})`))
),
cssProgressBarWrapper(
cssProgressBarStatus(
dom.style('width', use => `${use(item.progress)}%`)
)
)
)
);
}
export function buildNotifyMenuButton(notifier: Notifier, appModel: AppModel|null) {
const {connectState} = notifier.getStateForUI();
return cssHoverCircle({style: `margin: 5px;`},
dom.domComputed(connectState, (state) => buildConnectStateButton(state)),
(elem) => {
setPopupToCreateDom(elem, (ctl) => buildNotifyDropdown(ctl, notifier, appModel),
{...defaultMenuOptions, placement: 'bottom-end'});
},
testId('menu-btn'),
);
}
function buildNotifyDropdown(ctl: IOpenController, notifier: Notifier, appModel: AppModel|null): Element {
const {connectState, disconnectMsg, dropdownItems} = notifier.getStateForUI();
return cssDropdownWrapper(
// Reuse css classes for menus (combination of popweasel classes and those from Grist menus)
dom.cls(cssMenu.className),
dom.cls(menuCssClass),
// Close on Escape.
dom.onKeyDown({Escape: () => ctl.close()}),
// Once attached, focus this element, so that it accepts keyboard events.
(elem) => { setTimeout(() => elem.focus(), 0); },
cssDropdownContent(
cssDropdownHeader(
cssDropdownHeaderTitle('Notifications'),
cssDropdownFeedbackLink(
cssDropdownFeedbackIcon('Feedback'),
'Give feedback',
dom.on('click', () => beaconOpenMessage({appModel, onOpen: () => ctl.close()})),
testId('feedback'),
)
),
dom.maybe(disconnectMsg, (msg) =>
cssDropdownStatus(
buildConnectStateButton(connectState.get()),
dom('div', cssDropdownStatusText(msg.message), testId('disconnect-msg')),
)
),
dom.maybe((use) => use(dropdownItems).length === 0 && !use(disconnectMsg), () =>
cssDropdownStatus(
dom('div', cssDropdownStatusText('No notifications')),
)
),
dom.forEach(dropdownItems, item =>
buildNotificationDom(item, {appModel, onOpen: () => ctl.close()})),
),
testId('dropdown'),
);
}
export function buildSnackbarDom(notifier: Notifier, appModel: AppModel|null): Element {
const {progressItems, toasts} = notifier.getStateForUI();
return cssSnackbarWrapper(testId('snackbar-wrapper'),
dom.forEach(progressItems, item => buildProgressDom(item)),
dom.forEach(toasts, toast => buildNotificationDom(toast, {appModel})),
);
}
function buildConnectStateButton(state: ConnectState): Element {
switch (state) {
case ConnectState.JustDisconnected: return cssTopBarBtn('Notification', cssTopBarBtn.cls('-slate'));
case ConnectState.RecentlyDisconnected: return cssTopBarBtn('Offline', cssTopBarBtn.cls('-slate'));
case ConnectState.ReallyDisconnected: return cssTopBarBtn('Offline', cssTopBarBtn.cls('-error'));
case ConnectState.Connected:
default:
return cssTopBarBtn('Notification');
}
}
const cssDropdownWrapper = styled('div', `
background-color: white;
border: 1px solid ${colors.darkGrey};
padding: 0px;
`);
const cssDropdownContent = styled('div', `
min-width: 320px;
max-width: 320px;
`);
const cssDropdownHeader = styled('div', `
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
background-color: ${colors.lightGrey};
outline: 1px solid ${colors.darkGrey};
`);
const cssDropdownHeaderTitle = styled('span', `
font-weight: bold;
`);
const cssDropdownFeedbackLink = styled('div', `
display: flex;
color: ${colors.lightGreen};
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
`);
const cssDropdownFeedbackIcon = styled(icon, `
background-color: ${colors.lightGreen};
margin-right: 4px;
`);
const cssDropdownStatus = styled('div', `
padding: 16px 48px 24px 48px;
text-align: center;
border-top: 1px solid ${colors.darkGrey};
`);
const cssDropdownStatusText = styled('div', `
display: inline-block;
margin: 8px 0 0 0;
text-align: left;
color: ${colors.slate};
`);
// z-index below is set above other assorted children of <body> which include z-index such as 999
// and 1050 (for new-style and old-style modals, for example).
const cssSnackbarWrapper = styled('div', `
position: fixed;
bottom: 8px;
right: 8px;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: ${vars.mediumFontSize};
pointer-events: none; /* Allow mouse clicks through */
`);
const cssToastWrapper = styled('div', `
display: flex;
min-width: 240px;
max-width: 320px;
overflow: hidden;
margin: 4px;
padding: 12px;
border-radius: 3px;
color: ${colors.light};
background-color: ${vars.toastBg};
pointer-events: auto;
opacity: 1;
transition: opacity ${Expirable.fadeDelay}ms;
&-expiring, &-expired {
opacity: 0;
}
.${cssDropdownContent.className} > & {
background-color: unset;
color: unset;
border-radius: 0px;
border-top: 1px solid ${colors.darkGrey};
margin: 0px;
padding: 16px 20px;
}
`);
const cssToastBody = styled('div', `
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 0 12px;
overflow-wrap: anywhere;
`);
const cssToastText = styled('div', `
`);
const cssToastTitle = styled(cssToastText, `
font-weight: bold;
margin-bottom: 8px;
`);
const cssToastClose = styled('div', `
cursor: pointer;
user-select: none;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
margin: -4px -4px -4px 4px;
`);
const cssToastActions = styled('div', `
display: flex;
align-items: flex-end;
margin-top: 16px;
color: ${colors.lightGreen};
`);
const cssToastAction = styled('div', `
cursor: pointer;
user-select: none;
margin-right: 24px;
&, &:hover, &:focus {
color: inherit;
}
&:hover {
text-decoration: underline;
}
`);
const cssProgressBarWrapper = styled('div', `
margin-top: 18px;
margin-bottom: 11px;
height: 3px;
border-radius: 3px;
background-color: ${colors.light};
`);
const cssProgressBarSize = styled('span', `
color: ${colors.slate};
`);
const cssProgressBarStatus = styled('div', `
height: 3px;
min-width: 3px;
border-radius: 3px;
background-color: ${colors.lightGreen};
`);

View File

@@ -0,0 +1,541 @@
import { reportError } from 'app/client/models/AppModel';
import { ColumnRec, DocModel, TableRec, ViewSectionRec } from 'app/client/models/DocModel';
import { linkId, NoLink } from 'app/client/ui/selectBy';
import { getWidgetTypes, IWidgetType } from 'app/client/ui/widgetTypes';
import { bigPrimaryButton } from "app/client/ui2018/buttons";
import { colors, vars } from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons";
import { spinnerModal } from 'app/client/ui2018/modals';
import { isLongerThan, nativeCompare } from "app/common/gutil";
import { computed, Computed, Disposable, dom, domComputed, fromKo, IOption, select} from "grainjs";
import { makeTestId, Observable, onKeyDown, styled} from "grainjs";
import without = require('lodash/without');
import Popper from 'popper.js';
import { IOpenController, popupOpen, setPopupToCreateDom } from 'popweasel';
type TableId = number|'New Table'|null;
// Describes a widget selection.
export interface IPageWidget {
// The widget type
type: IWidgetType;
// The table (one of the listed tables or 'New Table')
table: TableId;
// Whether to summarize the table (not available for "New Table").
summarize: boolean;
// some of the listed columns to use to summarize the table.
columns: number[];
// link
link: string;
// the page widget section id (should be 0 for a to-be-saved new widget)
section: number;
}
// Creates a IPageWidget from a ViewSectionRec.
export function toPageWidget(section: ViewSectionRec): IPageWidget {
const link = linkId({
srcSectionRef: section.linkSrcSectionRef.peek(),
srcColRef: section.linkSrcColRef.peek(),
targetColRef: section.linkTargetColRef.peek()
});
return {
type: section.parentKey.peek() as IWidgetType,
table: section.table.peek().summarySourceTable.peek() || section.tableRef.peek(),
summarize: Boolean(section.table.peek().summarySourceTable.peek()),
columns: section.table.peek().columns.peek().peek()
.filter((col) => col.summarySourceCol.peek())
.map((col) => col.summarySourceCol.peek()),
link, section: section.id.peek()
};
}
export interface IOptions extends ISelectOptions {
// the initial selected value, we call the function when the popup get triggered
value?: () => IPageWidget;
// placement, directly passed to the underlying Popper library.
placement?: Popper.Placement;
}
const testId = makeTestId('test-wselect-');
// The picker disables some choices that do not make much sense. This function return the list of
// compatible types given the tableId and whether user is creating a new page or not.
function getCompatibleTypes(tableId: TableId, isNewPage: boolean|undefined): IWidgetType[] {
if (tableId !== 'New Table') {
return ['record', 'single', 'detail', 'chart', 'custom'];
} else if (isNewPage) {
// New view + new table means we'll be switching to the primary view.
return ['record'];
} else {
// The type 'chart' makes little sense when creating a new table.
return ['record', 'single', 'detail'];
}
}
// Whether table and type make for a valid selection whether the user is creating a new page or not.
function isValidSelection(table: TableId, type: IWidgetType, isNewPage: boolean|undefined) {
return table !== null && getCompatibleTypes(table, isNewPage).includes(type);
}
export type ISaveFunc = (val: IPageWidget) => Promise<void>;
// Delay in milliseconds, after a user click on the save btn, before we start showing a modal
// spinner. If saving completes before this time elapses (which is likely to happen for regular
// table) we don't show the modal spinner.
const DELAY_BEFORE_SPINNER_MS = 500;
// Attaches the page widget picker to elem to open on 'click' on the left.
export function attachPageWidgetPicker(elem: HTMLElement, docModel: DocModel, onSave: ISaveFunc,
options: IOptions = {}) {
// Overrides .placement, this is needed to enable the page widget to update position when user
// expand the `Group By` panel.
// TODO: remove .placement from the options of this method (note: breaking buildPageWidgetPicker
// into two steps, one for model creation and the other for building UI, seems promising. In
// particular listening to value.summarize to update popup position could be done directly in
// code).
options.placement = 'left';
const domCreator = (ctl: IOpenController) => buildPageWidgetPicker(ctl, docModel, onSave, options);
setPopupToCreateDom(elem, domCreator, {
placement: 'left',
trigger: ['click'],
attach: 'body',
boundaries: 'viewport'
});
}
// Open page widget widget picker on the right of element.
export function openPageWidgetPicker(elem: HTMLElement, docModel: DocModel, onSave: ISaveFunc,
options: IOptions = {}) {
popupOpen(elem, (ctl) => buildPageWidgetPicker(
ctl, docModel, onSave, options
), { placement: 'right' });
}
// Builds a picker to stick into the popup. Takes care of setting up the initial selected value and
// bind various events to the popup behaviours: close popup on save, gives focus to the picker,
// binds cancel and save to Escape and Enter keydown events. Also takes care of preventing the popup
// to overlay the trigger element (which could happen when the 'Group By' panel is expanded for the
// first time). When saving is taking time, show a modal spinner (see DELAY_BEFORE_SPINNER_MS).
export function buildPageWidgetPicker(
ctl: IOpenController,
docModel: DocModel,
onSave: ISaveFunc,
options: IOptions = {}) {
const tables = fromKo(docModel.allTables.getObservable());
const columns = fromKo(docModel.columns.createAllRowsModel('parentPos').getObservable());
// default value for when it is omitted
const defaultValue: IPageWidget = {
type: 'record',
table: null, // when creating a new widget, let's initially have no table selected
summarize: false,
columns: [],
link: NoLink,
section: 0,
};
// get initial value and setup state for the picker.
const initValue = options.value && options.value() || defaultValue;
const value: IWidgetValueObs = {
type: Observable.create(ctl, initValue.type),
table: Observable.create(ctl, initValue.table),
summarize: Observable.create(ctl, initValue.summarize),
columns: Observable.create(ctl, initValue.columns),
link: Observable.create(ctl, initValue.link),
section: Observable.create(ctl, initValue.section)
};
// calls onSave and closes the popup. Failure must be handled by the caller.
async function onSaveCB() {
ctl.close();
const savePromise = onSave({
type: value.type.get(),
table: value.table.get(),
summarize: value.summarize.get(),
columns: sortedAs(value.columns.get(), columns.get().map((col) => col.id.peek())),
link: value.link.get(),
section: value.section.get(),
});
// If savePromise throws an error, before or after timeout, we let the error propagate as it
// should be handle by the caller.
if (await isLongerThan(savePromise, DELAY_BEFORE_SPINNER_MS)) {
const label = getWidgetTypes(value.type.get()).label;
await spinnerModal(`Building ${label} widget`, savePromise);
}
}
// whether the current selection is valid
function isValid() {
return isValidSelection(value.table.get(), value.type.get(), options.isNewPage);
}
// Summarizing a table causes the 'Group By' panel to expand on the right. To prevent it from
// overlaying the trigger, we bind an update of the popup to it when it is on the left of the
// trigger.
// WARN: This does not work when the picker is triggered from a menu item because the trigger
// element does not exist anymore at this time so calling update will misplace the popup. However,
// this is not a problem at the time or writing because the picker is never placed at the left of
// a menu item (currently picker is only placed at the right of a menu item and at the left of a
// basic button).
if (options.placement && options.placement === 'left') {
ctl.autoDispose(value.summarize.addListener((val, old) => val && ctl.update()));
}
// dom
return cssPopupWrapper(
dom.create(PageWidgetSelect, value, tables, columns, onSaveCB, options),
// gives focus and binds keydown events
(elem: any) => {setTimeout(() => elem.focus(), 0); },
onKeyDown({
Escape: () => ctl.close(),
Enter: () => isValid() && onSaveCB()
})
);
}
// Same as IWidgetValue but with observable values
export type IWidgetValueObs = {
[P in keyof IPageWidget]: Observable<IPageWidget[P]>;
};
export interface ISelectOptions {
// the button's label
buttonLabel?: string;
// Indicates whether the section builder is in a new view
isNewPage?: boolean;
// A callback to provides the links that are available to a page widget. It is called any time the
// user changes in the selected page widget (type, table, summary ...) and we update the "SELECT
// BY" dropdown with the result list of options. The "SELECT BY" dropdown is hidden if omitted.
selectBy?: (val: IPageWidget) => Array<IOption<string>>;
}
// the list of widget types in the order they should be listed by the widget.
const sectionTypes: IWidgetType[] = [
'record', 'single', 'detail', 'chart', 'custom'
];
// Returns dom that let a user select a page widget. User can select a widget type (id: 'grid',
// 'card', ...), one of `tables` and optionally some of the `columns` of the selected table if she
// wants to generate a summary. Clicking the `Add ...` button trigger `onSave()`. Note: this is an
// internal method used by widgetPicker, it is only exposed for testing reason.
export class PageWidgetSelect extends Disposable {
// an observable holding the list of options of the `select by` dropdown
private _selectByOptions = this._options.selectBy ?
Computed.create(this, (use) => {
// TODO: it is unfortunate to have to convert from IWidgetValueObs to IWidgetValue. Maybe
// better to change this._value to be Observable<IWidgetValue> instead.
const val = {
type: use(this._value.type),
table: use(this._value.table),
summarize: use(this._value.summarize),
columns: use(this._value.columns),
// should not have a dependency on .link
link: this._value.link.get(),
section: use(this._value.section),
};
return this._options.selectBy!(val);
}) :
null;
private _isNewTableDisabled = Computed.create(this, this._value.type, (use, t) => !isValidSelection(
'New Table', t, this._options.isNewPage));
constructor(
private _value: IWidgetValueObs,
private _tables: Observable<TableRec[]>,
private _columns: Observable<ColumnRec[]>,
private _onSave: () => Promise<void>,
private _options: ISelectOptions = {}
) { super(); }
public buildDom() {
return cssContainer(
testId('container'),
cssBody(
cssPanel(
header('Select Widget'),
sectionTypes.map((value) => {
const {label, icon: iconName} = getWidgetTypes(value);
const disabled = computed(this._value.table, (use, tid) => this._isTypeDisabled(value, tid));
return cssEntry(
dom.autoDispose(disabled),
cssTypeIcon(iconName),
label,
dom.on('click', () => !disabled.get() && this._selectType(value)),
cssEntry.cls('-selected', (use) => use(this._value.type) === value),
cssEntry.cls('-disabled', disabled),
testId('type'),
);
}),
),
cssPanel(
testId('data'),
header('Select Data'),
cssEntry(
cssIcon('TypeTable'), 'New Table',
// prevent the selection of 'New Table' if it is disabled
dom.on('click', (ev) => !this._isNewTableDisabled.get() && this._selectTable('New Table')),
cssEntry.cls('-selected', (use) => use(this._value.table) === 'New Table'),
cssEntry.cls('-disabled', this._isNewTableDisabled),
testId('table')
),
dom.forEach(this._tables, (table) => dom('div',
cssEntryWrapper(
cssEntry(cssIcon('TypeTable'), cssLabel(dom.text(table.tableId)),
dom.on('click', () => this._selectTable(table.id())),
cssEntry.cls('-selected', (use) => use(this._value.table) === table.id()),
testId('table-label')
),
cssPivot(
cssBigIcon('Pivot'),
cssEntry.cls('-selected', (use) => use(this._value.summarize) && use(this._value.table) === table.id()),
dom.on('click', (ev, el) => this._selectPivot(table.id(), el as HTMLElement)),
testId('pivot'),
),
testId('table'),
)
)),
),
cssPanel(
header('Group by'),
dom.hide((use) => !use(this._value.summarize)),
domComputed(
(use) => use(this._columns)
.filter((col) => !col.isHiddenCol() && col.parentId() === use(this._value.table)),
(cols) => cols ?
dom.forEach(cols, (col) =>
cssEntry(cssIcon('FieldColumn'), cssFieldLabel(dom.text(col.label)),
dom.on('click', () => this._toggleColumnId(col.id())),
cssEntry.cls('-selected', (use) => use(this._value.columns).includes(col.id())),
testId('column')
)
) :
null
),
),
),
cssFooter(
cssFooterContent(
// If _selectByOptions exists and has more than then "NoLinkOption", show the selector.
dom.maybe((use) => this._selectByOptions && use(this._selectByOptions).length > 1, () => [
cssSmallLabel('SELECT BY'),
dom.update(cssSelect(this._value.link, this._selectByOptions!),
testId('selectby'))
]),
dom('div', {style: 'flex-grow: 1'}),
bigPrimaryButton(
// TODO: The button's label of the page widget picker should read 'Close' instead when
// there are no changes.
this._options.buttonLabel || 'Add to Page',
dom.prop('disabled', (use) => !isValidSelection(
use(this._value.table), use(this._value.type), this._options.isNewPage)
),
dom.on('click', () => this._onSave().catch(reportError)),
testId('addBtn'),
),
),
),
);
}
private _closeSummarizePanel() {
this._value.summarize.set(false);
this._value.columns.set([]);
}
private _openSummarizePanel() {
this._value.summarize.set(true);
}
private _selectType(t: IWidgetType) {
this._value.type.set(t);
}
private _selectTable(tid: TableId) {
this._value.table.set(tid);
this._closeSummarizePanel();
}
private _isSelected(el: HTMLElement) {
return el.classList.contains(cssEntry.className + '-selected');
}
private _selectPivot(tid: TableId, pivotEl: HTMLElement) {
if (this._isSelected(pivotEl)) {
this._closeSummarizePanel();
} else {
if (tid !== this._value.table.get()) {
this._value.columns.set([]);
this._value.table.set(tid);
}
this._openSummarizePanel();
}
}
private _toggleColumnId(cid: number) {
const ids = this._value.columns.get();
const newIds = ids.includes(cid) ? without(ids, cid) : [...ids, cid];
this._value.columns.set(newIds);
}
private _isTypeDisabled(type: IWidgetType, table: TableId) {
if (table === null) {
return false;
}
return !getCompatibleTypes(table, this._options.isNewPage).includes(type);
}
}
function header(label: string) {
return cssHeader(dom('h4', label), testId('heading'));
}
const cssContainer = styled('div', `
--outline: 1px solid rgba(217,217,217,0.60);
max-height: 386px;
box-shadow: 0 2px 20px 0 rgba(38,38,51,0.20);
border-radius: 2px;
display: flex;
flex-direction: column;
user-select: none;
background-color: white;
`);
const cssPopupWrapper = styled('div', `
&:focus {
outline: none;
}
`);
const cssBody = styled('div', `
display: flex;
min-height: 0;
`);
// todo: try replace min-width / max-width
const cssPanel = styled('div', `
width: 224px;
font-size: ${vars.mediumFontSize};
overflow: auto;
padding-bottom: 18px;
&:nth-of-type(2n) {
background-color: ${colors.lightGrey};
outline: var(--outline);
}
`);
const cssHeader = styled('div', `
margin: 24px 0 24px 24px;
font-size: ${vars.mediumFontSize};
`);
const cssEntry = styled('div', `
padding: 0 0 0 24px;
height: 32px;
display: flex;
flex-direction: row;
flex: 1 1 0px;
align-items: center;
white-space: nowrap;
overflow: hidden;
&-selected {
background-color: ${colors.mediumGrey};
}
&-disabled {
color: ${colors.mediumGrey};
}
&-disabled&-selected {
background-color: inherit;
}
`);
const cssIcon = styled(icon, `
margin-right: 8px;
flex-shrink: 0;
--icon-color: ${colors.slate};
.${cssEntry.className}-disabled > & {
opacity: 0.2;
}
`);
const cssTypeIcon = styled(cssIcon, `
--icon-color: ${colors.lightGreen};
`);
const cssLabel = styled('span', `
text-overflow: ellipsis;
overflow: hidden;
`);
const cssFieldLabel = styled(cssLabel, `
padding-right: 8px;
`);
const cssEntryWrapper = styled('div', `
display: flex;
align-items: center;
`);
const cssPivot = styled(cssEntry, `
width: 48px;
padding-left: 8px;
flex: 0 0 auto;
`);
const cssBigIcon = styled(icon, `
width: 24px;
height: 24px;
background-color: ${colors.darkGreen};
`);
const cssFooter = styled('div', `
display: flex;
border-top: var(--outline);
`);
const cssFooterContent = styled('div', `
flex-grow: 1;
height: 65px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 24px 0 24px;
`);
const cssSmallLabel = styled('span', `
font-size: ${vars.xsmallFontSize};
margin-right: 8px;
`);
const cssSelect = styled(select, `
flex: 1 0 160px;
`);
// Returns a copy of array with its items sorted in the same order as they appear in other.
function sortedAs(array: number[], other: number[]) {
const order: {[id: number]: number} = {};
for (const [index, item] of other.entries()) {
order[item] = index;
}
return array.slice().sort((a, b) => nativeCompare(order[a], order[b]));
}

82
app/client/ui/Pages.ts Normal file
View File

@@ -0,0 +1,82 @@
import { createGroup } from "app/client/components/commands";
import { duplicatePage } from "app/client/components/duplicatePage";
import { GristDoc } from "app/client/components/GristDoc";
import { PageRec } from "app/client/models/DocModel";
import { urlState } from "app/client/models/gristUrlState";
import * as MetaTableModel from "app/client/models/MetaTableModel";
import { find as findInTree, fromTableData, TreeItemRecord } from "app/client/models/TreeModel";
import { TreeViewComponent } from "app/client/ui/TreeViewComponent";
import { confirmModal } from 'app/client/ui2018/modals';
import { buildPageDom, PageActions } from "app/client/ui2018/pages";
import { mod } from 'app/common/gutil';
import { Computed, Disposable, dom, observable, Observable } from "grainjs";
// build dom for the tree view of pages
export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Observable<boolean>) {
const pagesTable = activeDoc.docModel.pages;
const buildDom = buildDomFromTable.bind(null, pagesTable, activeDoc);
// create the model and keep in sync with the table
const model = observable(fromTableData(pagesTable.tableData, buildDom));
owner.autoDispose(pagesTable.tableData.tableActionEmitter.addListener(() => {
model.set(fromTableData(pagesTable.tableData, buildDom, model.get()));
}));
// create a computed that reads the selected page from the url and return the corresponding item
const selected = Computed.create(owner, activeDoc.activeViewId, (use, viewId) =>
findInTree(model.get(), (i: TreeItemRecord) => i.record.viewRef === viewId) || null
);
owner.autoDispose(createGroup({
nextPage: () => selected.get() && otherPage(selected.get()!, +1),
prevPage: () => selected.get() && otherPage(selected.get()!, -1)
}, null, true));
// dom
return dom('div', dom.create(TreeViewComponent, model, {isOpen, selected, isReadonly: activeDoc.isReadonly}));
}
function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: GristDoc, id: number) {
const {docModel, isReadonly} = activeDoc;
const pageName = pagesTable.rowModels[id].view.peek().name;
const viewId = pagesTable.rowModels[id].view.peek().id.peek();
const docData = pagesTable.tableData.docData;
const actions: PageActions = {
onRename: (newName: string) => newName.length && pageName.saveOnly(newName),
onRemove: () => docData.sendAction(['RemoveRecord', '_grist_Views', viewId]),
// TODO: duplicate should prompt user for confirmation
onDuplicate: () => duplicatePage(activeDoc, id),
isRemoveDisabled: () => false,
isReadonly
};
// find a table with a matching primary view
const tableRef = docModel.tables.tableData.findRow('primaryViewId', viewId);
if (tableRef) {
function doRemove() {
const tableId = docModel.tables.tableData.getValue(tableRef, 'tableId');
return docData.sendAction(['RemoveTable', tableId]);
}
// if user removes a primary view, let's confirm first, because this will remove the
// corresponsing table and also all pages that are using this table.
// TODO: once we have raw table view, removing page should remove just the view (not the
// table), but for now this is the only way to remove a table in the newui.
actions.onRemove = () => confirmModal(
`Delete ${pageName()} data, and remove it from all pages?`, 'Delete', doRemove);
actions.isRemoveDisabled = () => (docModel.allTables.all().length <= 1);
}
return buildPageDom(pageName, actions, urlState().setLinkUrl({docPage: viewId}));
}
// Select another page in cyclic ordering of pages. Order is downard if given a positive `delta`,
// upward otherwise.
function otherPage(currentPage: TreeItemRecord, delta: number) {
const records = currentPage.storage.records;
const index = mod(currentPage.index + delta, records.length);
const docPage = records[index].viewRef;
return urlState().pushUrl({docPage});
}

235
app/client/ui/PinnedDocs.ts Normal file
View File

@@ -0,0 +1,235 @@
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {getTimeFromNow, HomeModel} from 'app/client/models/HomeModel';
import {makeDocOptionsMenu} from 'app/client/ui/DocMenu';
import {IExampleInfo} from 'app/client/ui/ExampleInfo';
import {transientInput} from 'app/client/ui/transientInput';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu} from 'app/client/ui2018/menus';
import * as roles from 'app/common/roles';
import {Document} from 'app/common/UserAPI';
import {computed, dom, makeTestId, observable, styled} from 'grainjs';
const testId = makeTestId('test-dm-');
/**
* PinnedDocs builds the dom at the top of the doclist showing all the pinned docs in the
* selectedOrg. Builds nothing if there are no pinned docs.
*
* Used only by DocMenu.
*/
export function createPinnedDocs(home: HomeModel) {
return pinnedDocList(
dom.forEach(home.currentWSPinnedDocs, doc => buildPinnedDoc(home, doc)),
testId('pinned-doc-list'),
);
}
/**
* Build a single doc card with a preview and name. A misnomer because it's now used not only for
* pinned docs, but also for the thumnbails (aka "icons") view mode.
*/
export function buildPinnedDoc(home: HomeModel, doc: Document, example?: IExampleInfo): HTMLElement {
const renaming = observable<Document|null>(null);
const isRenaming = computed((use) => use(renaming) === doc);
const docTitle = example?.title || doc.name;
return pinnedDocWrapper(
dom.autoDispose(isRenaming),
pinnedDoc(
urlState().setLinkUrl(docUrl(doc)),
pinnedDoc.cls('-no-access', !roles.canView(doc.access)),
pinnedDocPreview(
example?.bgColor ? dom.style('background-color', example.bgColor) : null,
(example?.imgUrl ?
cssImage({src: example.imgUrl}) :
[docInitials(docTitle), pinnedDocThumbnail()]
),
(doc.public && !example ? cssPublicIcon('PublicFilled', testId('public')) : null),
),
pinnedDocFooter(
dom.domComputed(isRenaming, (yesNo) => yesNo ?
pinnedDocEditorInput({
initialValue: doc.name || '',
save: async (val) => (val !== doc.name) ? home.renameDoc(doc.id, val) : undefined,
close: () => renaming.set(null),
}, testId('doc-name-editor'))
:
pinnedDocTitle(
dom.text(docTitle),
testId('pinned-doc-name'),
// Mostly for the sake of tests, allow .test-dm-pinned-doc-name to find documents in
// either 'list' or 'icons' views.
testId('doc-name')
),
),
cssPinnedDocDesc(
example?.desc || capitalizeFirst(getTimeFromNow(doc.updatedAt)),
testId('pinned-doc-desc')
)
)
),
example ? null : pinnedDocOptions(icon('Dots'),
menu(() => makeDocOptionsMenu(home, doc, renaming), {placement: 'bottom-start'}),
testId('pinned-doc-options')
),
testId('pinned-doc')
);
}
function docInitials(docTitle: string) {
return cssDocInitials(docTitle.slice(0, 2), testId('pinned-initials'));
}
// Capitalizes the first letter in the given string.
function capitalizeFirst(str: string): string {
return str.replace(/^[a-z]/gi, c => c.toUpperCase());
}
const pinnedDocList = styled('div', `
display: flex;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 16px;
margin: 0 0 28px 0;
`);
export const pinnedDocWrapper = styled('div', `
display: inline-block;
flex: 0 0 auto;
position: relative;
width: 210px;
margin: 16px 24px 16px 0;
border: 1px solid ${colors.mediumGrey};
border-radius: 1px;
vertical-align: top;
&:hover {
border: 1px solid ${colors.slate};
}
`);
const pinnedDoc = styled('a', `
display: flex;
flex-direction: column;
width: 100%;
color: black;
text-decoration: none;
cursor: pointer;
&:hover {
color: black;
text-decoration: none;
}
&-no-access, &-no-access:hover {
color: ${colors.slate};
cursor: not-allowed;
}
`);
const pinnedDocPreview = styled('div', `
position: relative;
flex: none;
width: 100%;
height: 131px;
background-color: ${colors.dark};
min-height: 0;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
.${pinnedDoc.className}-no-access > & {
opacity: 0.8;
}
`);
const pinnedDocThumbnail = styled('div', `
position: absolute;
right: 20px;
bottom: 20px;
height: 48px;
width: 48px;
background-image: var(--icon-ThumbPreview);
background-size: 48px 48px;
background-repeat: no-repeat;
background-position: center;
`);
const cssDocInitials = styled('div', `
position: absolute;
left: 20px;
bottom: 20px;
font-size: 32px;
border: 1px solid ${colors.lightGreen};
color: ${colors.mediumGreyOpaque};
border-radius: 3px;
padding: 4px 0;
width: 48px;
height: 48px;
text-align: center;
`);
const pinnedDocOptions = styled('div', `
position: absolute;
top: 12px;
right: 12px;
height: 24px;
width: 24px;
padding: 4px;
line-height: 0px;
border-radius: 3px;
cursor: default;
visibility: hidden;
background-color: ${colors.mediumGrey};
--icon-color: ${colors.light};
.${pinnedDocWrapper.className}:hover &, &.weasel-popup-open {
visibility: visible;
}
`);
const pinnedDocFooter = styled('div', `
width: 100%;
font-size: ${vars.mediumFontSize};
`);
const pinnedDocTitle = styled('div', `
margin: 16px 16px 0px 16px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`);
export const pinnedDocEditorInput = styled(transientInput, `
margin: 16px 16px 0px 16px;
font-weight: bold;
min-width: 0px;
color: initial;
font-size: inherit;
line-height: inherit;
appearance: none;
-moz-appearance: none;
padding: 0;
border: none;
outline: none;
background-color: ${colors.mediumGrey};
`);
const cssPinnedDocDesc = styled('div', `
margin: 8px 16px 16px 16px;
color: ${colors.slate};
`);
const cssImage = styled('img', `
position: relative;
max-height: 100%;
max-width: 100%;
`);
const cssPublicIcon = styled(icon, `
position: absolute;
top: 16px;
left: 16px;
--icon-color: ${colors.lightGreen};
`);

View File

@@ -0,0 +1,178 @@
import {AppModel, reportError} from 'app/client/models/AppModel';
import {getResetPwdUrl} from 'app/client/models/gristUrlState';
import {ApiKey} from 'app/client/ui/ApiKey';
import * as billingPageCss from 'app/client/ui/BillingPageCss';
import {transientInput} from 'app/client/ui/transientInput';
import {buildNameWarningsDom, checkName} from 'app/client/ui/WelcomePage';
import {bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars';
import {cssModalBody, cssModalButtons, cssModalTitle, cssModalWidth} from 'app/client/ui2018/modals';
import {IModalControl, modal} from 'app/client/ui2018/modals';
import {FullUser} from 'app/common/LoginSessionAPI';
import {Computed, dom, domComputed, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
/**
* Renders a modal with profile settings.
*/
export function showProfileModal(appModel: AppModel): void {
return modal((ctl, owner) => showProfileContent(ctl, owner, appModel));
}
function showProfileContent(ctl: IModalControl, owner: MultiHolder, appModel: AppModel): DomElementArg {
const apiKey = Observable.create<string>(owner, '');
const userObs = Observable.create<FullUser|null>(owner, null);
const isEditingName = Observable.create(owner, false);
const nameEdit = Observable.create<string>(owner, '');
const isNameValid = Computed.create(owner, nameEdit, (use, val) => checkName(val));
let needsReload = false;
let closeBtn: Element;
async function fetchApiKey() { apiKey.set(await appModel.api.fetchApiKey()); }
async function createApiKey() { apiKey.set(await appModel.api.createApiKey()); }
async function deleteApiKey() { await appModel.api.deleteApiKey(); apiKey.set(''); }
async function fetchUserProfile() { userObs.set(await appModel.api.getUserProfile()); }
async function fetchAll() {
await Promise.all([
fetchApiKey(),
fetchUserProfile()
]);
}
fetchAll().catch(reportError);
async function updateUserName(val: string) {
const user = userObs.get();
if (user && val && val !== user.name) {
closeBtn.toggleAttribute('disabled', true);
try {
await appModel.api.updateUserName(val);
await fetchAll();
needsReload = true;
} finally {
closeBtn.toggleAttribute('disabled', false);
}
}
}
return [
cssModalTitle('User Profile'),
cssModalWidth('fixed-wide'),
domComputed(userObs, (user) => user && (
cssModalBody(
cssDataRow(cssSubHeader('Email'), user.email),
cssDataRow(
cssSubHeader('Name'),
domComputed(isEditingName, (isediting) => (
isediting ? [
transientInput(
{
initialValue: user.name,
save: (val) => isNameValid.get() && updateUserName(val),
close: () => { isEditingName.set(false); nameEdit.set(''); },
},
dom.on('input', (ev, el) => nameEdit.set(el.value)),
),
cssTextBtn(
cssBillingIcon('Settings'), 'Save',
// no need to save on 'click', the transient input already does it on close
),
] : [
user.name,
cssTextBtn(
cssBillingIcon('Settings'), 'Edit',
dom.on('click', () => isEditingName.set(true))
),
]
)),
testId('username')
),
// show warning for invalid name but not for the empty string
dom.maybe(use => use(nameEdit) && !use(isNameValid), cssWarnings),
cssDataRow(
cssSubHeader('Login Method'),
user.loginMethod,
// TODO: should show btn only when logged in with google
user.loginMethod === 'Email + Password' ? cssTextBtn(
// rename to remove mention of Billing in the css
cssBillingIcon('Settings'), 'Reset',
dom.on('click', () => confirmPwdResetModal(user.email))
) : null,
testId('login-method'),
),
cssDataRow(cssSubHeader('API Key'), cssContent(
dom.create(ApiKey, {
apiKey,
onCreate: createApiKey,
onDelete: deleteApiKey,
anonymous: false,
})
)),
)
)),
cssModalButtons(
closeBtn = bigPrimaryButton('Close',
dom.on('click', () => {
if (needsReload) {
appModel.topAppModel.initialize();
}
ctl.close();
}),
testId('modal-confirm')
),
),
];
}
// We cannot use the confirmModal here because of the button link that we need here.
function confirmPwdResetModal(userEmail: string) {
return modal((ctl, owner) => {
return [
cssModalTitle('Reset Password'),
cssModalBody(`Click continue to open the password reset form. Submit it for your email address: ${userEmail}`),
cssModalButtons(
bigPrimaryButtonLink(
{ href: getResetPwdUrl(), target: '_blank' },
'Continue',
dom.on('click', () => ctl.close())
),
bigBasicButton(
'Cancel',
dom.on('click', () => ctl.close())
),
),
];
});
}
const cssDataRow = styled('div', `
margin: 8px 0px;
display: flex;
align-items: baseline;
`);
const cssSubHeader = styled('div', `
width: 110px;
padding: 8px 0;
display: inline-block;
vertical-align: top;
font-weight: bold;
`);
const cssContent = styled('div', `
flex: 1 1 300px;
`);
const cssTextBtn = styled(billingPageCss.billingTextBtn, `
width: 90px;
margin-left: auto;
`);
const cssBillingIcon = billingPageCss.billingIcon;
const cssWarnings = styled(buildNameWarningsDom, `
margin: -8px 0 0 110px;
`);

686
app/client/ui/RightPanel.ts Normal file
View File

@@ -0,0 +1,686 @@
/**
* Builds the structure of the right-side panel containing configuration and assorted tools.
* It includes the regular tabs, to configure the Page (including several sub-tabs), and Field;
* and allows other tools, such as Activity Feed, to be rendered temporarily in its place.
*
* A single RightPanel object is created in AppUI for a document page, and attached to PagePanels.
* GristDoc registers callbacks with it to create various standard tabs. These are created as
* needed, and destroyed when hidden.
*
* In addition, tools such as "Activity Feed" may use openTool() to replace the panel header and
* content. The user may dismiss this panel.
*
* All methods above return an object which may be disposed to close and dispose that specific
* tab from the outside (e.g. when GristDoc is disposed).
*/
import * as commands from 'app/client/components/commands';
import * as FieldConfigTab from 'app/client/components/FieldConfigTab';
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
import * as ViewConfigTab from 'app/client/components/ViewConfigTab';
import * as imports from 'app/client/lib/imports';
import {createSessionObs} from 'app/client/lib/sessionObs';
import {reportError} from 'app/client/models/AppModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {GridOptions} from 'app/client/ui/GridOptions';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, linkId, selectBy} from 'app/client/ui/selectBy';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {IWidgetType, widgetTypes} from 'app/client/ui/widgetTypes';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {textInput} from 'app/client/ui2018/editableLabel';
import {IconName} from 'app/client/ui2018/IconList';
import {icon} from 'app/client/ui2018/icons';
import {select} from 'app/client/ui2018/menus';
import {StringUnion} from 'app/common/StringUnion';
import {bundleChanges, Computed, Disposable, dom, DomArg, domComputed, DomContents,
DomElementArg, DomElementMethod, IDomComponent} from 'grainjs';
import {MultiHolder, Observable, styled, subscribe} from 'grainjs';
import * as ko from 'knockout';
// Represents a top tab of the right side-pane.
const TopTab = StringUnion("pageWidget", "field");
// Represents a subtab of pageWidget in the right side-pane.
const PageSubTab = StringUnion("widget", "sortAndFilter", "data");
// A map of widget type to the icon and label to use for a field of that widget.
const fieldTypes = new Map<IWidgetType, {label: string, icon: IconName, pluralLabel: string}>([
['record', {label: 'Column', icon: 'TypeCell', pluralLabel: 'Columns'}],
['detail', {label: 'Field', icon: 'TypeCell', pluralLabel: 'Fields'}],
['single', {label: 'Field', icon: 'TypeCell', pluralLabel: 'Fields'}],
['chart', {label: 'Series', icon: 'ChartLine', pluralLabel: 'Series'}],
['custom', {label: 'Column', icon: 'TypeCell', pluralLabel: 'Columns'}],
]);
// Returns the icon and label of a type, default to those associate to 'record' type.
export function getFieldType(widgetType: IWidgetType|null) {
return fieldTypes.get(widgetType || 'record') || fieldTypes.get('record')!;
}
export class RightPanel extends Disposable {
public readonly header: DomContents;
public readonly content: DomContents;
// If the panel is showing a tool, such as Action Log, instead of the usual section/field
// configuration, this will be set to the tool's header and content.
private _extraTool: Observable<IExtraTool|null>;
// Which of the two standard top tabs (page widget or field) is selected, or was last selected.
private _topTab = createSessionObs(this, "rightTopTab", "pageWidget", TopTab.guard);
// Which subtab is open for configuring page widget.
private _subTab = createSessionObs(this, "rightPageSubTab", "widget", PageSubTab.guard);
// Which type of page widget is active, e.g. "record" or "chart". This affects the names and
// icons in the top tab.
private _pageWidgetType = Computed.create<IWidgetType|null>(this, (use) => {
const section: ViewSectionRec = use(this._gristDoc.viewModel.activeSection);
return (use(section.parentKey) || null) as IWidgetType;
});
// Returns the active section if it's valid, null otherwise.
private _validSection = Computed.create(this, (use) => {
const sec = use(this._gristDoc.viewModel.activeSection);
return sec.getRowId() ? sec : null;
});
constructor(private _gristDoc: GristDoc, private _isOpen: Observable<boolean>) {
super();
this._extraTool = _gristDoc.rightPanelTool;
this.autoDispose(subscribe(this._extraTool, (_use, tool) => tool && _isOpen.set(true)));
this.header = this._buildHeaderDom();
this.content = this._buildContentDom();
this.autoDispose(commands.createGroup({
fieldTabOpen: () => this._openFieldTab(),
viewTabOpen: () => this._openViewTab(),
sortFilterTabOpen: () => this._openSortFilter(),
dataSelectionTabOpen: () => this._openDataSelection()
}, this, true));
}
private _openFieldTab() {
this._open('field');
}
private _openViewTab() {
this._open('pageWidget', 'widget');
}
private _openSortFilter() {
this._open('pageWidget', 'sortAndFilter');
}
private _openDataSelection() {
this._open('pageWidget', 'data');
}
private _open(topTab: typeof TopTab.type, subTab?: typeof PageSubTab.type) {
bundleChanges(() => {
this._isOpen.set(true);
this._topTab.set(topTab);
if (subTab) {
this._subTab.set(subTab);
}
});
}
private _buildHeaderDom() {
return dom.domComputed((use) => {
if (!use(this._isOpen)) { return null; }
const tool = use(this._extraTool);
return tool ? this._buildToolHeader(tool) : this._buildStandardHeader();
});
}
private _buildToolHeader(tool: IExtraTool) {
return cssTopBarItem(cssTopBarIcon(tool.icon), tool.label,
cssHoverCircle(cssHoverIcon("CrossBig"),
dom.on('click', () => this._gristDoc.showTool('none')),
testId('right-tool-close'),
),
cssTopBarItem.cls('-selected', true)
);
}
private _buildStandardHeader() {
return dom.maybe(this._pageWidgetType, (type) => {
const widgetInfo = widgetTypes.get(type) || {label: 'Table', icon: 'TypeTable'};
const fieldInfo = getFieldType(type);
return [
cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.label,
cssTopBarItem.cls('-selected', (use) => use(this._topTab) === 'pageWidget'),
dom.on('click', () => this._topTab.set("pageWidget")),
testId('right-tab-pagewidget')),
cssTopBarItem(cssTopBarIcon(fieldInfo.icon), fieldInfo.label,
cssTopBarItem.cls('-selected', (use) => use(this._topTab) === 'field'),
dom.on('click', () => this._topTab.set("field")),
testId('right-tab-field')),
];
});
}
private _buildContentDom() {
return dom.domComputed((use) => {
if (!use(this._isOpen)) { return null; }
const tool = use(this._extraTool);
if (tool) { return tabContentToDom(tool.content); }
const topTab = use(this._topTab);
if (topTab === 'field') {
return dom.create(this._buildFieldContent.bind(this));
}
if (topTab === 'pageWidget' && use(this._pageWidgetType)) {
return dom.create(this._buildPageWidgetContent.bind(this));
}
return null;
});
}
private _buildFieldContent(owner: MultiHolder) {
const obs: Observable<null|FieldConfigTab> = Observable.create(owner, null);
const fieldBuilder = this.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection().viewInstance();
return vsi && vsi.activeFieldBuilder();
}));
const gristDoc = this._gristDoc;
const content = Observable.create<TabContent[]>(owner, []);
const contentCallback = (tabs: TabContent[]) => content.set(tabs);
imports.loadViewPane()
.then(ViewPane => {
if (owner.isDisposed()) { return; }
const fct = owner.autoDispose(ViewPane.FieldConfigTab.create({gristDoc, fieldBuilder, contentCallback}));
obs.set(fct);
})
.catch(reportError);
return dom.maybe(obs, (fct) =>
buildConfigContainer(
cssLabel('COLUMN TITLE'),
fct._buildNameDom(),
fct._buildFormulaDom(),
cssSeparator(),
cssLabel('COLUMN TYPE'),
fct._buildFormatDom(),
cssSeparator(),
dom.maybe(fct.isForeignRefCol, () => [
cssLabel('Add referenced columns'),
cssRow(fct.refSelect.buildDom()),
cssSeparator()
]),
cssLabel('TRANSFORM'),
fct._buildTransformDom(),
this._disableIfReadonly(),
)
);
}
private _buildPageWidgetContent(_owner: MultiHolder) {
return [
cssSubTabContainer(
cssSubTab('Widget',
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'widget'),
dom.on('click', () => this._subTab.set("widget")),
testId('config-widget')),
cssSubTab('Sort & Filter',
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'sortAndFilter'),
dom.on('click', () => this._subTab.set("sortAndFilter")),
testId('config-sortAndFilter')),
cssSubTab('Data',
cssSubTab.cls('-selected', (use) => use(this._subTab) === 'data'),
dom.on('click', () => this._subTab.set("data")),
testId('config-data')),
),
dom.domComputed(this._subTab, (subTab) => (
dom.maybe(this._validSection, (activeSection) => (
buildConfigContainer(
subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) :
subTab === 'sortAndFilter' ? dom.create(this._buildPageSortFilterConfig.bind(this)) :
subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) :
null
)
))
))
];
}
private _createViewConfigTab(owner: MultiHolder): Observable<null|ViewConfigTab> {
const viewConfigTab = Observable.create<null|ViewConfigTab>(owner, null);
const gristDoc = this._gristDoc;
imports.loadViewPane()
.then(ViewPane => {
if (owner.isDisposed()) { return; }
viewConfigTab.set(owner.autoDispose(
ViewPane.ViewConfigTab.create({gristDoc, viewModel: gristDoc.viewModel, skipDomBuild: true})));
})
.catch(reportError);
return viewConfigTab;
}
private _buildPageWidgetConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
// TODO: This uses private methods from ViewConfigTab. These methods are likely to get
// refactored, but if not, should be made public.
const viewConfigTab = this._createViewConfigTab(owner);
return dom.maybe(viewConfigTab, (vct) => [
this._disableIfReadonly(),
cssLabel('WIDGET TITLE',
dom.style('margin-bottom', '14px')),
cssRow(cssTextInput(
Computed.create(owner, (use) => use(activeSection.titleDef)),
val => activeSection.titleDef.saveOnly(val),
testId('right-widget-title')
)),
cssRow(primaryButton('Change Widget', this._createPageWidgetPicker()),
cssRow.cls('-top-space')),
cssSeparator(),
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
cssLabel('Theme'),
dom('div',
vct._buildThemeDom(),
vct._buildLayoutDom())
]),
domComputed((use) => {
if (use(this._pageWidgetType) !== 'record') { return null; }
return dom.create(GridOptions, activeSection);
}),
dom.maybe((use) => use(this._pageWidgetType) === 'chart', () => [
cssLabel('CHART TYPE'),
vct._buildChartConfigDom(),
]),
dom.maybe((use) => use(this._pageWidgetType) === 'custom', () => [
cssLabel('CUSTOM'),
() => {
const parts = vct._buildCustomTypeItems() as any[];
return [
// If 'customViewPlugin' feature is on, show the toggle that allows switching to
// plugin mode. Note that the default mode for a new 'custom' view is 'url', so that's
// the only one that will be shown without the feature flag.
dom.maybe((use) => use(this._gristDoc.app.features).customViewPlugin,
() => dom('div', parts[0].buildDom())),
dom.maybe(use => use(activeSection.customDef.mode) === 'plugin',
() => dom('div', parts[2].buildDom())),
// In the default url mode, allow picking a url and granting/forbidding
// access to data.
dom.maybe(use => use(activeSection.customDef.mode) === 'url',
() => dom('div', parts[1].buildDom())),
];
}
]),
cssSeparator(),
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection, true)
]);
}
private _buildPageSortFilterConfig(owner: MultiHolder) {
const viewConfigTab = this._createViewConfigTab(owner);
return [
cssLabel('SORT'),
dom.maybe(viewConfigTab, (vct) => vct.buildSortDom()),
cssSeparator(),
cssLabel('FILTER'),
dom.maybe(viewConfigTab, (vct) => dom('div', vct._buildFilterDom())),
];
}
private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
const viewConfigTab = this._createViewConfigTab(owner);
const viewModel = this._gristDoc.viewModel;
const table = activeSection.table;
const groupedBy = Computed.create(owner, (use) => use(use(table).groupByColumns));
const link = Computed.create(owner, (use) => {
return linkId({
srcSectionRef: use(activeSection.linkSrcSectionRef),
srcColRef: use(activeSection.linkSrcColRef),
targetColRef: use(activeSection.linkTargetColRef)
});
});
// TODO: this computed is not enough to make sure that the linkOptions are up to date. Indeed
// the selectBy function depends on a much greater number of observables. Creating that many
// dependencies does not seem a better approach. Instead, we could refresh the list of
// linkOptions only when the user clicks the dropdown. Such behaviour is not supported by the
// weasel select function as of writing and would require a custom implementation.
const linkOptions = Computed.create(owner, (use) =>
selectBy(
this._gristDoc.docModel,
use(viewModel.viewSections).peek(),
activeSection,
)
);
link.onWrite((val) => this._gristDoc.saveLink(linkFromId(val)));
return [
this._disableIfReadonly(),
cssLabel('DATA TABLE'),
cssRow(
cssIcon('TypeTable'), cssDataLabel('SOURCE DATA'),
cssContent(dom.text((use) => use(use(table).primaryTableId)),
testId('pwc-table'))
),
dom(
'div',
cssRow(cssIcon('Pivot'), cssDataLabel('GROUPED BY')),
cssRow(domComputed(groupedBy, (cols) => cssList(cols.map((c) => (
cssListItem(dom.text(c.label),
testId('pwc-groupedBy-col'))
))))),
testId('pwc-groupedBy'),
// hide if not a summary table
dom.hide((use) => !use(use(table).summarySourceTable)),
),
cssButtonRow(primaryButton('Edit Data Selection', this._createPageWidgetPicker(),
testId('pwc-editDataSelection')),
dom.maybe(
use => Boolean(use(use(activeSection.table).summarySourceTable)),
() => basicButton(
'Detach',
dom.on('click', () => this._gristDoc.docData.sendAction(
["DetachSummaryViewSection", activeSection.getRowId()])),
testId('detach-button'),
)),
cssRow.cls('-top-space'),
),
// TODO: "Advanced settings" is for "on-demand" marking of tables. This should only be shown
// for raw data tables (once that's supported), should have updated UI, and should possibly
// be hidden for free plans.
dom.maybe(viewConfigTab, (vct) => cssRow(
dom('div', vct._buildAdvancedSettingsDom()),
)),
cssSeparator(),
cssLabel('SELECT BY'),
cssRow(
select(link, linkOptions, {defaultLabel: 'Select Widget'}),
testId('right-select-by')
),
domComputed((use) => {
const activeSectionRef = activeSection.getRowId();
const allViewSections = use(use(viewModel.viewSections).getObservable());
const selectorFor = allViewSections.filter((sec) => use(sec.linkSrcSectionRef) === activeSectionRef);
// TODO: sections should be listed following the order of appearance in the view layout (ie:
// left/right - top/bottom);
return selectorFor.length ? [
cssLabel('SELECTOR FOR', testId('selector-for')),
cssRow(cssList(selectorFor.map((sec) => this._buildSectionItem(sec))))
] : null;
}),
];
}
private _createPageWidgetPicker(): DomElementMethod {
const gristDoc = this._gristDoc;
const section = gristDoc.viewModel.activeSection;
const onSave = (val: IPageWidget) => gristDoc.saveViewSection(section.peek(), val);
return (elem) => { attachPageWidgetPicker(elem, gristDoc.docModel, onSave, {
buttonLabel: 'Save',
value: () => toPageWidget(section.peek()),
selectBy: (val) => gristDoc.selectBy(val),
}); };
}
// Returns dom for a section item.
private _buildSectionItem(sec: ViewSectionRec) {
return cssListItem(
dom.text(sec.titleDef),
testId('selector-for-entry')
);
}
// Returns a DomArg that disables the content of the panel by adding a transparent overlay on top
// of it.
private _disableIfReadonly() {
if (this._gristDoc.docPageModel) {
return dom.maybe(this._gristDoc.docPageModel.isReadonly, () => (
cssOverlay(
testId('disable-overlay'),
cssBottomText('You do not have edit access to this document'),
)
));
}
}
}
export function buildConfigContainer(...args: DomElementArg[]): DomArg {
return cssConfigContainer(
// The `position: relative;` style is needed for the overlay for the readonly mode. Note that
// we cannot set it on the cssConfigContainer directly because it conflicts with how overflow
// works. `padding-top: 1px;` prevents collapsing the top margins for the container and the
// first child.
dom('div', {style: 'position: relative; padding-top: 1px;'}, ...args),
);
}
// This logic is copied from SidePane.js for building DOM from TabContent.
// TODO It may not be needed after new-ui refactoring of the side-pane content.
function tabContentToDom(content: Observable<TabContent[]>|TabContent[]|IDomComponent) {
function buildItemDom(item: any) {
return dom('div.config_item',
dom.show(item.showObs || true),
item.buildDom()
);
}
if ("buildDom" in content) {
return content.buildDom();
}
return cssTabContents(
dom.forEach(content, itemOrHeader => {
if (itemOrHeader.header) {
return dom('div.config_group',
dom.show(itemOrHeader.showObs || true),
itemOrHeader.label ? dom('div.config_header', itemOrHeader.label) : null,
dom.forEach(itemOrHeader.items, item => buildItemDom(item)),
);
} else {
return buildItemDom(itemOrHeader);
}
})
);
}
const cssOverlay = styled('div', `
background-color: white;
opacity: 0.8;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
`);
const cssBottomText = styled('span', `
position: absolute;
bottom: -40px;
padding: 4px 16px;
`);
export const cssLabel = styled('div', `
text-transform: uppercase;
margin: 16px 16px 12px 16px;
font-size: ${vars.xsmallFontSize};
`);
export const cssRow = styled('div', `
display: flex;
margin: 8px 16px;
align-items: center;
&-top-space {
margin-top: 24px;
}
`);
export const cssButtonRow = styled(cssRow, `
margin-left: 0;
margin-right: 0;
& > button {
margin-left: 16px;
}
`);
export const cssIcon = styled(icon, `
flex: 0 0 auto;
background-color: ${colors.slate};
`);
const cssTopBarItem = styled('div', `
flex: 1 1 0px;
height: 100%;
background-color: ${colors.lightGrey};
font-weight: ${vars.headerControlTextWeight};
color: ${colors.dark};
--icon-color: ${colors.slate};
display: flex;
align-items: center;
cursor: default;
&-selected {
background-color: ${colors.lightGreen};
font-weight: initial;
color: ${colors.light};
--icon-color: ${colors.light};
}
&:not(&-selected):hover {
background-color: ${colors.mediumGrey};
--icon-color: ${colors.lightGreen};
}
`);
const cssTopBarIcon = styled(icon, `
flex: none;
margin: 16px;
height: 16px;
width: 16px;
background-color: vars(--icon-color);
`);
const cssHoverCircle = styled('div', `
margin-left: auto;
margin-right: 8px;
width: 32px;
height: 32px;
background: none;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: ${colors.darkGreen};
}
`);
const cssHoverIcon = styled(icon, `
height: 16px;
width: 16px;
background-color: vars(--icon-color);
`);
export const cssSubTabContainer = styled('div', `
height: 48px;
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
`);
export const cssSubTab = styled('div', `
color: ${colors.lightGreen};
flex: auto;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
text-align: center;
padding-bottom: 8px;
border-bottom: 1px solid ${colors.mediumGrey};
cursor: default;
&-selected {
color: ${colors.dark};
border-bottom: 1px solid ${colors.lightGreen};
}
&:not(&-selected):hover {
color: ${colors.darkGreen};
}
&:hover {
border-bottom: 1px solid ${colors.lightGreen};
}
.${cssSubTabContainer.className}:hover > &-selected:not(:hover) {
border-bottom: 1px solid ${colors.mediumGrey};
}
`);
const cssTabContents = styled('div', `
padding: 16px 8px;
overflow: auto;
`);
const cssSeparator = styled('div', `
border-bottom: 1px solid ${colors.mediumGrey};
margin-top: 24px;
`);
const cssConfigContainer = styled('div', `
overflow: auto;
--color-list-item: none;
--color-list-item-hover: none;
&:after {
content: "";
display: block;
height: 40px;
}
& .fieldbuilder_settings {
margin: 16px 0 0 0;
}
`);
const cssDataLabel = styled('div', `
flex: 0 0 81px;
color: ${colors.slate};
font-size: ${vars.xsmallFontSize};
margin-left: 4px;
margin-top: 2px;
`);
const cssContent = styled('div', `
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
min-width: 1em;
`);
const cssList = styled('div', `
list-style: none;
width: 100%;
`);
const cssListItem = styled('li', `
background-color: ${colors.mediumGrey};
border-radius: 2px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
padding: 4px 8px;
`);
const cssTextInput = styled(textInput, `
flex: 1 0 auto;
`);

288
app/client/ui/ShareMenu.ts Normal file
View File

@@ -0,0 +1,288 @@
import {loadUserManager} from 'app/client/lib/imports';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
import {urlState} from 'app/client/models/gristUrlState';
import {makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu';
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 {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 {MenuCreateFunc} from 'popweasel';
function buildOriginalUrlId(urlId: string, isSnapshot: boolean): string {
const parts = parseUrlId(urlId);
return isSnapshot ? buildUrlId({...parts, snapshotId: undefined}) : parts.trunkId;
}
/**
* Builds the content of the export menu. The menu button and contents render differently for
* different modes (normal, pre-fork, fork, snapshot).
*/
export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
// The menu needs pageModel.currentDoc to render the button. It further needs pageModel.gristDoc
// to render its contents, but we handle by merely skipping such content if gristDoc is not yet
// available (a user quick enough to open the menu in this state would have to re-open it).
return dom.maybe(pageModel.currentDoc, (doc) => {
const appModel = pageModel.appModel;
const saveCopy = () => makeCopy(doc, appModel, 'Save Document').catch(reportError);
if (doc.idParts.snapshotId) {
const backToCurrent = () => urlState().pushUrl({doc: buildOriginalUrlId(doc.id, true)});
return shareButton('Back to Current', () => [
menuManageUsers(doc, pageModel),
menuSaveCopy('Save Copy', doc, appModel),
menuOriginal(doc, appModel, true),
menuExports(doc, pageModel),
], {buttonAction: backToCurrent});
} else if (doc.isPreFork || doc.isBareFork) {
// A new unsaved document, or a fiddle, or a public example.
const saveActionTitle = doc.isBareFork ? 'Save Document' : 'Save Copy';
return shareButton(saveActionTitle, () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(saveActionTitle, doc, appModel),
menuExports(doc, pageModel),
], {buttonAction: saveCopy});
} else if (doc.isFork) {
// For forks, the main actions are "Replace Original" and "Save Copy". When "Replace
// Original" is unavailable (for samples, forks of public docs, etc), we'll consider "Save
// Copy" primary and keep it as an action button on top. Otherwise, show a tag without a
// default action; click opens the menu where the user can choose.
if (!roles.canEdit(doc.trunkAccess || null)) {
return shareButton('Save Copy', () => [
menuManageUsers(doc, pageModel),
menuSaveCopy('Save Copy', doc, appModel),
menuOriginal(doc, appModel, false),
menuExports(doc, pageModel),
], {buttonAction: saveCopy});
} else {
return shareButton('Unsaved', () => [
menuManageUsers(doc, pageModel),
menuSaveCopy('Save Copy', doc, appModel),
menuOriginal(doc, appModel, false),
menuExports(doc, pageModel),
]);
}
} else {
return shareButton(null, () => [
menuManageUsers(doc, pageModel),
menuSaveCopy('Duplicate Document', doc, appModel),
menuWorkOnCopy(pageModel),
menuExports(doc, pageModel),
]);
}
});
}
/**
* Render the share button, possibly as a text+icon pair when buttonText is not null. The text
* portion can be an independent action button (when buttonAction is given), or simply a more
* visible extension of the icon that opens the menu.
*/
function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
options: {buttonAction?: () => void} = {},
) {
if (!buttonText) {
// Regular circular button that opens a menu.
return cssHoverCircle({ style: `margin: 5px;` },
cssTopBarBtn('Share'),
menu(menuCreateFunc, {placement: 'bottom-end'}),
testId('tb-share'),
);
} else if (options.buttonAction) {
// Split button: the left text part calls `buttonAction`, and the circular icon opens menu.
return cssShareButton(
cssShareAction(buttonText,
dom.on('click', options.buttonAction),
testId('tb-share-action'),
),
cssShareCircle(
cssShareIcon('Share'),
menu(menuCreateFunc, {placement: 'bottom-end'}),
testId('tb-share'),
),
);
} else {
// Combined button: the left text part and circular icon open the menu as a single button.
return cssShareButton(
cssShareButton.cls('-combined'),
cssShareAction(buttonText),
cssShareCircle(
cssShareIcon('Share')
),
menu(menuCreateFunc, {placement: 'bottom-end'}),
testId('tb-share'),
);
}
}
// Renders "Manage Users" menu item.
function menuManageUsers(doc: DocInfo, pageModel: DocPageModel) {
return [
menuItem(() => manageUsers(doc, pageModel), 'Manage Users',
dom.cls('disabled', !roles.canEditAccess(doc.access)),
testId('tb-share-option')
),
menuDivider(),
];
}
// Renders "Return to Original" and "Replace Original" menu items. When used with snapshots, we
// say "Current Version" in place of the word "Original".
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});
function replaceOriginal() {
const user = appModel.currentValidUser;
replaceTrunkWithFork(user, doc, appModel, origUrlId).catch(reportError);
}
return [
cssMenuSplitLink({href: originalUrl},
cssMenuSplitLinkText(`Return to ${termToUse}`), testId('return-to-original'),
cssMenuIconLink({href: originalUrl, target: '_blank'}, testId('open-original'),
cssMenuIcon('FieldLink'),
)
),
menuItem(replaceOriginal, `Replace ${termToUse}...`,
dom.cls('disabled', !roles.canEdit(doc.trunkAccess || null)),
testId('replace-original'),
),
];
}
// Renders "Save Copy..." and "Copy as Template..." menu items. The name of the first action is
// specified in saveActionTitle.
function menuSaveCopy(saveActionTitle: string, doc: Document, appModel: AppModel) {
const saveCopy = () => makeCopy(doc, appModel, saveActionTitle).catch(reportError);
return [
// TODO Disable these when user has no accessible destinations.
menuItem(saveCopy, `${saveActionTitle}...`, testId('save-copy')),
];
}
// Renders "Work on a Copy" menu item.
function menuWorkOnCopy(pageModel: DocPageModel) {
const gristDoc = pageModel.gristDoc.get();
if (!gristDoc) { return null; }
const makeUnsavedCopy = async function() {
const {urlId} = await gristDoc.docComm.fork();
await urlState().pushUrl({doc: urlId});
};
return [
menuItem(makeUnsavedCopy, 'Work on a Copy', testId('work-on-copy')),
menuText('Edit without affecting the original'),
];
}
/**
* The part of the menu with "Download" and "Export CSV" items.
*/
function menuExports(doc: Document, pageModel: DocPageModel) {
const isElectron = (window as any).isRunningUnderElectron;
const gristDoc = pageModel.gristDoc.get();
if (!gristDoc) { return null; }
// Note: This line adds the 'show in folder' option for electron and a download option for hosted.
return [
menuDivider(),
(isElectron ?
menuItem(() => gristDoc.app.comm.showItemInFolder(doc.name),
'Show in folder', testId('tb-share-option')) :
menuItemLink({ href: gristDoc.getDownloadLink(), target: '_blank', download: ''},
menuIcon('Download'), 'Download', testId('tb-share-option'))
),
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
menuIcon('Download'), 'Export CSV', testId('tb-share-option')),
];
}
/**
* Opens the user-manager for the doc.
*/
async function manageUsers(doc: DocInfo, docPageModel: DocPageModel) {
const appModel: AppModel = docPageModel.appModel;
const api = appModel.api;
const user = appModel.currentValidUser;
(await loadUserManager()).showUserManagerModal(api, {
permissionData: api.getDocAccess(doc.id),
activeEmail: user ? user.email : null,
resourceType: 'document',
resourceId: doc.id,
docPageModel,
// On save, re-fetch the document info, to toggle the "Public Access" icon if it changed.
onSave: () => docPageModel.refreshCurrentDoc(doc),
});
}
const cssShareButton = styled('div', `
display: flex;
align-items: center;
position: relative;
z-index: 0;
margin: 5px;
white-space: nowrap;
--share-btn-bg: ${colors.lightGreen};
&-combined:hover, &-combined.weasel-popup-open {
--share-btn-bg: ${colors.darkGreen};
}
`);
const cssShareAction = styled(primaryButton, `
margin-right: -16px;
padding-right: 24px;
background-color: var(--share-btn-bg);
border-color: var(--share-btn-bg);
`);
const cssShareCircle = styled(cssHoverCircle, `
z-index: 1;
background-color: var(--share-btn-bg);
border: 1px solid white;
&:hover, &.weasel-popup-open {
background-color: ${colors.darkGreen};
}
`);
const cssShareIcon = styled(cssTopBarBtn, `
background-color: white;
height: 30px;
width: 30px;
`);
const cssMenuSplitLink = styled(menuItemLink, `
padding: 0;
align-items: stretch;
`);
const cssMenuSplitLinkText = styled('div', `
flex: auto;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
&:not(:hover) {
background-color: white;
color: black;
}
`);
const cssMenuIconLink = styled('a', `
display: block;
flex: none;
padding: 8px 24px;
background-color: white;
--icon-color: ${colors.lightGreen};
&:hover {
background-color: ${colors.mediumGreyOpaque};
--icon-color: ${colors.darkGreen};
}
`);
const cssMenuIcon = styled(icon, `
display: block;
`);

92
app/client/ui/Tools.ts Normal file
View File

@@ -0,0 +1,92 @@
import { GristDoc } from "app/client/components/GristDoc";
import { urlState } from "app/client/models/gristUrlState";
import { showExampleCard } from 'app/client/ui/ExampleCard';
import { examples } from 'app/client/ui/ExampleInfo';
import { createHelpTools, cssSectionHeader, cssSpacer, cssTools } from 'app/client/ui/LeftPanelCommon';
import { cssLinkText, cssPageEntry, cssPageIcon, cssPageLink } from 'app/client/ui/LeftPanelCommon';
import { colors } from 'app/client/ui2018/cssVars';
import { icon } from 'app/client/ui2018/icons';
import { commonUrls } from 'app/common/gristUrls';
import { Disposable, dom, makeTestId, Observable, styled } from "grainjs";
const testId = makeTestId('test-tools-');
export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Observable<boolean>): Element {
const isEfcr = (gristDoc.app.topAppModel.productFlavor === 'efcr');
return cssTools(
cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)),
cssSectionHeader("TOOLS"),
isEfcr ? cssPageEntry(
cssPageLink(cssPageIcon('FieldReference'), cssLinkText('eFC-Connect'),
{href: commonUrls.efcrConnect, target: '_blank'}),
) : null,
cssPageEntry(
cssPageLink(cssPageIcon('Log'), cssLinkText('Document History'), testId('log'),
dom.on('click', () => gristDoc.showTool('docHistory')))
),
// TODO: polish validation and add it back
dom.maybe((use) => use(gristDoc.app.features).validationsTool, () =>
cssPageEntry(
cssPageLink(cssPageIcon('Validation'), cssLinkText('Validate Data'), testId('validate'),
dom.on('click', () => gristDoc.showTool('validations'))))
),
// TODO: polish repl and add it back.
dom.maybe((use) => use(gristDoc.app.features).replTool, () =>
cssPageEntry(
cssPageLink(cssPageIcon('Repl'), cssLinkText('REPL'), testId('repl'),
dom.on('click', () => gristDoc.showTool('repl'))))
),
cssPageEntry(
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'code'),
cssPageLink(cssPageIcon('Code'),
cssLinkText('Code View'),
urlState().setLinkUrl({docPage: 'code'})
),
testId('code'),
),
cssSpacer(),
dom.maybe(gristDoc.docPageModel.currentDoc, (doc) => {
if (!doc.workspace.isSupportWorkspace) { return null; }
const ex = examples.find((e) => e.matcher.test(doc.name));
if (!ex || !ex.tutorialUrl) { return null; }
const appModel = gristDoc.docPageModel.appModel;
return cssPageEntry(
cssPageLink(cssPageIcon('Page'), cssLinkText('How-to Tutorial'), testId('tutorial'),
{href: ex.tutorialUrl, target: '_blank'},
cssExampleCardOpener(
icon('TypeDetails'),
dom.on('click', (ev, elem) => {
ev.preventDefault();
showExampleCard(ex, appModel, elem, true);
}),
testId('welcome-opener'),
(elem) => {
// Once the trigger element is attached to DOM, show the card.
setTimeout(() => showExampleCard(ex, appModel, elem), 0);
},
),
),
);
}),
createHelpTools(gristDoc.docPageModel.appModel, false)
);
}
const cssExampleCardOpener = styled('div', `
cursor: pointer;
margin-right: 4px;
margin-left: auto;
border-radius: 16px;
border-radius: 3px;
height: 24px;
width: 24px;
padding: 4px;
line-height: 0px;
--icon-color: ${colors.light};
background-color: ${colors.lightGreen};
&:hover {
background-color: ${colors.darkGreen};
}
`);

127
app/client/ui/TopBar.ts Normal file
View File

@@ -0,0 +1,127 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {loadSearch} from 'app/client/lib/imports';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {workspaceName} from 'app/client/models/WorkspaceInfo';
import {AccountWidget} from 'app/client/ui/AccountWidget';
import {buildNotifyMenuButton} from 'app/client/ui/NotifyUI';
import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {waitGrainObs} from 'app/common/gutil';
import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
export function createTopBarHome(appModel: AppModel) {
return [
cssFlexSpace(),
buildNotifyMenuButton(appModel.notifier, appModel),
dom('div', dom.create(AccountWidget, appModel)),
];
}
export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageModel: DocPageModel, allCommands: any) {
const doc = pageModel.currentDoc;
const renameDoc = (val: string) => pageModel.renameDoc(val);
const displayNameWs = Computed.create(owner, pageModel.currentWorkspace,
(use, ws) => ws ? {...ws, name: workspaceName(appModel, ws)} : ws);
const searchBarContent = Observable.create<HTMLElement|null>(owner, null);
loadSearch()
.then(async module => {
const model = module.SearchModelImpl.create(owner, (await waitGrainObs(pageModel.gristDoc))!);
searchBarContent.set(module.searchBar(model, makeTestId('test-tb-search-')));
})
.catch(reportError);
return [
// TODO Before gristDoc is loaded, we could show doc-name without the page. For now, we delay
// showing of breadcrumbs until gristDoc is loaded.
dom.maybe(pageModel.gristDoc, (gristDoc) =>
cssBreadcrumbContainer(
docBreadcrumbs(displayNameWs, pageModel.currentDocTitle, gristDoc.currentPageName, {
docNameSave: renameDoc,
pageNameSave: getRenamePageFn(gristDoc),
isPageNameReadOnly: (use) => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== 'number',
isDocNameReadOnly: (use) => use(gristDoc.isReadonly) || use(pageModel.isFork),
isFork: pageModel.isFork,
isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork) && !use(pageModel.isSample)),
isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)),
isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)),
})
)
),
cssFlexSpace(),
// Don't show useless undo/redo buttons for sample docs, to leave more space for "Make copy".
dom.maybe(pageModel.undoState, (state) => [
topBarUndoBtn('Undo',
dom.on('click', () => state.isUndoDisabled.get() || allCommands.undo.run()),
cssHoverCircle.cls('-disabled', state.isUndoDisabled),
testId('undo')
),
topBarUndoBtn('Redo',
dom.on('click', () => state.isRedoDisabled.get() || allCommands.redo.run()),
cssHoverCircle.cls('-disabled', state.isRedoDisabled),
testId('redo')
),
cssSpacer(),
]),
dom.domComputed(searchBarContent),
buildShareMenuButton(pageModel),
buildNotifyMenuButton(appModel.notifier, appModel),
dom('div', dom.create(AccountWidget, appModel, pageModel))
];
}
// Given the GristDoc instance, returns a rename function for the current active page.
// If the current page is not able to be renamed or the new name is invalid, the function is a noop.
function getRenamePageFn(gristDoc: GristDoc): (val: string) => Promise<void> {
return async (val: string) => {
const views = gristDoc.docModel.views;
const viewId = gristDoc.activeViewId.get();
if (typeof viewId === 'number' && val.length > 0) {
const name = views.rowModels[viewId].name;
await name.saveOnly(val);
}
};
}
function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element {
return cssHoverCircle(
cssTopBarUndoBtn(iconName),
...domArgs
);
}
const cssTopBarUndoBtn = styled(cssTopBarBtn, `
background-color: ${colors.slate};
.${cssHoverCircle.className}:hover & {
background-color: ${colors.lightGreen};
}
.${cssHoverCircle.className}-disabled:hover & {
background-color: ${colors.darkGrey};
cursor: default;
}
`);
const cssBreadcrumbContainer = styled('div', `
padding: 7px;
flex: 1 1 auto;
min-width: 0px;
overflow: hidden;
`);
const cssFlexSpace = styled('div', `
flex: 1 1 0px;
`);
const cssSpacer = styled('div', `
width: 10px;
`);

View File

@@ -0,0 +1,34 @@
import {colors} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {styled} from 'grainjs';
export const cssHoverCircle = styled('div', `
width: 32px;
height: 32px;
background: none;
border-radius: 16px;
&:hover, &.weasel-popup-open {
background-color: ${colors.mediumGrey};
}
&-disabled:hover {
background: none;
}
`);
export const cssTopBarBtn = styled(icon, `
width: 32px;
height: 32px;
padding: 8px 8px;
cursor: pointer;
-webkit-mask-size: 16px 16px;
background-color: ${colors.lightGreen};
.${cssHoverCircle.className}-disabled & {
background-color: ${colors.darkGrey};
cursor: default;
}
&-slate { background-color: ${colors.slate}; }
&-error { background-color: ${colors.error}; }
`);

View File

@@ -0,0 +1,658 @@
import { TreeItem, TreeModel, TreeNode, walkTree } from "app/client/models/TreeModel";
import { mouseDrag, MouseDragHandler, MouseDragStart } from "app/client/ui/mouseDrag";
import * as css from 'app/client/ui/TreeViewComponentCss';
import { Computed, dom, DomArg, Holder } from "grainjs";
import { Disposable, IDisposable, makeTestId, ObsArray, Observable, observable } from "grainjs";
import debounce = require('lodash/debounce');
import defaults = require("lodash/defaults");
import noop = require('lodash/noop');
// DropZone identifies a location where an item can be inserted
interface DropZone {
zone: 'above'|'below'|'within';
item: ItemModel;
// `locked` allows to lock the dropzone until the cursor leaves the current item. This is useful
// when the dropzone is not computed from the cursor position but rather is set to 'within' an
// item when the auto-expander's timeout expires (defined in `this._updateExpander(...)`). In such
// a case it would be nearly impossible for the user to properly drop the item without the
// lock. Because when she releases the mouse she is very likely to move cursor unintentionally
// which would update the dropZone to either 'above' or 'below' and would insert item in the
// desired position.
locked?: boolean;
}
// The view model for a TreeItem
interface ItemModel {
highlight: Observable<boolean>;
collapsed: Observable<boolean>;
dragged: Observable<boolean>;
// vertical distance in px the user is dragging the item
deltaY: Observable<number>;
// the px distance from the left side of the container to the label
offsetLeft: () => number;
treeItem: TreeItem;
headerElement: HTMLElement;
containerElement: HTMLElement;
handleElement: HTMLElement;
labelElement: HTMLElement;
offsetElement: HTMLElement;
arrowElement: HTMLElement;
}
// Whether item1 and item2 are models of the same item.
function eq(item1: ItemModel|"root", item2: ItemModel|"root") {
if (item1 === "root" && item2 === "root") {
return true;
}
if (item1 === "root" || item2 === "root") {
return false;
}
return item1.treeItem === item2.treeItem;
}
// Set when a drag starts.
interface Drag extends IDisposable {
startY: number;
item: ItemModel;
// a holder used to update the highlight surrounding the target's parent
highlightedBox: Holder<IDisposable>;
autoExpander: Holder<{item: ItemModel} & IDisposable>;
}
// The geometry of the target which is a visual artifact showing where the user can drop an item.
interface Target {
width: number;
top: number;
left: number;
}
export interface TreeViewOptions {
// the number of pixels used for indentation.
offset?: number;
expanderDelay?: number;
// the delay a user has to keep the mouse down on a item before dragging starts
dragStartDelay?: number;
isOpen?: Observable<boolean>;
selected: Observable<TreeItem|null>;
// When true turns readonly mode on, defaults to false.
isReadonly?: Observable<boolean>;
}
const testId = makeTestId('test-treeview-');
/**
* The TreeViewComponent is a component that can show hierarchical data. It supports collapsing
* children and dragging and dropping to move items in the tree.
*
* Hovering an item reveals a handle. User must then grab that handle to drag an item. During drag
* the handle slides vertically to follow cursor's motion. During drag, the component highlights
* visually where the user can drop the item by displaying a target (above or below) the targeted
* item and by highlighting its parent. In order to ensure data consistency, the component prevents
* dropping an item within its own children. If the cursor leaves the component during a drag, all
* such visual artifact (handle, target and target's parent) are hidden, but if the cursor re-enter
* the componet without releasing the mouse, they will show again allowing user to resume dragging.
*/
// note to self: in the future the model will be updated by the server, which could cause conflicts
// if the user is dragging at the same time. It could be simpler to freeze the model and to differ
// their resolution until after the drag terminates.
export class TreeViewComponent extends Disposable {
private readonly _options: Required<TreeViewOptions>;
private readonly _containerElement: Element;
private _drag: Holder<Drag> = Holder.create(this);
private _hoveredItem: ItemModel|"root" = "root";
private _dropZone: DropZone|null = null;
private readonly _hideTarget = observable(true);
private readonly _target = observable<Target>({width: 0, top: 0, left: 0});
private readonly _dragging = observable(false);
private readonly _isClosed: Computed<boolean>;
private _treeItemMap: Map<TreeItem, Element> = new Map();
private _childrenDom: Observable<Node>;
constructor(private _model: Observable<TreeModel>, options: TreeViewOptions) {
super();
this._options = defaults(options, {
offset: 10,
expanderDelay: 1000,
dragStartDelay: 1000,
isOpen: Observable.create(this, true),
isReadonly: Observable.create(this, false),
});
// While building dom we add listeners to the children of all tree nodes to watch for changes
// and call this._update. Hence, repeated calls to this._update is likely to add or remove
// listeners to the observable that triggered the udpate which is not supported by grainjs and
// could fail (possibly infinite loop). Debounce allows for several change to resolve to a
// single update.
this._update = debounce(this._update.bind(this), 0, {leading: false});
// build dom for the tree of children
this._childrenDom = observable(this._buildChildren(this._model.get().children()));
this.autoDispose(this._model.addListener(this._update, this));
this._isClosed = Computed.create(this, (use) => !use(this._options.isOpen));
this._containerElement = css.treeViewContainer(
// hides the drop zone and target when the cursor leaves the component
dom.on('mouseleave', () => {
this._setDropZone(null);
const drag = this._drag.get();
if (drag) {
drag.autoExpander.clear();
drag.item.handleElement.style.display = 'none';
}
}),
dom.on('mouseenter', () => {
const drag = this._drag.get();
if (drag) {
drag.item.handleElement.style.display = '';
}
}),
// it's important to insert drop zone indicator before children, otherwise it could prevent
// some mouse events to hit children's dom
this._buildTarget(),
// insert children
dom.domComputed(this._childrenDom),
css.treeViewContainer.cls('-close', this._isClosed),
css.treeViewContainer.cls('-dragging', this._dragging),
testId('container'),
);
}
public buildDom() { return this._containerElement; }
// Starts a drag.
private _startDrag(ev: MouseEvent) {
if (this._options.isReadonly.get()) { return null; }
if (this._isClosed.get()) { return null; }
this._hoveredItem = this._closestItem(ev.target as HTMLElement|null);
if (this._hoveredItem === "root") {
return null;
}
const drag = {
startY: ev.clientY - this._hoveredItem.headerElement.getBoundingClientRect().top,
item: this._hoveredItem,
highlightedBox: Holder.create(this),
autoExpander: Holder.create<IDisposable & {item: ItemModel }>(this),
dispose: () => {
drag.autoExpander.dispose();
drag.highlightedBox.dispose();
drag.item.dragged.set(false);
drag.item.handleElement.style.display = '';
drag.item.deltaY.set(0);
}
};
this._drag.autoDispose(drag);
this._hoveredItem.dragged.set(true);
this._dragging.set(true);
return {
onMove: (mouseEvent: MouseEvent) => this._onMouseMove(mouseEvent),
onStop: () => this._terminateDragging(),
};
}
// Terminates a drag.
private _terminateDragging() {
const drag = this._drag.get();
// Clearing the `drag` instance before moving the item allow to revert the style of the item
// being dragged before it gets removed from the model.
this._drag.clear();
if (drag && this._dropZone) {
this._moveTreeNode(drag.item, this._dropZone);
}
this._setDropZone(null);
this._hideTarget.set(true);
this._dragging.set(false);
}
// The target is an horizontal bar indicating where user can drop an item. It typically shows
// above or below any particular item to indicate where the dragged item would be inserted.
private _buildTarget() {
return css.target(
testId('target'),
// show only if a drop zone is set
dom.hide(this._hideTarget),
dom.style('width', (use) => use(this._target).width + 'px'),
dom.style('top', (use) => use(this._target).top + 'px'),
dom.style('left', (use) => use(this._target).left + 'px'),
);
}
// Update this._childrenDom with the content of the new tree. Its rebuilds entirely the tree of
// items and reuses dom from the old content for each item that were already part of the old
// tree. Then takes care of disposing dom for thoses items that were removed from the old tree.
private _update() {
this._childrenDom.set(this._buildChildren(this._model.get().children(), 0));
// Dispose all the items from this._treeItemMap that are not in the new tree. Note an item
// already takes care of removing itself from the this._treeItemMap on dispose (thanks to the
// dom.onDispose(() => this._treeItemMap.delete(treeItem)) in this._getOrCreateItem). First
// create a map with all the items from _treeItemMap (they may or may not be included in the new
// tree), then walk the new tree and remove all of its items from the map. Eventually, what
// remains in the map are the elements that need disposal.
const map = new Map(this._treeItemMap);
walkTree(this._model.get(), (treeItem) => map.delete(treeItem));
map.forEach((elem, key) => dom.domDispose(elem));
}
// Build list of children. For each child reuses item's dom if already exist and update the offset
// and the list of children. Also add a listener that calls this._update to children.
private _buildChildren(children: ObsArray<TreeItem>, level: number = 0) {
return css.itemChildren(
children.get().map(treeItem => {
const elem = this._getOrCreateItem(treeItem);
this._setOffset(elem, level);
const itemHeaderElem = elem.children[0];
const itemChildren = treeItem.children();
const arrowElement = dom.getData(elem, 'item').arrowElement;
if (itemChildren) {
const itemChildrenElem = this._buildChildren(treeItem.children()!, level + 1);
replaceChildren(elem, itemHeaderElem, itemChildrenElem);
dom.styleElem(arrowElement, 'visibility', itemChildren.get().length ? 'visible' : 'hidden');
} else {
replaceChildren(elem, itemHeaderElem);
dom.styleElem(arrowElement, 'visibility', 'hidden');
}
return elem;
}),
dom.autoDispose(children.addListener(this._update, this)),
);
}
// Get or create dom for treeItem.
private _getOrCreateItem(treeItem: TreeItem): Element {
let item = this._treeItemMap.get(treeItem);
if (!item) {
item = this._buildTreeItemDom(treeItem,
dom.onDispose(() => this._treeItemMap.delete(treeItem))
);
this._treeItemMap.set(treeItem, item);
}
return item;
}
private _setOffset(el: Element, level: number) {
const item = dom.getData(el, 'item') as ItemModel;
item.offsetElement.style.width = level * this._options.offset + "px";
}
private _buildTreeItemDom(treeItem: TreeItem, ...args: DomArg[]): Element {
const collapsed = observable(false);
const dragged = observable(false);
// vertical distance in px the user is dragging the item
const deltaY = observable(0);
const children = treeItem.children();
const offsetLeft = () =>
labelElement.getBoundingClientRect().left - this._containerElement.getBoundingClientRect().left;
const highlight = observable(false);
let headerElement: HTMLElement;
let labelElement: HTMLElement;
let handleElement: HTMLElement;
let offsetElement: HTMLElement;
let arrowElement: HTMLElement;
const containerElement = dom('div.itemContainer',
testId('itemContainer'),
dom.cls('collapsed', collapsed),
css.itemHeaderWrapper(
testId('itemHeaderWrapper'),
dom.cls('dragged', dragged),
css.itemHeaderWrapper.cls('-not-dragging', (use) => !use(this._dragging)),
headerElement = css.itemHeader(
testId('itemHeader'),
dom.cls('highlight', highlight),
dom.cls('selected', (use) => use(this._options.selected) === treeItem),
offsetElement = css.offset(testId('offset')),
arrowElement = css.arrow(
css.dropdown('Dropdown'),
testId('itemArrow'),
dom.style('transform', (use) => use(collapsed) ? 'rotate(-90deg)' : ''),
dom.on('click', (ev) => toggle(collapsed)),
// Let's prevent dragging to start when un-intentionally holding the mouse down on an arrow.
dom.on('mousedown', (ev) => ev.stopPropagation()),
),
labelElement = css.itemLabel(
testId('label'),
treeItem.buildDom(),
dom.style('top', (use) => use(deltaY) + 'px')
),
delayedMouseDrag(this._startDrag.bind(this), this._options.dragStartDelay),
),
css.itemLabelRight(
handleElement = css.centeredIcon('DragDrop',
dom.style('top', (use) => use(deltaY) + 'px'),
testId('handle'),
dom.hide(this._options.isReadonly),
),
mouseDrag((startEvent, elem) => this._startDrag(startEvent))),
),
...args
);
// Associates some of this item internals to the dom element. This is what makes possible to
// find which item user is currently pointing at using `const item =
// this._closestItem(ev.target);` where ev is a mouse event.
const itemModel = {
collapsed, dragged, children, treeItem, offsetLeft, highlight, deltaY,
headerElement,
containerElement,
handleElement,
labelElement,
offsetElement,
arrowElement,
} as ItemModel;
dom.dataElem(containerElement, 'item', itemModel);
return containerElement;
}
private _updateHandle(y: number) {
const drag = this._drag.get();
if (drag) {
drag.item.deltaY.set(y - drag.startY - drag.item.headerElement.getBoundingClientRect().top);
}
}
private _onMouseMove(ev: MouseEvent) {
if (!(ev.target instanceof HTMLElement)) {
return null;
}
const item = this._closestItem(ev.target);
if (item === "root") {
return;
}
// updates the expander when cursor is entering a new item while dragging
const drag = this._drag.get();
if (drag && !eq(this._hoveredItem, item)) {
this._updateExpander(drag, item);
}
this._hoveredItem = item;
this._updateHandle(ev.clientY);
// update the target, update the target's parent
const dropZone = this._getDropZone(ev.clientY);
this._setDropZone(dropZone);
}
// Set the drop zone and update the target and target's parent
private _setDropZone(dropZone: DropZone|null) {
// if there is a locked dropzone on the hovered item already set, do nothing (see
// `DropZone#locked` documentation at the begin of this file for more detail)
if (this._dropZone && this._dropZone.locked && eq(this._dropZone.item, this._hoveredItem)) {
return;
}
this._dropZone = dropZone;
this._updateTarget();
this._updateTargetParent();
}
// Update the target based on this._dropZone.
private _updateTarget() {
const dropZone = this._dropZone;
if (dropZone) {
const left = this._getDropZoneOffsetLeft(dropZone);
const width = this._getDropZoneRight(dropZone) - left;
const top = this._getDropZoneTop(dropZone);
this._target.set({width, left, top});
this._hideTarget.set(false);
} else {
this._hideTarget.set(true);
}
}
// compute the px distance between the left side of the container and the right side of the header
private _getDropZoneRight(dropZone: DropZone): number {
const headerRight = dropZone.item.headerElement.getBoundingClientRect().right;
const containerRight = this._containerElement.getBoundingClientRect().left;
return headerRight - containerRight;
}
// compute the px distance between the left side of the container and the drop zone
private _getDropZoneOffsetLeft(dropZone: DropZone): number {
// when target is 'within' the item we must add one level of indentation to the items left offset
return dropZone.item.offsetLeft() + (dropZone.zone === 'within' ? this._options.offset : 0);
}
// compute the px distance between the top of the container and the drop zone
private _getDropZoneTop(dropZone: DropZone): number {
const el = dropZone.item.headerElement;
// when crossing the border between 2 consecutive items A and B while dragging another item, in
// order to allow the target to remain steady between A and B we need to remove 2 px when
// dropzone is 'above', otherwise it causes the target to flicker.
return dropZone.zone === 'above' ? el.offsetTop - 2 : el.offsetTop + el.clientHeight;
}
// Turns off the highlight on the former parent, and turns it on the new parent.
private _updateTargetParent() {
const drag = this._drag.get();
if (!drag) {
return;
}
const newParent = this._dropZone ? this._getDropZoneParent(this._dropZone) : null;
if (newParent && newParent !== "root") {
drag.highlightedBox.autoDispose({dispose: () => newParent.highlight.set(false)});
newParent.highlight.set(true);
} else {
// setting holder to a dump value allows to dispose the previous value
drag.highlightedBox.autoDispose({dispose: noop});
}
}
private _getDropZone(mouseY: number): DropZone|null {
const item = this._hoveredItem;
const drag = this._drag.get();
if (!drag || item === "root") {
return null;
}
// let's not permit dropping above or below the dragged item
if (eq(drag.item, item)) {
return null;
}
// prevents dropping items into their own children
if (this._isInChildOf(item.containerElement, drag.item.containerElement)) {
return null;
}
const children = item.treeItem.children();
const rect = item.headerElement.getBoundingClientRect();
// if cursor is over the top half of the header set the drop zone to above this item
if ((mouseY - rect.top) <= rect.height / 2) {
return {zone: 'above', item};
}
// if cursor is over the bottom half of the header set the drop zone to below this item, unless
// the children are expanded in which case set the drop zone to 'within' this item.
if ((mouseY - rect.top) > rect.height / 2) {
if (!item.collapsed.get() && children && children.get().length) {
// set drop zone to above the first child only if the dragged item is not this item, because
// it is not allowed to drop item into their own children.
if (eq(item, drag.item)) {
return null;
}
return {zone: 'within', item};
} else {
return {zone: 'below', item};
}
}
return null;
}
// Returns whether `element` is nested in a child of `parent`. Both `el` and `parent` must be
// a child of this._containerElement.
private _isInChildOf(el: Element, parent: Element) {
while (el.parentElement
&& el.parentElement !== parent
&& el.parentElement !== this._containerElement // let's stop at the top element
) {
el = el.parentElement;
}
return el.parentElement === parent;
}
// Finds the closest ancestor with '.itemContainer' and returns the attached ItemModel. Returns
// "root" if none are found.
private _closestItem(element: HTMLElement|null): ItemModel|"root" {
if (element) {
let el: HTMLElement|null = element;
while (el && el !== this._containerElement) {
if (el.classList.contains('itemContainer')) {
return dom.getData(el, 'item');
}
el = el.parentElement;
}
}
return "root";
}
// Return the ItemModel of the item's parent or 'root' if parent is the root.
private _getParent(item: ItemModel): ItemModel|"root" {
return this._closestItem(item.containerElement.parentElement);
}
// Return the ItemModel of the dropZone's parent or 'root' if parent is the root.
private _getDropZoneParent(zone: DropZone): ItemModel|"root" {
return zone.zone === 'within' ? zone.item : this._getParent(zone.item);
}
// Returns the TreeNode associated with the item or the TreeModel if item is the root.
private _getTreeNode(item: ItemModel|"root"): TreeNode {
return item === "root" ? this._model.get() : item.treeItem;
}
// returns the item that is just after where zone is pointing to
private _getNextChild(zone: DropZone): TreeItem|undefined {
const children = this._getTreeNode(this._getDropZoneParent(zone)).children();
if (!children) {
return undefined;
}
switch (zone.zone) {
case "within": return children.get()[0];
case "above": return zone.item.treeItem;
case "below": return findNext(children.get(), zone.item.treeItem);
}
}
// trigger calls to TreeNode#insertBefore(...) and TreeNode#removeChild(...) to move draggedItem
// to where zone is pointing to.
private _moveTreeNode(draggedItem: ItemModel, zone: DropZone) {
const parentTo = this._getTreeNode(this._getDropZoneParent(zone));
const childrenTo = parentTo.children();
const nextChild = this._getNextChild(zone);
const parentFrom = this._getTreeNode(this._getParent(draggedItem));
if (!childrenTo) {
throw new Error('Should not be possible to drop into an item with `null` children');
}
if (parentTo === parentFrom) {
// if dropping an item below the above item, do nothing.
if (nextChild === draggedItem.treeItem) {
return;
}
// if dropping and item above the below item, do nothing.
if (findNext(childrenTo.get(), draggedItem.treeItem) === nextChild) {
return;
}
}
// call callbacks
parentTo.insertBefore(draggedItem.treeItem, nextChild || null);
}
// Shuts down the previous expander, and sets a new one for item if it is not the dragged item and
// it can be expanded (ie: it has collapsed children or it has empty list of children).
private _updateExpander(drag: Drag, item: ItemModel) {
const children = item.treeItem.children();
if (eq(drag.item, item) || !children || children.get().length && !item.collapsed.get()) {
drag.autoExpander.clear();
} else {
const callback = () => {
// Expanding the item needs some extra care. Because we could push the dragged item
// downwards in the view (if the dragged item is below the item to be expanded). In which
// case we must update `item.deltaY` to reflect the offset in order to prevent an offset
// between the handle (and the drag image) and the cursor. So let's first save the old pos of the item.
const oldItemTop = drag.item.headerElement.getBoundingClientRect().top;
// let's expand the item
item.collapsed.set(false);
// let's get the new pos for the dragged item, and get the diff
const newItemTop = drag.item.headerElement.getBoundingClientRect().top;
const offset = newItemTop - oldItemTop;
// let's reflect the offset on `item.deltaY`
drag.item.deltaY.set(drag.item.deltaY.get() - offset);
// then set the dropzone.
this._setDropZone({zone: 'within', item, locked: true});
};
const timeoutId = window.setTimeout(callback, this._options.expanderDelay);
const dispose = () => window.clearTimeout(timeoutId);
drag.autoExpander.autoDispose({item, dispose});
}
}
}
// returns the item next to item in children, or null.
function findNext(children: TreeItem[], item: TreeItem) {
return children.find((val, i, array) => Boolean(i) && array[i - 1] === item);
}
function toggle(obs: Observable<boolean>) {
obs.set(!obs.get());
}
export function addTreeView(model: Observable<TreeModel>, options: TreeViewOptions) {
return dom.create(TreeViewComponent, model, options);
}
// Starts dragging only when the user keeps the mouse down for a while. Also the cursor must not
// move until the timer expires. Implementation relies on `./mouseDrag` and a timer that will call
// `startDrag` only after a timer expires.
function delayedMouseDrag(startDrag: MouseDragStart, delay: number) {
return mouseDrag((startEvent, el) => {
// the drag handler is assigned when the timer expires
let handler: MouseDragHandler|null;
const timeoutId = setTimeout(() => handler = startDrag(startEvent, el), delay);
dom.onDisposeElem(el, () => clearTimeout(timeoutId));
function onMove(ev: MouseEvent) {
// Clears timeout if cursor moves before timer expires, ie: the startDrag won't be called.
handler ? handler.onMove(ev) : clearTimeout(timeoutId);
}
function onStop(ev: MouseEvent) {
handler ? handler.onStop(ev) : clearTimeout(timeoutId);
}
return {onMove, onStop};
});
}
// Replaces the children of elem with children.
function replaceChildren(elem: Element, ...children: Element[]) {
while (elem.firstChild) {
elem.removeChild(elem.firstChild);
}
for (const child of children) {
elem.appendChild(child);
}
}

View File

@@ -0,0 +1,119 @@
import { colors, vars } from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons";
import { styled } from "grainjs";
export const treeViewContainer = styled('div', `
user-select: none;
-moz-user-select: none;
position: relative;
display: flex;
flex-direction: row;
/* adds 2px to make room for the target when shown above the first item */
margin-top: 2px;
`);
export const itemChildren = styled('div', `
flex: 1 1 auto;
min-width: 0;
.itemContainer.collapsed > & {
display: none;
}
`);
export const dragDropContainer = styled('div', `
position: relative;
`);
// pointer-events: none is set while dragging, to ensure that mouse events are sent to the element
// over which we are dragging, rather than the one being dragged (which is also under the cursor).
export const itemHeaderWrapper = styled('div', `
display: flex;
flex-direction: row;
&.dragged {
pointer-events: none;
}
`);
export const itemHeader = styled('div', `
display: flex;
flex-direction: row;
flex-grow: 1;
min-width: 0;
border-radius: 0 2px 2px 0;
border: solid 1px transparent;
.${itemHeaderWrapper.className}-not-dragging:hover > & {
background-color: ${colors.mediumGrey};
}
.${itemHeaderWrapper.className}-not-dragging > &.selected {
background-color: ${colors.darkBg};
color: white;
}
&.highlight {
border-color: ${vars.controlFg};
}
`);
export const dropdown = styled(icon, `
background-color: ${colors.slate};
.${itemHeaderWrapper.className}-not-dragging > .${itemHeader.className}.selected & {
background-color: white;
}
`);
export const itemLabelRight = styled('div', `
--icon-color: ${colors.slate};
width: 16px;
.${treeViewContainer.className}-close & {
display: none;
}
`);
export const centeredIcon = styled(icon, `
height: 100%;
visibility: hidden;
.${itemHeaderWrapper.className}-not-dragging:hover &, .${itemHeaderWrapper.className}.dragged & {
visibility: visible;
cursor: grab;
}
`);
export const itemLabel = styled('div', `
flex-grow: 1;
min-width: 0;
cursor: pointer;
.${itemHeaderWrapper.className}.dragged & {
opacity: 0.5;
transform: rotate(3deg);
position: relative;
}
`);
export const arrow = styled('div', `
display: flex;
flex-shrink: 0;
align-items: center;
width: 24px;
justify-content: center;
.${treeViewContainer.className}-close & {
display: none;
}
`);
export const offset = styled('div', `
flex-shrink: 0;
.${treeViewContainer.className}-close & {
display: none;
}
`);
// I gave target a 2px height in order to make it dstinguishable from the header's highlight which
// is a 1px border of same color. Setting pointer-events to prevent target from grabbing mouse
// events none and causes some intermittent interruptions to the processing of mouse events when
// hovering while dragging.
export const target = styled('div', `
position: absolute;
height: 2px;
background: ${vars.controlFg};
pointer-events: none;
`);

127
app/client/ui/UserImage.ts Normal file
View File

@@ -0,0 +1,127 @@
import {colors} from 'app/client/ui2018/cssVars';
import {FullUser} from 'app/common/LoginSessionAPI';
import {dom, DomElementArg, styled} from 'grainjs';
export type Size = 'small' | 'medium' | 'large';
/**
* Returns a DOM element showing a circular icon with a user's picture, or the user's initials if
* picture is missing. Also vares the color of the circle when using initials.
*/
export function createUserImage(user: FullUser|null, size: Size, ...args: DomElementArg[]): HTMLElement {
let initials: string;
return cssUserImage(
cssUserImage.cls('-' + size),
(!user || user.anonymous) ? cssUserImage.cls('-anon') :
[
(user.picture ? cssUserPicture({src: user.picture}, dom.on('error', (ev, el) => dom.hideElem(el, true))) : null),
dom.style('background-color', pickColor(user)),
(initials = getInitials(user)).length > 1 ? cssUserImage.cls('-reduced') : null,
initials!,
],
...args,
);
}
/**
* Extracts initials from a user, e.g. a FullUser. E.g. "Foo Bar" is turned into "FB", and
* "foo@example.com" into just "f".
*
* Exported for testing.
*/
export function getInitials(user: {name?: string, email?: string}) {
const source = (user.name && user.name.trim()) || (user.email && user.email.trim()) || '';
return source.split(/\s+/, 2).map(p => p.slice(0, 1)).join('');
}
/**
* Hashes the username to return a color.
*/
function pickColor(user: FullUser): string {
let c = hashCode(user.name + ':' + user.email) % someColors.length;
if (c < 0) { c += someColors.length; }
return someColors[c];
}
/**
* Hash a string into an integer. From https://stackoverflow.com/a/7616484/328565.
*/
function hashCode(str: string): number {
let hash: number = 0;
for (let i = 0; i < str.length; i++) {
// tslint:disable-next-line:no-bitwise
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return hash;
}
// These mostly come from https://clrs.cc/
const someColors = [
'#0B437D',
'#0074D9',
'#7FDBFF',
'#39CCCC',
'#16DD6D',
'#2ECC40',
'#16B378',
'#EFCC00',
'#FF851B',
'#FF4136',
'#85144b',
'#F012BE',
'#B10DC9',
];
export const cssUserImage = styled('div', `
position: relative;
text-align: center;
text-transform: uppercase;
user-select: none;
-moz-user-select: none;
color: white;
border-radius: 100px;
display: flex;
align-items: center;
justify-content: center;
&-small {
width: 24px;
height: 24px;
font-size: 13.5px;
--reduced-font-size: 12px;
}
&-medium {
width: 32px;
height: 32px;
font-size: 18px;
--reduced-font-size: 16px;
}
&-large {
width: 40px;
height: 40px;
font-size: 22.5px;
--reduced-font-size: 20px;
}
&-anon {
border: 1px solid ${colors.slate};
color: ${colors.slate};
}
&-anon::before {
content: "?"
}
&-reduced {
font-size: var(--reduced-font-size);
}
`);
const cssUserPicture = styled('img', `
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
background-color: white;
border-radius: 100px;
border: 1px solid white; /* make sure edge of circle with initials is not visible */
box-sizing: content-box; /* keep the border outside of the size of the image */
`);

View File

@@ -0,0 +1,637 @@
/**
* This module exports a UserManager component, consisting of a list of emails, each with an
* associated role (See app/common/roles), and a way to change roles, and add or remove new users.
* The component is instantiated as a modal with a confirm button to pass changes to the server.
*
* It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions.
*/
import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles';
import {tbind} from 'app/common/tbind';
import {PermissionData, UserAPI} from 'app/common/UserAPI';
import {computed, Computed, Disposable, observable, Observable} from 'grainjs';
import {dom, DomElementArg, input, styled} from 'grainjs';
import {cssMenuItem} from 'popweasel';
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {setTestState} from 'app/client/lib/testState';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {reportError} from 'app/client/models/errors';
import {getCurrentDocUrl, urlState} from 'app/client/models/gristUrlState';
import {IEditableMember, IMemberSelectOption, IOrgMemberSelectOption} from 'app/client/models/UserManagerModel';
import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserManagerModel';
import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel';
import {AccessRules} from 'app/client/ui/AccessRules';
import {shadowScroll} from 'app/client/ui/shadowScroll';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {createUserImage, cssUserImage} from 'app/client/ui/UserImage';
import {basicButton, bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {inputMenu, menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {cssModalBody, cssModalButtons, cssModalTitle, modal} from 'app/client/ui2018/modals';
export interface IUserManagerOptions {
permissionData: Promise<PermissionData>;
activeEmail: string|null;
resourceType: ResourceType;
resourceId: string|number;
docPageModel?: DocPageModel;
onSave?: () => Promise<unknown>;
}
// Returns an instance of UserManagerModel given IUserManagerOptions. Makes the async call for the
// required properties of the options.
async function getModel(options: IUserManagerOptions): Promise<UserManagerModelImpl> {
const permissionData = await options.permissionData;
return new UserManagerModelImpl(permissionData, options.resourceType, options.activeEmail);
}
/**
* Public interface for creating the UserManager in the app. Creates a modal that includes
* the UserManager menu with save and cancel buttons.
*/
export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOptions) {
const modelObs: Observable<UserManagerModel|null> = observable(null);
const aclUIEnabled = Boolean(urlState().state.get().params?.aclUI);
const gristDoc = aclUIEnabled ? options.docPageModel?.gristDoc.get() : null;
const accessRules = gristDoc ? AccessRules.create(null, gristDoc) : null;
const accessRulesOpen = observable(false);
// Get the model and assign it to the observable. Report errors to the app.
getModel(options)
.then(model => modelObs.set(model))
.catch(reportError);
modal(ctl => [
// We set the padding to 0 since the body scroll shadows extend to the edge of the modal.
{ style: 'padding: 0;' },
cssModalTitle(
{ style: 'margin: 40px 64px 0 64px;' },
dom.domComputed(accessRulesOpen, rules =>
rules ?
['Access Rules'] :
[
`Invite people to ${renderType(options.resourceType)}`,
(options.resourceType === 'document' ? makeCopyBtn(cssCopyBtn.cls('-header')) : null),
]
),
testId('um-header')
),
cssModalBody(
dom.autoDispose(accessRules),
cssUserManagerBody(
// TODO: Show a loading indicator before the model is loaded.
dom.maybe(modelObs, model => new UserManager(model).buildDom()),
dom.hide(accessRulesOpen),
),
cssUserManagerBody(
accessRules?.buildDom(),
dom.show(accessRulesOpen),
),
),
cssModalButtons(
{ style: 'margin: 32px 64px; display: flex;' },
bigPrimaryButton('Confirm',
dom.boolAttr('disabled', (use) => (
(!use(modelObs) || !use(use(modelObs)!.isAnythingChanged)) &&
(!accessRules || !use(accessRules.isAnythingChanged))
)),
dom.on('click', async () => {
const model = modelObs.get();
if (model) {
// Save changes to the server, reporting any errors to the app.
try {
if (model.isAnythingChanged.get()) {
await model.save(userApi, options.resourceId);
}
await accessRules?.save();
await options.onSave?.();
ctl.close();
} catch (err) {
reportError(err);
}
} else {
ctl.close();
}
}),
testId('um-confirm')
),
bigBasicButton('Cancel',
dom.on('click', () => ctl.close()),
testId('um-cancel')
),
(accessRules ?
bigBasicButton({style: 'margin-left: auto'},
dom.domComputed(accessRulesOpen, rules => rules ?
[cssBigIcon('Expand', cssBigIcon.cls('-reflect')), 'Back to Users'] :
['Access Rules', cssBigIcon('Expand')]
),
dom.on('click', () => accessRulesOpen.set(!accessRulesOpen.get())),
) :
null
),
testId('um-buttons'),
)
]);
}
/**
* See module documentation for overview.
*
* Usage:
* const um = new UserManager(model);
* um.buildDom();
*/
export class UserManager extends Disposable {
constructor(private _model: UserManagerModel) {
super();
}
public buildDom() {
const memberEmail = this.autoDispose(new MemberEmail(tbind(this._model.add, this._model)));
return [
memberEmail.buildDom(),
this._buildOptionsDom(),
shadowScroll(
testId('um-members'),
this._buildPublicAccessMember(),
dom.forEach(this._model.membersEdited, (member) => this._buildMemberDom(member)),
),
];
}
private _buildOptionsDom(): Element {
const publicMember = this._model.publicMember;
return cssOptionRow(
// TODO: Consider adding a tooltip explaining inheritance. A brief text caption may
// be used to fill whitespace in org UserManager.
this._model.isOrg ? null : dom('span', { style: `float: left;` },
dom('span', 'Inherit access: '),
this._inheritRoleSelector()
),
publicMember ? dom('span', { style: `float: right;` },
dom('span', 'Public access: '),
cssOptionBtn(
menu(() => [
menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)),
menuItem(() => publicMember.access.set(null), 'Off',
// Disable null access if anonymous access is inherited.
dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null),
testId(`um-public-option`)
),
// If the 'Off' setting is disabled, show an explanation.
dom.maybe((use) => use(publicMember.inheritedAccess) !== null, () => menuText(
`Public access inherited from ${getResourceParent(this._model.resourceType)}. ` +
`To remove, set 'Inherit access' option to 'None'.`))
]),
dom.text((use) => use(publicMember.effectiveAccess) ? 'On' : 'Off'),
cssCollapseIcon('Collapse'),
testId('um-public-access')
)
) : null
);
}
// Build a single member row.
private _buildMemberDom(member: IEditableMember) {
const disableRemove = Computed.create(null, (use) =>
Boolean(this._model.isActiveUser(member) || use(member.inheritedAccess)));
return dom('div',
dom.autoDispose(disableRemove),
dom.maybe((use) => use(member.effectiveAccess) && use(member.effectiveAccess) !== roles.GUEST, () =>
cssMemberListItem(
cssMemberListItem.cls('-removed', (use) => member.isRemoved),
cssMemberImage(
createUserImage(getFullUser(member), 'large')
),
cssMemberText(
cssMemberPrimary(member.name || dom('span', member.email, testId('um-email'))),
member.name ? cssMemberSecondary(member.email, testId('um-email')) : null
),
member.isRemoved ? null : this._memberRoleSelector(member.effectiveAccess,
member.inheritedAccess, this._model.isActiveUser(member)),
// Only show delete buttons when editing the org users or when a user is being newly
// added to any resource. In workspace/doc UserManager instances we want to see all the
// users in the org, whether or not they have access to the resource of interest. They may
// be denied access via the role dropdown.
// Show the undo icon when an item has been removed but its removal has not been saved to
// the server.
cssMemberBtn(
// Button icon.
member.isRemoved ? cssUndoIcon('Undo', testId('um-member-undo')) :
cssRemoveIcon('Remove', testId('um-member-delete')),
cssMemberBtn.cls('-disabled', disableRemove),
// Click handler.
dom.on('click', () => disableRemove.get() ||
(member.isRemoved ? this._model.add(member.email, member.access.get()) :
this._model.remove(member)))
),
testId('um-member')
)
)
);
}
private _buildPublicAccessMember() {
const publicMember = this._model.publicMember;
if (!publicMember) { return null; }
return dom('div',
dom.maybe((use) => Boolean(use(publicMember.effectiveAccess)), () =>
cssMemberListItem(
cssPublicMemberIcon('PublicFilled'),
cssMemberText(
cssMemberPrimary('Public Access'),
cssMemberSecondary('Anyone with link ', makeCopyBtn()),
),
this._memberRoleSelector(publicMember.effectiveAccess, publicMember.inheritedAccess, false,
// Only show the Editor and Viewer options for the role of the "Public Access" member.
this._model.userSelectOptions.filter(opt => [roles.EDITOR, roles.VIEWER].includes(opt.value!))
),
cssMemberBtn(
cssRemoveIcon('Remove', testId('um-member-delete')),
dom.on('click', () => publicMember.access.set(null)),
),
testId('um-public-member')
)
)
);
}
// Returns a div containing a button that opens a menu to choose between roles.
private _memberRoleSelector(
role: Observable<string|null>,
inherited: Observable<roles.Role|null>,
isActiveUser: boolean,
allRolesOverride?: IOrgMemberSelectOption[],
) {
const allRoles = allRolesOverride ||
(this._model.isOrg ? this._model.orgUserSelectOptions : this._model.userSelectOptions);
return cssRoleBtn(
menu(() => [
dom.forEach(allRoles, _role =>
// The active user should be prevented from changing their own role.
menuItem(() => isActiveUser || role.set(_role.value), _role.label,
// Indicate which option is inherited, if any.
dom.text((use) => use(inherited) && (use(inherited) === _role.value)
&& !isActiveUser ? ' (inherited)' : ''),
// Disable everything providing less access than the inherited access
dom.cls('disabled', (use) =>
roles.getStrongestRole(_role.value, use(inherited)) !== _role.value),
testId(`um-role-option`)
)
),
// If the user's access is inherited, give an explanation on how to change it.
isActiveUser ? menuText(`User may not modify their own access.`) : null,
// If the user's access is inherited, give an explanation on how to change it.
dom.maybe((use) => use(inherited) && !isActiveUser, () => menuText(
`User inherits permissions from ${getResourceParent(this._model.resourceType)}. To remove, ` +
`set 'Inherit access' option to 'None'.`)),
// If the user is a guest, give a description of the guest permission.
dom.maybe((use) => !this._model.isOrg && use(role) === roles.GUEST, () => menuText(
`User has view access to ${this._model.resourceType} resulting from manually-set access ` +
`to resources inside. If removed here, this user will lose access to resources inside.`)),
this._model.isOrg ? menuText(`No default access allows access to be ` +
`granted to individual documents or workspaces, rather than the full team site.`) : null
]),
dom.text((use) => {
// Get the label of the active role. Note that the 'Guest' role is assigned when the role
// is not found because it is not included as a selection.
const activeRole = allRoles.find((_role: IOrgMemberSelectOption) => use(role) === _role.value);
return activeRole ? activeRole.label : "Guest";
}),
cssCollapseIcon('Collapse'),
testId('um-member-role')
);
}
// Builds the max inherited role selection button and menu.
private _inheritRoleSelector() {
const role = this._model.maxInheritedRole;
const allRoles = this._model.inheritSelectOptions;
return cssOptionBtn(
menu(() => [
dom.forEach(allRoles, _role =>
menuItem(() => role.set(_role.value), _role.label,
testId(`um-role-option`)
)
)
]),
dom.text((use) => {
// Get the label of the active role.
const activeRole = allRoles.find((_role: IMemberSelectOption) => use(role) === _role.value);
return activeRole ? activeRole.label : "";
}),
cssCollapseIcon('Collapse'),
testId('um-max-inherited-role')
);
}
}
/**
* Represents the widget that allows typing in an email and adding it.
* The border of the input turns green when the email is considered valid.
*/
export class MemberEmail extends Disposable {
public email = this.autoDispose(observable<string>(""));
public isEmpty = this.autoDispose(computed<boolean>((use) => !use(this.email)));
private _isValid = this.autoDispose(observable<boolean>(false));
private _emailElem: HTMLInputElement;
constructor(
private _onAdd: (email: string, role: roles.NonGuestRole) => void
) {
super();
// Reset custom validity that we sometimes set.
this.email.addListener(() => this._emailElem.setCustomValidity(""));
}
public buildDom(): Element {
const enableAdd: Computed<boolean> = computed((use) => Boolean(use(this.email) && use(this._isValid)));
return cssEmailInputContainer(
dom.autoDispose(enableAdd),
cssMailIcon('Mail'),
this._emailElem = cssEmailInput(this.email, {onInput: true, isValid: this._isValid},
{type: "email", placeholder: "Enter email address"},
dom.onKeyPress({Enter: () => this._commit()}),
inputMenu(() => [
cssInputMenuItem(() => this._commit(),
cssUserImagePlus('+',
cssUserImage.cls('-large'),
cssUserImagePlus.cls('-invalid', (use) => !use(enableAdd))
),
cssMemberText(
cssMemberPrimary('Invite new member'),
cssMemberSecondary(
dom.text((use) => `We'll email an invite to ${use(this.email)}`)
)
),
testId('um-add-email')
)
], {
// NOTE: An offset of -40px is used to center the input button across the
// input container (including an envelope icon) rather than the input inside.
modifiers: {
offset: { enabled: true, offset: -40 }
},
stretchToSelector: `.${cssEmailInputContainer.className}`
})
),
cssEmailInputContainer.cls('-green', enableAdd),
testId('um-member-new')
);
}
// Add the currently entered email if valid, or trigger a validation message if not.
private _commit() {
this._emailElem.setCustomValidity("");
this._isValid.set(this._emailElem.checkValidity());
if (this.email.get() && this._isValid.get()) {
try {
this._onAdd(this.email.get(), roles.VIEWER);
this._reset();
} catch (e) {
this._emailElem.setCustomValidity(e.message);
}
}
this._emailElem.reportValidity();
}
// Reset the widget.
private _reset() {
this.email.set("");
this._emailElem.focus();
}
}
// Returns a new FullUser object from an IEditableMember.
function getFullUser(member: IEditableMember): FullUser {
return {
id: member.id,
name: member.name,
email: member.email,
picture: member.picture
};
}
// Create a "Copy Link" button.
function makeCopyBtn(...domArgs: DomElementArg[]) {
return cssCopyBtn(cssCopyIcon('Copy'), 'Copy Link',
dom.on('click', copyLink),
testId('um-copy-link'),
...domArgs,
);
}
// Copy the current document link to clipboard, and notify the user with a transient popup near
// the given element.
async function copyLink(ev: MouseEvent, elem: HTMLElement) {
const link = getCurrentDocUrl();
await copyToClipboard(link);
setTestState({clipboard: link});
showTransientTooltip(elem, 'Link copied to clipboard', {key: 'copy-doc-link'});
}
const cssUserManagerBody = styled('div', `
display: flex;
flex-direction: column;
width: 600px;
height: 374px;
border-bottom: 1px solid ${colors.darkGrey};
font-size: ${vars.mediumFontSize};
`);
const cssCopyBtn = styled(basicButton, `
border: none;
font-weight: normal;
padding: 0 8px;
&-header {
float: right;
margin-top: 8px;
}
`);
const cssCopyIcon = styled(icon, `
margin-right: 4px;
margin-top: -2px;
`);
const cssOptionRow = styled('div', `
font-size: ${vars.mediumFontSize};
margin: 0 63px 23px 63px;
`);
const cssOptionBtn = styled('span', `
display: inline-flex;
font-size: ${vars.mediumFontSize};
color: ${colors.lightGreen};
cursor: pointer;
`);
export const cssMemberListItem = styled('div', `
display: flex;
width: 460px;
height: 64px;
margin: 0 auto;
padding: 12px 0;
`);
export const cssMemberImage = styled('div', `
width: 40px;
height: 40px;
margin: 0 4px;
border-radius: 20px;
background-color: ${colors.lightGreen};
background-size: cover;
.${cssMemberListItem.className}-removed & {
opacity: 0.4;
}
`);
const cssPublicMemberIcon = styled(icon, `
width: 32px;
height: 32px;
margin: 4px 8px;
--icon-color: ${colors.lightGreen};
`);
export const cssMemberText = styled('div', `
display: flex;
flex-direction: column;
justify-content: center;
margin: 2px 12px;
flex: 1 1 0;
min-width: 0px;
font-size: ${vars.mediumFontSize};
.${cssMemberListItem.className}-removed & {
opacity: 0.4;
}
`);
export const cssMemberPrimary = styled('span', `
font-weight: bold;
color: ${colors.dark};
padding: 2px 0;
.${cssMenuItem.className}-sel & {
color: white;
}
`);
export const cssMemberSecondary = styled('span', `
color: ${colors.slate};
/* the following just undo annoying bootstrap styles that apply to all labels */
margin: 0px;
font-weight: normal;
padding: 2px 0;
white-space: nowrap;
.${cssMenuItem.className}-sel & {
color: white;
}
`);
export const cssEmailInputContainer = styled('div', `
position: relative;
display: flex;
height: 42px;
padding: 0 3px;
margin: 16px 63px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
font-size: ${vars.mediumFontSize};
outline: none;
&-green {
border: 1px solid ${colors.lightGreen};
}
`);
export const cssMailIcon = styled(icon, `
margin: 12px 8px 12px 13px;
background-color: ${colors.slate};
`);
export const cssEmailInput = styled(input, `
flex: 1 1 0;
line-height: 42px;
font-size: ${vars.mediumFontSize};
font-family: ${vars.fontFamily};
outline: none;
border: none;
`);
export const cssMemberBtn = styled('div', `
width: 16px;
height: 16px;
cursor: pointer;
&-disabled {
opacity: 0.3;
cursor: default;
}
`);
export const cssRemoveIcon = styled(icon, `
margin: 12px 0;
`);
const cssUndoIcon = styled(icon, `
margin: 12px 0;
`);
const cssRoleBtn = styled('div', `
display: flex;
justify-content: flex-end;
font-size: ${vars.mediumFontSize};
color: ${colors.lightGreen};
margin: 12px 24px;
cursor: pointer;
&.disabled {
cursor: default;
}
`);
const cssCollapseIcon = styled(icon, `
margin-top: 1px;
background-color: var(--grist-color-light-green);
`);
const cssInputMenuItem = styled(menuItem, `
height: 64px;
padding: 8px 15px;
`);
const cssUserImagePlus = styled(cssUserImage, `
background-color: ${colors.lightGreen};
margin: auto 0;
&-invalid {
background-color: ${colors.mediumGrey};
}
.${cssMenuItem.className}-sel & {
background-color: white;
color: ${colors.lightGreen};
}
`);
const cssBigIcon = styled(icon, `
height: 24px;
width: 24px;
margin: -8px 0 -4px 0;
&-reflect {
transform: scaleX(-1);
}
`);
// Render the name "organization" as "team site" in UI
function renderType(resourceType: ResourceType): string {
return resourceType === 'organization' ? 'team site' : resourceType;
}

View File

@@ -0,0 +1,25 @@
import {allCommands} from 'app/client/components/commands';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {testId} from 'app/client/ui2018/cssVars';
import {menuDivider, menuItemCmd} from 'app/client/ui2018/menus';
import {dom} from 'grainjs';
/**
* Returns a list of menu items for a view section.
*/
export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionRec, isReadonly: boolean) {
return [
dom.maybe((use) => ['detail', 'single'].includes(use(viewSection.parentKey)), () =>
menuItemCmd(allCommands.editLayout, 'Edit Card Layout',
dom.cls('disabled', isReadonly))),
menuItemCmd(allCommands.deleteSection, 'Delete widget',
dom.cls('disabled', viewModel.viewSections().peekLength <= 1 || isReadonly),
testId('section-delete')),
menuDivider(),
menuItemCmd(allCommands.viewTabOpen, 'Widget options', testId('widget-options')),
menuItemCmd(allCommands.sortFilterTabOpen, 'Advanced Sort & Filter'),
menuItemCmd(allCommands.dataSelectionTabOpen, 'Data selection')
];
}

View File

@@ -0,0 +1,268 @@
import {flipColDirection, parseSortColRefs} from 'app/client/lib/sortUtil';
import {ColumnRec, DocModel, ViewFieldRec, ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu, menuDivider} from 'app/client/ui2018/menus';
import {Computed, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
import difference = require('lodash/difference');
import {setPopupToCreateDom} from 'popweasel';
import {makeViewLayoutMenu} from '../ui/ViewLayoutMenu';
import {basicButton, primaryButton} from '../ui2018/buttons';
const testId = makeTestId('test-section-menu-');
type IconSuffix = '' | '-saved' | '-unsaved';
export function viewSectionMenu(docModel: DocModel, viewSection: ViewSectionRec, viewModel: ViewRec,
isReadonly: Observable<boolean>, newUI = true) {
const emptySortFilterObs: Computed<boolean> = Computed.create(null, use => {
return use(viewSection.activeSortSpec).length === 0 && use(viewSection.filteredFields).length === 0;
});
// Using a static subscription to emptySortFilterObs ensures that it's calculated first even if
// it started in the "unsaved" state (in which a dynamic use()-based subscription to
// emptySortFilterObs wouldn't be active, which could result in a wrong order of evaluation).
const iconSuffixObs: Computed<IconSuffix> = Computed.create(null, emptySortFilterObs, (use, empty) => {
if (use(viewSection.filterSpecChanged) || !use(viewSection.activeSortJson.isSaved)) {
return '-unsaved';
} else if (!empty) {
return '-saved';
} else {
return '';
}
});
return cssMenu(
testId('wrapper'),
dom.autoDispose(emptySortFilterObs),
dom.autoDispose(iconSuffixObs),
dom.cls(clsOldUI.className, !newUI),
dom.maybe(iconSuffixObs, () => cssFilterIconWrapper(testId('filter-icon'), cssFilterIcon('Filter'))),
cssMenu.cls(iconSuffixObs),
cssDotsIconWrapper(cssDotsIcon('Dots')),
menu(_ctl => {
return [
dom.domComputed(use => {
use(viewSection.activeSortJson.isSaved); // Rebuild sort panel if sort gets saved. A little hacky.
return makeSortPanel(viewSection, use(viewSection.activeSortSpec),
(row: number) => docModel.columns.getRowModel(row));
}),
dom.domComputed(viewSection.filteredFields, fields =>
makeFilterPanel(viewSection, fields)),
dom.domComputed(iconSuffixObs, iconSuffix => {
const displaySave = iconSuffix === '-unsaved';
return [
displaySave ? cssMenuInfoHeader(
cssSaveButton('Save', testId('btn-save'),
dom.on('click', async () => {
await viewSection.activeSortJson.save(); // Save sort
await viewSection.saveFilters(); // Save filter
}),
dom.boolAttr('disabled', isReadonly),
),
basicButton('Revert', testId('btn-revert'),
dom.on('click', () => {
viewSection.activeSortJson.revert(); // Revert sort
viewSection.revertFilters(); // Revert filter
})
)
) : null,
menuDivider()
];
}),
...makeViewLayoutMenu(viewModel, viewSection, isReadonly.get())
];
})
);
}
function makeSortPanel(section: ViewSectionRec, sortSpec: number[], getColumn: (row: number) => ColumnRec) {
const changedColumns = difference(sortSpec, parseSortColRefs(section.sortColRefs.peek()));
const sortColumns = sortSpec.map(colRef => {
// colRef is a rowId of a column or its negative value (indicating descending order).
const col = getColumn(Math.abs(colRef));
return cssMenuText(
cssMenuIconWrapper(
cssMenuIconWrapper.cls('-changed', changedColumns.includes(colRef)),
cssMenuIconWrapper.cls(colRef < 0 ? '-desc' : '-asc'),
cssIcon('Sort',
dom.style('transform', colRef < 0 ? 'none' : 'scaleY(-1)'),
dom.on('click', () => {
section.activeSortSpec(flipColDirection(sortSpec, colRef));
})
)
),
cssMenuTextLabel(col.colId()),
cssMenuIconWrapper(
cssIcon('Remove', testId('btn-remove-sort'), dom.on('click', () => {
const idx = sortSpec.findIndex(c => c === colRef);
if (idx !== -1) {
sortSpec.splice(idx, 1);
section.activeSortSpec(sortSpec);
}
}))
),
testId('sort-col')
);
});
return [
cssMenuInfoHeader('Sorted by', testId('heading-sorted')),
sortColumns.length > 0 ? sortColumns : cssMenuText('(Default)')
];
}
function makeFilterPanel(section: ViewSectionRec, filteredFields: ViewFieldRec[]) {
const fields = filteredFields.map(field => {
const fieldChanged = Computed.create(null, fromKo(field.activeFilter.isSaved), (_use, isSaved) => !isSaved);
return cssMenuText(
dom.autoDispose(fieldChanged),
cssMenuIconWrapper(
cssMenuIconWrapper.cls('-changed', fieldChanged),
cssIcon('FilterSimple'),
(elem) => {
const instance = section.viewInstance();
if (instance && instance.createFilterMenu) { // Should be set if using BaseView
setPopupToCreateDom(elem, ctl => instance.createFilterMenu(ctl, field), {
placement: 'bottom-end',
boundaries: 'viewport',
trigger: ['click']
});
}
}
),
cssMenuTextLabel(field.label()),
cssMenuIconWrapper(cssIcon('Remove'), dom.on('click', () => field.activeFilter(''))),
testId('filter-col')
);
});
return [
cssMenuInfoHeader('Filtered by', {style: 'margin-top: 4px'}, testId('heading-filtered')),
filteredFields.length > 0 ? fields : cssMenuText('(Not filtered)')
];
}
const clsOldUI = styled('div', ``);
const cssMenu = styled('div', `
margin-top: -3px; /* Section header is 24px, so need to move this up a little bit */
display: inline-flex;
cursor: pointer;
border-radius: 3px;
border: 1px solid transparent;
&.${clsOldUI.className} {
margin-top: 0px;
border-radius: 0px;
}
&:hover, &.weasel-popup-open {
background-color: ${colors.mediumGrey};
}
&-unsaved, &-unsaved.weasel-popup-open {
border: 1px solid ${colors.lightGreen};
background-color: ${colors.lightGreen};
}
&-unsaved:hover {
border: 1px solid ${colors.darkGreen};
background-color: ${colors.darkGreen};
}
&-unsaved.${clsOldUI.className} {
border: 1px solid transparent;
background-color: ${colors.lightGreen};
}
`);
const cssIconWrapper = styled('div', `
padding: 3px;
border-radius: 3px;
cursor: pointer;
user-select: none;
`);
const cssMenuIconWrapper = styled(cssIconWrapper, `
padding: 3px;
margin: -3px 0;
width: 22px;
height: 22px;
&:hover, &.weasel-popup-open {
background-color: ${colors.mediumGrey};
}
&-changed {
background-color: ${colors.lightGreen};
}
&-changed:hover, &-changed:hover.weasel-popup-open {
background-color: ${colors.darkGreen};
}
`);
const cssIcon = styled(icon, `
flex: none;
cursor: pointer;
background-color: ${colors.slate};
.${cssMenuIconWrapper.className}-changed & {
background-color: white;
}
.${clsOldUI.className} & {
background-color: white;
}
`);
const cssDotsIconWrapper = styled(cssIconWrapper, `
border-radius: 0px 2px 2px 0px;
.${clsOldUI.className} & {
border-radius: 0px;
}
.${cssMenu.className}-unsaved & {
background-color: white;
}
`);
const cssDotsIcon = styled(cssIcon, `
.${clsOldUI.className}.${cssMenu.className}-unsaved & {
background-color: ${colors.slate};
}
`);
const cssFilterIconWrapper = styled(cssIconWrapper, `
border-radius: 2px 0px 0px 2px;
`);
const cssFilterIcon = styled(cssIcon, `
.${cssMenu.className}-unsaved & {
background-color: ${colors.light};
}
`);
const cssMenuInfoHeader = styled('div', `
font-weight: ${vars.bigControlTextWeight};
padding: 8px 24px 8px 24px;
cursor: default;
`);
const cssMenuText = styled('div', `
display: flex;
align-items: center;
color: ${colors.slate};
padding: 0px 24px 8px 24px;
cursor: default;
`);
const cssMenuTextLabel = styled('span', `
flex-grow: 1;
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
`);
const cssSaveButton = styled(primaryButton, `
margin-right: 8px;
`);

View File

@@ -0,0 +1,462 @@
import { GristDoc } from "app/client/components/GristDoc";
import { KoArray, syncedKoArray } from "app/client/lib/koArray";
import * as kf from 'app/client/lib/koForm';
import * as tableUtil from 'app/client/lib/tableUtil';
import { ColumnRec, ViewFieldRec, ViewSectionRec } from "app/client/models/DocModel";
import { getFieldType } from "app/client/ui/RightPanel";
import { IWidgetType } from "app/client/ui/widgetTypes";
import { basicButton, cssButton, primaryButton } from 'app/client/ui2018/buttons';
import * as checkbox from "app/client/ui2018/checkbox";
import { colors, vars } from "app/client/ui2018/cssVars";
import { cssDragger } from "app/client/ui2018/draggableList";
import { icon } from "app/client/ui2018/icons";
import * as gutil from 'app/common/gutil';
import { Computed, Disposable, dom, IDomArgs, makeTestId, Observable, styled } from "grainjs";
import difference = require("lodash/difference");
const testId = makeTestId('test-vfc-');
type IField = ViewFieldRec|ColumnRec;
interface DraggableFieldsOption {
// an object holding options for the draggable list, see koForm.js for more detail on the accepted
// options.
draggableOptions: any;
// the itemCreateFunc callback passed to kf.draggableList for the visible fields.
itemCreateFunc(field: IField): Element|undefined;
}
/**
* VisibleFieldsConfig builds dom for the visible/hidden fields configuration component. Usage is:
*
* dom.create(VisibleFieldsConfig, gristDoc, section);
*
* Can also be used to build the two draggable list only:
*
* const config = VisibleFieldsConfig.create(null, gristDoc, section);
* const [visibleFieldsDraggable, hiddenFieldsDraggable] =
* config.buildSectionFieldsConfigHelper({visibleFields: {itemCreateFunc: getLabelFunc},
* hiddenFields: {itemCreateFunc: getLabelFunc}});
*
* The later for is useful to support old ui, refer to function's doc for more detail on the
* available options.
*/
export class VisibleFieldsConfig extends Disposable {
private _hiddenFields: KoArray<ColumnRec> = this.autoDispose(syncedKoArray(this._section.hiddenColumns));
private _fieldLabel = Computed.create(this, (use) => {
const widgetType = use(this._section.parentKey) as IWidgetType;
return getFieldType(widgetType).pluralLabel;
});
private _collapseHiddenFields = Observable.create(this, false);
/**
* Set if and only if the corresponding selection is empty, ie: respectively
* visibleFieldsSelection and hiddenFieldsSelection.
*/
private _showVisibleBatchButtons = Observable.create(this, false);
private _showHiddenBatchButtons = Observable.create(this, false);
private _visibleFieldsSelection = new Set<number>();
private _hiddenFieldsSelection = new Set<number>();
constructor(private _gristDoc: GristDoc,
private _section: ViewSectionRec,
private _useNewUI: boolean = false) {
super();
// Unselects visible fields that are hidden.
this.autoDispose(this._section.viewFields.peek().subscribe((ev) => {
unselectDeletedFields(this._visibleFieldsSelection, ev);
this._showVisibleBatchButtons.set(Boolean(this._visibleFieldsSelection.size));
}, null, 'spliceChange'));
// Unselectes hidden fields that are shown.
this.autoDispose(this._hiddenFields.subscribe((ev) => {
unselectDeletedFields(this._hiddenFieldsSelection, ev);
this._showHiddenBatchButtons.set(Boolean(this._hiddenFieldsSelection.size));
}, null, 'spliceChange'));
}
/**
* Build the two draggable list components to show both the visible and the hidden fields of a
* section. Each draggable list can be parametrized using both `options.visibleFields` and
* `options.hiddenFields` options.
*
* @param {DraggableFieldOption} options.hiddenFields options for the list of hidden fields.
* @param {DraggableFieldOption} options.visibleFields options for the list of visible fields.
* @return {[Element, Element]} the two draggable elements (ie: koForm.draggableList) showing
* respectivelly the list of visible fields and the list of hidden
* fields of section.
*/
public buildSectionFieldsConfigHelper(
options: {
visibleFields: DraggableFieldsOption,
hiddenFields: DraggableFieldsOption,
}): [HTMLElement, HTMLElement] {
const itemClass = this._useNewUI ? cssDragRow.className : 'view_config_draggable_field';
const fieldsDraggable = dom.update(
kf.draggableList(
this._section.viewFields.peek(),
options.visibleFields.itemCreateFunc,
{
itemClass,
reorder: this._changeFieldPosition.bind(this),
remove: this._removeField.bind(this),
receive: this._addField.bind(this),
...options.visibleFields.draggableOptions,
}
),
);
const hiddenFieldsDraggable = kf.draggableList(
this._hiddenFields,
options.hiddenFields.itemCreateFunc,
{
itemClass,
reorder() { throw new Error('Hidden Fields cannot be reordered'); },
receive() { throw new Error('Cannot drop items into Hidden Fields'); },
remove(item: ColumnRec) {
// Return the column object. This value is passed to the viewFields
// receive function as its respective item parameter
return item;
},
removeButton: false,
...options.hiddenFields.draggableOptions,
}
);
kf.connectDraggableOneWay(hiddenFieldsDraggable, fieldsDraggable);
return [fieldsDraggable, hiddenFieldsDraggable];
}
public buildDom() {
const [fieldsDraggable, hiddenFieldsDraggable] = this.buildSectionFieldsConfigHelper({
visibleFields: {
itemCreateFunc: (field) => this._buildVisibleFieldItem(field as ViewFieldRec),
draggableOptions: {
removeButton: false,
drag_indicator: cssDragger,
}
},
hiddenFields: {
itemCreateFunc: (field) => this._buildHiddenFieldItem(field as ColumnRec),
draggableOptions: {
removeButton: false,
drag_indicator: cssDragger,
},
},
});
return [
cssHeader(
cssFieldListHeader(dom.text((use) => `Visible ${use(this._fieldLabel)}`)),
dom.maybe(
(use) => Boolean(use(use(this._section.viewFields).getObservable()).length),
() => (
cssGreenLabel(
icon('Tick'),
'Select All',
dom.on('click', () => this._setVisibleCheckboxes(fieldsDraggable, true)),
testId('visible-fields-select-all'),
)
)
),
),
dom.update(fieldsDraggable, testId('visible-fields')),
dom.maybe(this._showVisibleBatchButtons, () =>
cssRow(
primaryButton(
dom.text((use) => `Hide ${use(this._fieldLabel)}`),
dom.on('click', () => this._removeSelectedFields()),
),
basicButton(
'Clear',
dom.on('click', () => this._setVisibleCheckboxes(fieldsDraggable, false)),
),
testId('visible-batch-buttons')
),
),
cssHeader(
cssHeaderIcon(
'Dropdown',
dom.style('transform', (use) => use(this._collapseHiddenFields) ? 'rotate(-90deg)' : ''),
dom.style('cursor', 'pointer'),
dom.on('click', () => this._collapseHiddenFields.set(!this._collapseHiddenFields.get())),
testId('collapse-hidden'),
),
// TODO: show `hidden column` only when some fields are hidden
cssFieldListHeader(dom.text((use) => `Hidden ${use(this._fieldLabel)}`)),
dom.maybe(
(use) => Boolean(use(this._hiddenFields.getObservable()).length && !use(this._collapseHiddenFields)),
() => (
cssGreenLabel(
icon('Tick'),
'Select All',
dom.on('click', () => this._setHiddenCheckboxes(hiddenFieldsDraggable, true)),
testId('hidden-fields-select-all'),
)
)
),
),
dom(
'div',
dom.hide(this._collapseHiddenFields),
dom.update(
hiddenFieldsDraggable,
testId('hidden-fields'),
),
dom.maybe(this._showHiddenBatchButtons, () =>
cssRow(
primaryButton(
dom.text((use) => `Show ${use(this._fieldLabel)}`),
dom.on('click', () => this._addSelectedFields()),
),
basicButton(
'Clear',
dom.on('click', () => this._setHiddenCheckboxes(hiddenFieldsDraggable, false)),
),
testId('hidden-batch-buttons')
)
),
),
];
}
// Set all checkboxes for the visible fields.
private _setVisibleCheckboxes(visibleFieldsDraggable: Element, checked: boolean) {
this._setCheckboxesHelper(
visibleFieldsDraggable,
this._section.viewFields.peek().peek(),
this._visibleFieldsSelection,
checked
);
this._showVisibleBatchButtons.set(checked);
}
// Set all checkboxes for the hidden fields.
private _setHiddenCheckboxes(hiddenFieldsDraggable: Element, checked: boolean) {
this._setCheckboxesHelper(
hiddenFieldsDraggable,
this._hiddenFields.peek(),
this._hiddenFieldsSelection,
checked
);
this._showHiddenBatchButtons.set(checked);
}
// A helper to set all checkboxes. Takes care of setting all checkboxes in the dom and updating
// the selection.
private _setCheckboxesHelper(draggable: Element, fields: IField[], selection: Set<number>,
checked: boolean) {
findCheckboxes(draggable).forEach((el) => el.checked = checked);
selection.clear();
if (checked) {
// add all ids to the selection
fields.forEach((field) => selection.add(field.id.peek()));
}
}
private _buildHiddenFieldItem(column: IField) {
const id = column.id.peek();
const selection = this._hiddenFieldsSelection;
return cssFieldEntry(
cssFieldLabel(dom.text(column.label)),
cssHideIcon('EyeShow',
dom.on('click', () => this._addField(column)),
testId('hide')
),
buildCheckbox(
dom.prop('checked', selection.has(id)),
dom.on('change', (ev, el) => {
el.checked ? selection.add(id) : selection.delete(id);
this._showHiddenBatchButtons.set(Boolean(selection.size));
})
)
);
}
private _buildVisibleFieldItem(field: IField) {
const id = field.id.peek();
const selection = this._visibleFieldsSelection;
return cssFieldEntry(
cssFieldLabel(dom.text(field.label)),
// TODO: we need a "cross-out eye" icon here.
cssHideIcon('EyeHide',
dom.on('click', () => this._removeField(field)),
testId('hide')
),
buildCheckbox(
dom.prop('checked', selection.has(id)),
dom.on('change', (ev, el) => {
el.checked ? selection.add(id) : selection.delete(id);
this._showVisibleBatchButtons.set(Boolean(selection.size));
})
)
);
}
private _changeFieldPosition(field: ViewFieldRec, nextField: ViewFieldRec|null) {
const parentPos = getFieldNewPosition(this._section.viewFields.peek(), field, nextField);
const vsfAction = ['UpdateRecord', field.id.peek(), {parentPos} ];
return this._gristDoc.docModel.viewFields.sendTableAction(vsfAction);
}
private async _removeField(field: IField) {
const id = field.id.peek();
const action = ['RemoveRecord', id];
await this._gristDoc.docModel.viewFields.sendTableAction(action);
}
private async _removeSelectedFields() {
const toRemove = Array.from(this._visibleFieldsSelection).sort(gutil.nativeCompare);
const action = ['BulkRemoveRecord', toRemove];
await this._gristDoc.docModel.viewFields.sendTableAction(action);
}
private async _addField(column: IField, nextField: ViewFieldRec|null = null) {
const parentPos = getFieldNewPosition(this._section.viewFields.peek(), column, nextField);
const colInfo = {
parentId: this._section.id.peek(),
colRef: column.id.peek(),
parentPos,
};
const action = ['AddRecord', null, colInfo];
await this._gristDoc.docModel.viewFields.sendTableAction(action);
}
private async _addSelectedFields() {
const toAdd = Array.from(this._hiddenFieldsSelection);
const rowIds = gutil.arrayRepeat(toAdd.length, null);
const colInfo = {
parentId: gutil.arrayRepeat(toAdd.length, this._section.id.peek()),
colRef: toAdd,
};
const action = ['BulkAddRecord', rowIds, colInfo];
await this._gristDoc.docModel.viewFields.sendTableAction(action);
}
}
function getFieldNewPosition(fields: KoArray<ViewFieldRec>, item: IField,
nextField: ViewFieldRec|null): number {
const index = getItemIndex(fields, nextField);
return tableUtil.fieldInsertPositions(fields, index, 1)[0];
}
function getItemIndex<T>(collection: KoArray<ViewFieldRec>, item: ViewFieldRec|null): number {
if (item !== null) {
return collection.peek().indexOf(item);
}
return collection.peek().length;
}
function buildCheckbox(...args: IDomArgs<HTMLInputElement>) {
return checkbox.cssLabel(
{style: 'flex-shrink: 0;'},
checkbox.cssCheckboxSquare(
{type: 'checkbox'},
...args
)
);
}
// helper to find checkboxes withing a draggable list. This assumes that checkboxes are the only
// <input> element in draggableElement.
function findCheckboxes(draggableElement: Element): NodeListOf<HTMLInputElement> {
return draggableElement.querySelectorAll<HTMLInputElement>('input');
}
// Removes from selection the ids of the fields that appear as deleted in the splice event. Note
// that it can happen that a field appears as deleted and yet belongs to the new array (as a result
// of an `assign` call for instance). In which case the field is to be considered as not deleted.
function unselectDeletedFields(selection: Set<number>, event: {deleted: IField[], array: IField[]}) {
// go though the difference between deleted fields and the new array.
const removed: IField[] = difference(event.deleted, event.array);
for (const field of removed) {
selection.delete(field.id.peek());
}
}
const cssDragRow = styled('div', `
display: flex !important;
align-items: center;
margin: 0 16px 0px 0px;
& > .kf_draggable_content {
margin: 2px 0;
flex: 1 1 0px;
min-width: 0px;
}
`);
export const cssFieldEntry = styled('div', `
display: flex;
background-color: ${colors.mediumGrey};
width: 100%;
border-radius: 2px;
margin: 0 8px 0 0;
padding: 4px 8px;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
--icon-color: ${colors.slate};
`);
const cssHideIcon = styled(icon, `
display: none;
cursor: pointer;
flex: none;
margin-right: 8px;
.kf_draggable:hover & {
display: block;
}
`);
export const cssFieldLabel = styled('span', `
flex: 1 1 auto;
text-overflow: ellipsis;
overflow: hidden;
`);
const cssFieldListHeader = styled('span', `
flex: 1 1 0px;
font-size: ${vars.xsmallFontSize};
text-transform: uppercase;
`);
const cssRow = styled('div', `
display: flex;
margin: 16px;
overflow: hidden;
--icon-color: ${colors.slate};
& > .${cssButton.className} {
margin-right: 8px;
}
`);
const cssGreenLabel = styled('div', `
--icon-color: ${colors.lightGreen};
color: ${colors.lightGreen};
cursor: pointer;
`);
const cssHeader = styled(cssRow, `
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
`);
const cssHeaderIcon = styled(icon, `
flex: none;
margin-right: 4px;
`);

View File

@@ -0,0 +1,206 @@
import { Computed, Disposable, dom, domComputed, DomContents, input, MultiHolder, Observable, styled } from "grainjs";
import { AppModel, reportError } from "app/client/models/AppModel";
import { urlState } from "app/client/models/gristUrlState";
import { AccountWidget } from "app/client/ui/AccountWidget";
import { appHeader } from 'app/client/ui/AppHeader';
import * as BillingPageCss from "app/client/ui/BillingPageCss";
import { pagePanels } from "app/client/ui/PagePanels";
import { bigPrimaryButton, bigPrimaryButtonLink, cssButton } from "app/client/ui2018/buttons";
import { colors, testId, vars } from "app/client/ui2018/cssVars";
import { getOrgName, Organization } from "app/common/UserAPI";
export class WelcomePage extends Disposable {
private _currentUserName = this._appModel.currentUser && this._appModel.currentUser.name || '';
private _orgs: Organization[];
private _orgsLoaded = Observable.create(this, false);
constructor(private _appModel: AppModel) {
super();
}
public buildDom() {
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen: Observable.create(this, false),
hideOpener: true,
header: appHeader('', this._appModel.topAppModel.productFlavor),
content: null,
},
headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)],
contentMain: this.buildPageContent()
});
}
public buildPageContent(): Element {
return cssScrollContainer(cssContainer(
cssTitle('Welcome to Grist'),
testId('welcome-page'),
domComputed(urlState().state, (state) => (
state.welcome === 'user' ? dom.create(this._buildNameForm.bind(this)) :
state.welcome === 'teams' ? dom.create(this._buildOrgPicker.bind(this)) :
null
)),
));
}
private _buildNameForm(owner: MultiHolder) {
let inputEl: HTMLInputElement;
let form: HTMLFormElement;
const value = Observable.create(owner, checkName(this._currentUserName) ? this._currentUserName : '');
const isNameValid = Computed.create(owner, value, (use, val) => checkName(val));
// delayed focus
setTimeout(() => inputEl.focus(), 10);
return form = dom(
'form',
{ method: "post" },
cssLabel('Your full name, as you\'d like it displayed to your collaborators.'),
inputEl = cssInput(
value, { onInput: true, },
{ name: "username" },
dom.onKeyDown({Enter: () => isNameValid.get() && form.submit()}),
),
dom.maybe((use) => use(value) && !use(isNameValid), buildNameWarningsDom),
cssButtonGroup(
bigPrimaryButton(
'Continue',
dom.boolAttr('disabled', (use) => Boolean(use(value) && !use(isNameValid))),
testId('continue-button')
),
)
);
}
private async _fetchOrgs() {
this._orgs = await this._appModel.api.getOrgs(true);
this._orgsLoaded.set(true);
}
private _buildOrgPicker(): DomContents {
this._fetchOrgs().catch(reportError);
return dom.maybe(this._orgsLoaded, () => {
let orgs = this._orgs;
if (orgs && orgs.length > 1) {
// Let's make sure that the first org is not the personal org.
if (orgs[0].owner) {
orgs = [...orgs.slice(1), orgs[0]];
}
return [
cssParagraph(
"You've been added to a team. ",
"Go to the team site, or to your personal site."
),
cssParagraph(
"You can always switch sites using the account menu in the top-right corner."
),
orgs.map((org, i) => (
cssOrgButton(
getOrgName(org),
urlState().setLinkUrl({org: org.domain || undefined}),
testId('org'),
i ? cssButton.cls('-primary', false) : null
)
)),
];
}
});
}
}
const cssScrollContainer = styled('div', `
display: flex;
overflow-y: auto;
flex-direction: column;
`);
const cssContainer = styled('div', `
width: 450px;
align-self: center;
margin: 60px;
display: flex;
flex-direction: column;
&:after {
content: "";
height: 8px;
}
`);
const cssFlexSpace = styled('div', `
flex: 1 1 0px;
`);
const cssTitle = styled('div', `
height: 32px;
line-height: 32px;
margin: 0 0 28px 0;
color: ${colors.dark};
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
`);
const textStyle = `
font-weight: normal;
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
`;
const cssLabel = styled('label', textStyle);
const cssParagraph = styled('p', textStyle);
const cssButtonGroup = styled('div', `
margin-top: 24px;
display: flex;
`);
const cssWarning = styled('div', `
color: red;
`);
const cssInput = styled(input, BillingPageCss.inputStyle);
const cssOrgButton = styled(bigPrimaryButtonLink, `
margin: 0 0 8px;
width: 200px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
&:first-of-type {
margin-top: 16px;
}
`);
/**
* We allow alphanumeric characters and certain common whitelisted characters (except at the start),
* plus everything non-ASCII (for non-English alphabets, which we want to allow but it's hard to be
* more precise about what exactly to allow).
*/
// eslint-disable-next-line no-control-regex
const VALID_NAME_REGEXP = /^(\w|[^\u0000-\u007F])(\w|[- ./'"()]|[^\u0000-\u007F])*$/;
/**
* Test name against various rules to check if it is a valid username. Returned obj has `.valid` set
* to true if all passes, otherwise it has the `.flag` set the the first failing test.
*/
export function checkName(name: string): boolean {
return VALID_NAME_REGEXP.test(name);
}
/**
* Builds dom to show marning messages to the user.
*/
export function buildNameWarningsDom() {
return cssWarning(
"Names only allow letters, numbers and certain special characters",
testId('username-warning'),
);
}

95
app/client/ui/buttons.ts Normal file
View File

@@ -0,0 +1,95 @@
import {styled} from 'grainjs';
/**
* Plain clickable button, light grey with dark text, slightly raised.
*/
export const button1 = styled('button', `
background: linear-gradient(to bottom, #fafafa 0%,#eaeaea 100%);
border-radius: 0.5em;
border: 1px solid #c9c9c9;
box-shadow: 0px 2px 2px -2px rgba(0,0,0,0.2);
color: #444;
font-weight: 500;
overflow: hidden;
padding: 0.4em 1em;
margin: 0.5em;
&:disabled {
color: #A0A0A0;
}
&:active:not(:disabled) {
background: linear-gradient(to bottom, #eaeaea 0%, #fafafa 100%);
box-shadow: inset 0px 0px 2px 0px rgba(0,0,0,0.2), 0px 0px 2px 1px #0078ff;
}
`);
/**
* Similar to button1 but smaller to match other grist buttons.
*/
export const button1Small = styled(button1, `
height: 2.5rem;
line-height: 1.1rem;
font-size: 1rem;
font-weight: bold;
color: #444;
border-radius: 4px;
box-shadow: none;
outline: none;
&:active:not(:disabled) {
box-shadow: none;
}
`);
const buttonBrightStyle = `
&:not(:disabled) {
background: linear-gradient(to bottom, #ffb646 0%,#f68400 100%);
border-color: #f68400;
color: #ffffff;
text-shadow: 1px 1px 0px rgb(0,0,0,0.2);
}
&:active:not(:disabled) {
background: linear-gradient(to bottom, #f68400 0%,#ffb646 100%);
}
`;
/**
* Just like button1 but orange with white text.
*/
export const button1Bright = styled(button1, buttonBrightStyle);
/**
* Just like button1Small but orange with white text.
*/
export const button1SmallBright = styled(button1Small, buttonBrightStyle);
/**
* A button that looks like a flat circle with a unicode symbol inside, e.g.
* "\u2713" for checkmark, or "\u00D7" for an "x".
*
* Modifier class circleSymbolButton.cls("-light") makes it look disabled.
* Modifier class circleSymbolButton.cls("-green") makes it green.
*/
export const circleSymbolButton = styled('button', `
border: none;
padding: 0px;
width: 1em;
height: 1em;
margin: 0.3em;
background: grey;
color: #fff;
border-radius: 1em;
font-family: sans-serif;
font-weight: bold;
text-align: center;
font-size: 1.2em;
line-height: 0em;
text-decoration: none;
cursor:pointer;
&-light {
background-color: lightgrey;
}
&-green {
background-color: #00c209;
}
`);

160
app/client/ui/errorPages.ts Normal file
View File

@@ -0,0 +1,160 @@
import {AppModel} from 'app/client/models/AppModel';
import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState';
import {appHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {bigBasicButtonLink, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {GristLoadConfig} from 'app/common/gristUrls';
import {dom, DomElementArg, makeTestId, observable, styled} from 'grainjs';
const testId = makeTestId('test-');
export function createErrPage(appModel: AppModel) {
const gristConfig: GristLoadConfig = (window as any).gristConfig || {};
const message = gristConfig.errMessage;
return gristConfig.errPage === 'signed-out' ? createSignedOutPage(appModel) :
gristConfig.errPage === 'verified' ? createVerifiedPage(appModel) :
gristConfig.errPage === 'not-found' ? createNotFoundPage(appModel, message) :
gristConfig.errPage === 'access-denied' ? createForbiddenPage(appModel, message) :
createOtherErrorPage(appModel, message);
}
/**
* Creates a page to show that the user has no access to this org.
*/
export function createForbiddenPage(appModel: AppModel, message?: string) {
return pagePanelsError(appModel, 'Access denied', [
dom.domComputed(appModel.currentValidUser, user => user ? [
cssErrorText(message || "You do not have access to this organization's documents."),
cssErrorText("You are signed in as ", dom('b', user.email),
". You can sign in with a different account, or ask an administrator for access."),
] : [
// This page is not normally shown because a logged out user with no access will get
// redirected to log in. But it may be seen if a user logs out and returns to a cached
// version of this page.
cssErrorText("Sign in to access this organization's documents."),
]),
cssButtonWrap(bigPrimaryButtonLink(
appModel.currentValidUser ? 'Add account' : 'Sign in',
{href: getLoginUrl()},
testId('error-signin'),
))
]);
}
/**
* Creates a page that shows the user is logged out.
*/
export function createSignedOutPage(appModel: AppModel) {
return pagePanelsError(appModel, 'Signed out', [
cssErrorText("You are now signed out."),
cssButtonWrap(bigPrimaryButtonLink(
'Sign in again', {href: getLoginUrl()}, testId('error-signin')
))
]);
}
/**
* Creates a page that shows the user is verified.
*/
export function createVerifiedPage(appModel: AppModel) {
return pagePanelsError(appModel, 'Verified', [
cssErrorText("Your email is now verified."),
cssButtonWrap(bigPrimaryButtonLink(
'Sign in', {href: getLoginUrl(getMainOrgUrl())}, testId('error-signin')
))
]);
}
/**
* Creates a "Page not found" page.
*/
export function createNotFoundPage(appModel: AppModel, message?: string) {
return pagePanelsError(appModel, 'Page not found', [
cssErrorText(message || "The requested page could not be found.", dom('br'),
"Please check the URL and try again."),
cssButtonWrap(bigPrimaryButtonLink('Go to main page', testId('error-primary-btn'),
urlState().setLinkUrl({}))),
cssButtonWrap(bigBasicButtonLink('Contact support', {href: 'https://getgrist.com/contact'})),
]);
}
/**
* Creates a generic error page with the given message.
*/
export function createOtherErrorPage(appModel: AppModel, message?: string) {
return pagePanelsError(appModel, 'Something went wrong', [
cssErrorText(message ? `There was an error: ${addPeriod(message)}` :
"There was an unknown error."),
cssButtonWrap(bigPrimaryButtonLink('Go to main page', testId('error-primary-btn'),
urlState().setLinkUrl({}))),
cssButtonWrap(bigBasicButtonLink('Contact support', {href: 'https://getgrist.com/contact'})),
]);
}
function addPeriod(msg: string): string {
return msg.endsWith('.') ? msg : msg + '.';
}
function pagePanelsError(appModel: AppModel, header: string, content: DomElementArg) {
const panelOpen = observable(false);
return pagePanels({
leftPanel: {
panelWidth: observable(240),
panelOpen,
hideOpener: true,
header: appHeader(appModel.currentOrgName, appModel.topAppModel.productFlavor),
content: leftPanelBasic(appModel, panelOpen),
},
headerMain: createTopBarHome(appModel),
contentMain: cssCenteredContent(cssErrorContent(
cssBigIcon(),
cssErrorHeader(header, testId('error-header')),
content,
testId('error-content'),
)),
});
}
const cssCenteredContent = styled('div', `
width: 100%;
height: 100%;
overflow-y: auto;
`);
const cssErrorContent = styled('div', `
text-align: center;
margin: 64px 0 64px;
`);
const cssBigIcon = styled('div', `
display: inline-block;
width: 100%;
height: 64px;
background-image: var(--icon-GristLogo);
background-size: contain;
background-repeat: no-repeat;
background-position: center;
`);
const cssErrorHeader = styled('div', `
font-weight: ${vars.headerControlTextWeight};
font-size: ${vars.xxxlargeFontSize};
margin: 24px;
text-align: center;
color: ${colors.dark};
`);
const cssErrorText = styled('div', `
font-size: ${vars.mediumFontSize};
color: ${colors.dark};
margin: 0 auto 24px auto;
max-width: 400px;
text-align: center;
`);
const cssButtonWrap = styled('div', `
margin-bottom: 8px;
`);

71
app/client/ui/modals.ts Normal file
View File

@@ -0,0 +1,71 @@
import {Disposable} from 'app/client/lib/dispose';
import {dom, styled} from 'grainjs';
const modalBacker = styled('div', `
position: fixed;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 100;
background-color: rgba(0, 0, 0, 0.5);
`);
const modal = styled('div', `
background-color: white;
color: black;
margin: 0 auto;
border-radius: 4px;
box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.2);
border: 1px solid #aaa;
padding: 10px;
`);
export const modalHeader = styled('div', `
font-size: 12pt;
color: #859394;
padding: 5px;
`);
export const modalButtonRow = styled('div', `
width: 70%;
margin: 0 auto;
text-align: center;
& > button {
width: 80px;
}
`);
/**
* A simple modal. Shows up in the middle of the screen with a tinted backdrop.
* Created with the given body content and width.
*
* Closed and disposed via clicking anywhere outside the modal. May also be closed by
* calling the `dispose()` function.
*/
export class Modal1 extends Disposable {
private _dom: Element;
public create(
body: Element,
width: number = 300
) {
this._dom = modalBacker(
modal({style: `width: ${width}px;`, tabindex: "-1"},
dom.cls('clipboard_focus'),
body,
dom.on('click', (e) => e.stopPropagation())
),
dom.on('click', () => this.dispose())
);
document.body.appendChild(this._dom);
this.autoDisposeCallback(() => {
document.body.removeChild(this._dom);
});
}
}

224
app/client/ui/selectBy.ts Normal file
View File

@@ -0,0 +1,224 @@
import { ColumnRec, DocModel, ViewSectionRec } from 'app/client/models/DocModel';
import { IPageWidget } from 'app/client/ui/PageWidgetPicker';
import { removePrefix } from 'app/common/gutil';
import { IOptionFull } from 'grainjs';
// some unicode characters
const BLACK_CIRCLE = '\u2022';
const RIGHT_ARROW = '\u2192';
// Describes a link
export interface IPageWidgetLink {
// The source section id
srcSectionRef: number;
// The source column id
srcColRef: number;
// The target col id
targetColRef: number;
}
export const NoLink = linkId({
srcSectionRef: 0,
srcColRef: 0,
targetColRef: 0
});
const NoLinkOption: IOptionFull<string> = {
label: "Select Widget",
value: NoLink
};
interface LinkNode {
// the tableId
tableId: string;
// is the table a summary table
isSummary: boolean;
// list of ids of the sections that are ancestors to this section according to the linked section
// relationship
ancestors: Set<number>;
// the section record. Must be the empty record sections that are to be created.
section: ViewSectionRec;
// the column record or undefined for the main section node (ie: the node that does not connect to
// any particular column)
column?: ColumnRec;
// the widget type
widgetType: string;
}
// Returns true is the link from `source` to `target` is valid, false otherwise.
function isValidLink(source: LinkNode, target: LinkNode) {
// section must not be the same
if (source.section.getRowId() === target.section.getRowId()) {
return false;
}
// table must match
if (source.tableId !== target.tableId) {
return false;
}
// summary table can only link to and from the main node (node with no column)
if ((source.isSummary || target.isSummary) && (source.column || target.column)) {
return false;
}
// cannot select from chart or custom
if (['chart', 'custom'].includes(source.widgetType)) {
return false;
}
// The link must not create a cycle
if (source.ancestors.has(target.section.getRowId())) {
return false;
}
return true;
}
// Represents the differents way to reference to a section for linking
type MaybeSection = ViewSectionRec|IPageWidget;
// Returns a list of options with all links that link one of the `source` section to the `target`
// section. Each `opt.value` is a unique identifier (see: linkId() and linkFromId() for more
// detail), and `opt.label` is a human readable representation of the form
// `<section_name>[.<source-col-name>][ -> <target-col-name>]` where the <source-col-name> appears
// only when linking from a reference column, as opposed to linking from the table directly. And the
// <target-col-name> shows only when both <section_name>[.<source-col-name>] is ambiguous.
export function selectBy(docModel: DocModel, sources: ViewSectionRec[],
target: MaybeSection): Array<IOptionFull<string>> {
const sourceNodes = createNodes(docModel, sources);
const targetNodes = createNodes(docModel, [target]);
const options = [NoLinkOption];
for (const srcNode of sourceNodes) {
const validTargets = targetNodes.filter((tgt) => isValidLink(srcNode, tgt));
const hasMany = validTargets.length > 1;
for (const tgtNode of validTargets) {
// a unique identifier for this link
const value = linkId({
srcSectionRef: srcNode.section.getRowId(),
srcColRef: srcNode.column ? srcNode.column.getRowId() : 0,
targetColRef: tgtNode.column ? tgtNode.column.getRowId() : 0,
});
// a human readable description
let label = srcNode.section.titleDef();
// add the source node col name or nothing for table node
label += srcNode.column ? ` ${BLACK_CIRCLE} ${srcNode.column.label.peek()}` : '';
// add the target column name only if target has multiple valid nodes
label += hasMany && tgtNode.column ? ` ${RIGHT_ARROW} ${tgtNode.column.label.peek()}` : '';
// add the new option
options.push({ label, value });
}
}
return options;
}
function isViewSectionRec(section: MaybeSection): section is ViewSectionRec {
return Boolean((section as ViewSectionRec).getRowId);
}
// Create all nodes for sections.
function createNodes(docModel: DocModel, sections: MaybeSection[]) {
const nodes = [];
for (const section of sections) {
if (isViewSectionRec(section)) {
nodes.push(...fromViewSectionRec(section));
} else {
nodes.push(...fromPageWidget(docModel, section));
}
}
return nodes;
}
// Creates an array of LinkNode from a view section record.
function fromViewSectionRec(section: ViewSectionRec): LinkNode[] {
const table = section.table.peek();
const columns = table.columns.peek().peek();
const ancestors = new Set<number>();
for (let sec = section; sec.getRowId(); sec = sec.linkSrcSection.peek()) {
if (ancestors.has(sec.getRowId())) {
// tslint:disable-next-line:no-console
console.warn(`Links should not create a cycle - section ids: ${Array.from(ancestors)}`);
break;
}
ancestors.add(sec.getRowId());
}
const mainNode = {
tableId: table.primaryTableId.peek(),
isSummary: table.primaryTableId.peek() !== table.tableId.peek(),
widgetType: section.parentKey.peek(),
ancestors,
section,
};
const nodes: LinkNode[] = [mainNode];
// add the column nodes
for (const column of columns) {
const tableId = removePrefix(column.type.peek(), 'Ref:');
if (tableId) {
nodes.push({...mainNode, tableId, column});
}
}
return nodes;
}
// Creates an array of LinkNode from a page widget.
function fromPageWidget(docModel: DocModel, pageWidget: IPageWidget): LinkNode[] {
if (typeof pageWidget.table !== 'number') { return []; }
const table = docModel.tables.getRowModel(pageWidget.table);
const columns = table.columns.peek().peek();
const mainNode: LinkNode = {
tableId: table.primaryTableId.peek(),
isSummary: pageWidget.summarize,
widgetType: pageWidget.type,
ancestors: new Set(),
section: docModel.viewSections.getRowModel(pageWidget.section),
};
const nodes: LinkNode[] = [mainNode];
// adds the column nodes
for (const column of columns) {
const tableId = removePrefix(column.type.peek(), 'Ref:');
if (tableId) {
nodes.push({...mainNode, tableId, column});
}
}
return nodes;
}
// Returns an identifier to uniquely identify a link. Here we adopt a simple approach where
// {srcSectionRef: 2, srcColRef: 3, targetColRef: 3} is turned into "[2, 3, 3]".
export function linkId(link: IPageWidgetLink) {
return JSON.stringify([link.srcSectionRef, link.srcColRef, link.targetColRef]);
}
// Returns link's properties from its identifier.
export function linkFromId(linkid: string) {
const [srcSectionRef, srcColRef, targetColRef] = JSON.parse(linkid);
return {srcSectionRef, srcColRef, targetColRef};
}

View File

@@ -0,0 +1,48 @@
import {dom, IDomArgs, Observable, styled} from 'grainjs';
// Shadow css settings for member scroll top and bottom.
const SHADOW_TOP = 'inset 0 4px 6px 0 rgba(217,217,217,0.4)';
const SHADOW_BTM = 'inset 0 -4px 6px 0 rgba(217,217,217,0.4)';
/**
* Creates a scroll div used in the UserManager and moveDoc menus to display
* shadows at the top and bottom of a list of scrollable items.
*/
export function shadowScroll(...args: IDomArgs<HTMLDivElement>) {
// Observables to indicate the scroll position.
const scrollTop = Observable.create(null, true);
const scrollBtm = Observable.create(null, true);
return cssScrollMenu(
dom.autoDispose(scrollTop),
dom.autoDispose(scrollBtm),
// Update scroll positions on init and on scroll.
(elem) => { setTimeout(() => scrollBtm.set(isAtScrollBtm(elem)), 0); },
dom.on('scroll', (_, elem) => {
scrollTop.set(isAtScrollTop(elem));
scrollBtm.set(isAtScrollBtm(elem));
}),
// Add shadows on the top/bottom if the list is scrolled away from either.
dom.style('box-shadow', (use) => {
const shadows = [use(scrollTop) ? null : SHADOW_TOP, use(scrollBtm) ? null : SHADOW_BTM];
return shadows.filter(css => css).join(', ');
}),
...args
);
}
// Indicates that an element is currently scrolled such that the top of the element is visible.
function isAtScrollTop(elem: Element): boolean {
return elem.scrollTop === 0;
}
// Indicates that an element is currently scrolled such that the bottom of the element is visible.
// It is expected that the elem arg has the offsetHeight property set.
function isAtScrollBtm(elem: HTMLElement): boolean {
return elem.scrollTop >= (elem.scrollHeight - elem.offsetHeight);
}
const cssScrollMenu = styled('div', `
flex: 1 1 0;
width: 100%;
overflow-y: auto;
`);

80
app/client/ui/tooltips.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* This module implements tooltips of two kinds:
* - to be shown on hover, similar to the native "title" attribute (popweasel meant to provide
* that, but its machinery isn't really needed). TODO these aren't yet implemented.
* - to be shown briefly, as a transient notification next to some action element.
*/
import {prepareForTransition} from 'app/client/ui/transitions';
import {testId} from 'app/client/ui2018/cssVars';
import {dom, styled} from 'grainjs';
import Popper from 'popper.js';
interface ITipOptions {
// Where to place the tooltip relative to the reference element. Defaults to 'top'.
// See https://popper.js.org/docs/v1/#popperplacements--codeenumcode
placement?: Popper.Placement;
// When to remove the transient tooltip. Defaults to 2000ms.
timeoutMs?: number;
// When set, a tooltip will replace any previous tooltip with the same key.
key?: string;
}
// Map of open tooltips, mapping the key (from ITipOptions) to the cleanup function that removes
// the tooltip.
const openTooltips = new Map<string, () => void>();
export function showTransientTooltip(refElem: Element, text: string, options: ITipOptions = {}) {
const placement: Popper.Placement = options.placement || 'top';
const timeoutMs: number = options.timeoutMs || 2000;
const key = options.key;
// If we had a previous tooltip with the same key, clean it up.
if (key) { openTooltips.get(key)?.(); }
// Add the content element.
const content = cssTooltip({role: 'tooltip'}, text, testId(`transient-tooltip`));
document.body.appendChild(content);
// Create a popper for positioning the tooltip content relative to refElem.
const popperOptions: Popper.PopperOptions = {
modifiers: {preventOverflow: {boundariesElement: 'viewport'}},
placement,
};
const popper = new Popper(refElem, content, popperOptions);
// Fade in the content using transitions.
prepareForTransition(content, () => { content.style.opacity = '0'; });
content.style.opacity = '';
// Cleanup involves destroying the Popper instance, removing the element, etc.
function cleanup() {
popper.destroy();
dom.domDispose(content);
content.remove();
if (key) { openTooltips.delete(key); }
clearTimeout(timer);
}
const timer = setTimeout(cleanup, timeoutMs);
if (key) { openTooltips.set(key, cleanup); }
}
const cssTooltip = styled('div', `
position: absolute;
z-index: 5000; /* should be higher than a modal */
background-color: black;
border-radius: 3px;
box-shadow: 0 0 2px rgba(0,0,0,0.5);
text-align: center;
color: white;
width: auto;
font-family: sans-serif;
font-size: 10pt;
padding: 8px 16px;
margin: 4px;
opacity: 0.65;
transition: opacity 0.2s;
`);

View File

@@ -0,0 +1,50 @@
/**
* This is a temporary <input> element. The intended usage is to create is when needed (e.g. when
* some "rename" option is chosen), and provide methods to save and to close.
*
* It calls save() on Enter and on blur, which should return a Promise. On successful save, and on
* Escape, it calls close(), which should destroy the <input>.
*/
import {reportError} from 'app/client/models/AppModel';
import {dom, DomArg} from 'grainjs';
export interface ITransientInputOptions {
initialValue: string;
save(value: string): Promise<void>|any;
close(): void;
}
export function transientInput({initialValue, save, close}: ITransientInputOptions,
...args: Array<DomArg<HTMLInputElement>>) {
let lastSave: string = initialValue;
async function onSave(explicitSave: boolean) {
try {
if (explicitSave || input.value !== lastSave) {
lastSave = input.value;
await save(input.value);
}
close();
} catch (err) {
reportError(err);
delayedFocus();
}
}
function delayedFocus() {
setTimeout(() => { input.focus(); input.select(); }, 10);
}
const input = dom('input', {type: 'text', placeholder: 'Enter name'},
dom.prop('value', initialValue),
dom.on('blur', () => onSave(false)),
dom.onKeyDown({
Enter: () => onSave(true),
Escape: () => close(),
}),
...args,
);
delayedFocus();
return input;
}

View File

@@ -0,0 +1,28 @@
/**
* Exposes utilities for getting the types information associated to each of the widget types.
*/
import { IconName } from "app/client/ui2018/IconList";
// all widget types
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom';
// Widget type info.
export interface IWidgetTypeInfo {
label: string;
icon: IconName;
}
// the list of widget types with their labels and icons
export const widgetTypes = new Map<IWidgetType, IWidgetTypeInfo> ([
['record', {label: 'Table', icon: 'TypeTable'}],
['single', {label: 'Card', icon: 'TypeCard'}],
['detail', {label: 'Card List', icon: 'TypeCardList'}],
['chart', {label: 'Chart', icon: 'TypeChart'}],
['custom', {label: 'Custom', icon: 'TypeCustom'}]
]);
// Returns the widget type info for sectionType, or the one for 'record' if sectionType is null.
export function getWidgetTypes(sectionType: IWidgetType|null): IWidgetTypeInfo {
return widgetTypes.get(sectionType || 'record') || widgetTypes.get('record')!;
}