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