diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 646e0461..272e3f31 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: python-version: [3.9] node-version: [18.x] tests: - - ':lint:python:client:common:smoke:' + - ':lint:python:client:common:smoke:stubs:' - ':server-1-of-2:' - ':server-2-of-2:' - ':nbrowser-^[A-G]:' @@ -73,7 +73,7 @@ jobs: run: yarn run build:prod - name: Install chromedriver - if: contains(matrix.tests, ':nbrowser-') || contains(matrix.tests, ':smoke:') + if: contains(matrix.tests, ':nbrowser-') || contains(matrix.tests, ':smoke:') || contains(matrix.tests, ':stubs:') run: ./node_modules/selenium-webdriver/bin/linux/selenium-manager --driver chromedriver - name: Run smoke test @@ -92,6 +92,10 @@ jobs: if: contains(matrix.tests, ':common:') run: yarn run test:common + - name: Run stubs tests + if: contains(matrix.tests, ':stubs:') + run: MOCHA_WEBDRIVER_HEADLESS=1 yarn run test:stubs + - name: Run server tests with minio and redis if: contains(matrix.tests, ':server-') run: | diff --git a/app/client/ui/CreateTeamModal.ts b/app/client/ui/CreateTeamModal.ts new file mode 100644 index 00000000..91ce263c --- /dev/null +++ b/app/client/ui/CreateTeamModal.ts @@ -0,0 +1,362 @@ +import {autoFocus} from 'app/client/lib/domUtils'; +import {ValidationGroup, Validator} from 'app/client/lib/Validator'; +import {AppModel, getHomeUrl} from 'app/client/models/AppModel'; +import {reportError, UserError} from 'app/client/models/errors'; +import {urlState} from 'app/client/models/gristUrlState'; +import {bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; +import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {IModalControl, modal} from 'app/client/ui2018/modals'; +import {TEAM_PLAN} from 'app/common/Features'; +import {checkSubdomainValidity} from 'app/common/orgNameUtils'; +import {UserAPIImpl} from 'app/common/UserAPI'; +import { + Disposable, dom, DomArg, DomContents, DomElementArg, IDisposableOwner, input, makeTestId, + Observable, styled +} from 'grainjs'; +import { makeT } from '../lib/localization'; + +const t = makeT('CreateTeamModal'); +const testId = makeTestId('test-create-team-'); + +export function buildNewSiteModal(context: Disposable, options: { + planName: string, + selectedPlan?: string, + onCreate?: () => void +}) { + const { onCreate } = options; + + return showModal( + context, + (_owner: Disposable, ctrl: IModalControl) => dom.create(NewSiteModalContent, ctrl, onCreate), + dom.cls(cssModalIndex.className), + ); +} + +class NewSiteModalContent extends Disposable { + private _page = Observable.create(this, 'createTeam'); + private _team = Observable.create(this, ''); + private _domain = Observable.create(this, ''); + private _ctrl: IModalControl; + + constructor( + ctrl: IModalControl, + private _onCreate?: (planName: string) => void) { + super(); + this._ctrl = ctrl; + } + + public buildDom() { + const team = this._team; + const domain = this._domain; + const ctrl = this._ctrl; + return dom.domComputed(this._page, pageValue => { + + switch (pageValue) { + case 'createTeam': return buildTeamPage({ + team, + domain, + create: () => this._createTeam(), + ctrl + }); + case 'teamSuccess': return buildConfirm({domain: domain.get()}); + } + }); + } + + private async _createTeam() { + const api = new UserAPIImpl(getHomeUrl()); + try { + await api.newOrg({name: this._team.get(), domain: this._domain.get()}); + this._page.set('teamSuccess'); + if (this._onCreate) { + this._onCreate(TEAM_PLAN); + } + } catch (err) { + reportError(err as Error); + } + } +} + +export function buildUpgradeModal(owner: Disposable, planName: string): void { + throw new UserError(t(`Billing is not supported in grist-core`)); +} + +export interface UpgradeButton { + showUpgradeCard(...args: DomArg[]): DomContents; + showUpgradeButton(...args: DomArg[]): DomContents; +} + +export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): UpgradeButton { + return { + showUpgradeCard: () => null, + showUpgradeButton: () => null, + }; +} + +export function buildConfirm({ + domain, +}: { + domain: string; +}) { + return cssConfirmWrapper( + cssSparks(), + hspace('1.5em'), + cssHeaderLine(t('Team site created'), testId("confirmation")), + hspace('2em'), + bigPrimaryButtonLink( + urlState().setLinkUrl({org: domain || undefined}), t('Go to your site'), testId("confirmation-link") + ) + ); +} + +function buildTeamPage({ + team, + domain, + create, + ctrl +}: { + team: Observable; + domain: Observable; + create: () => any; + ctrl: IModalControl; +}) { + const disabled = Observable.create(null, false); + const group = new ValidationGroup(); + async function click() { + disabled.set(true); + try { + if (!await group.validate()) { + return; + } + await create(); + } finally { + disabled.set(false); + } + } + const clickOnEnter = dom.onKeyPress({ + Enter: () => click(), + }); + return cssWide( + dom.autoDispose(disabled), + cssHeaderLine(t("Work as a Team"), testId("creation-title")), + cssSubHeaderLine(t("Choose a name and url for your team site")), + hspace('1.5em'), + cssColumns( + cssSetup( + cssLabel(t('Team name')), + cssRow(cssField(cssInput( + team, + {onInput: true}, + autoFocus(), + group.inputReset(), + clickOnEnter, + testId('name')))), + dom.create(Validator, group, t("Team name is required"), () => !!team.get()), + hspace('2em'), + cssLabel(t('Team url')), + cssRow( + {style: 'align-items: baseline'}, + cssField( + {style: 'flex: 0 1 0; min-width: auto; margin-right: 5px'}, + dom.text(`${window.location.origin}/o/`)), + cssField(cssInput( + domain, {onInput: true}, clickOnEnter, group.inputReset(), testId('domain') + )), + ), + dom.create(Validator, group, t("Domain name is required"), () => !!domain.get()), + dom.create(Validator, group, t("Domain name is invalid"), () => checkSubdomainValidity(domain.get())), + cssButtonsRow( + bigBasicButton( + t('Cancel'), + dom.on('click', () => ctrl.close()), + testId('cancel')), + bigPrimaryButton(t("Create site"), + dom.on('click', click), + dom.prop('disabled', disabled), + testId('confirm') + ), + ) + ) + ) + ); +} + +function showModal( + context: Disposable, + content: (owner: Disposable, ctrl: IModalControl) => DomContents, + ...args: DomElementArg[] +) { + let control!: IModalControl; + modal((ctrl, modalScope) => { + control = ctrl; + // When parent is being disposed and we are still visible, close the modal. + context.onDispose(() => { + // If the modal is already closed (disposed, do nothing) + if (modalScope.isDisposed()) { + return; + } + // If not, and parent is going away, close the modal. + ctrl.close(); + }); + return [ + cssCreateTeamModal.cls(''), + cssCloseButton(testId("close-modal"), cssBigIcon('CrossBig'), dom.on('click', () => ctrl.close())), + content(modalScope, ctrl) + ]; + }, {backerDomArgs: args}); + return control; +} + +function hspace(height: string) { + return dom('div', {style: `height: ${height}`}); +} + +export const cssCreateTeamModal = styled('div', ` + position: relative; + @media ${mediaSmall} { + & { + width: 100%; + min-width: unset; + padding: 24px 16px; + } + } +`); + +const cssConfirmWrapper = styled('div', ` + text-align: center; +`); + +const cssSparks = styled('div', ` + height: 48px; + width: 48px; + background-image: var(--icon-Sparks); + display: inline-block; + background-repeat: no-repeat; + &-small { + height: 20px; + width: 20px; + background-size: cover; + } +`); + +const cssColumns = styled('div', ` + display: flex; + gap: 60px; + flex-wrap: wrap; +`); + +const cssSetup = styled('div', ` + display: flex; + flex-direction: column; + flex-grow: 1; +`); + +const cssHeaderLine = styled('div', ` + text-align: center; + font-size: 24px; + font-weight: 600; + margin-bottom: 16px; +`); + +const cssSubHeaderLine = styled('div', ` + text-align: center; + margin-bottom: 7px; +`); + +const cssLabel = styled('label', ` + font-weight: ${vars.headerControlTextWeight}; + font-size: ${vars.mediumFontSize}; + color: ${theme.text}; + line-height: 1.5em; + margin: 0px; + margin-bottom: 0.3em; +`); + + +const cssWide = styled('div', ` + min-width: 760px; + @media ${mediaSmall} { + & { + min-width: unset; + } + } +`); + +const cssRow = styled('div', ` + display: flex; +`); + +const cssField = styled('div', ` + display: block; + flex: 1 1 0; + margin: 4px 0; + min-width: 120px; +`); + + +const cssButtonsRow = styled('div', ` + display: flex; + justify-content: flex-end; + margin-top: 20px; + min-width: 250px; + gap: 10px; + flex-wrap: wrap; + @media ${mediaSmall} { + & { + margin-top: 60px; + } + } +`); + +const cssCloseButton = styled('div', ` + position: absolute; + top: 8px; + right: 8px; + padding: 4px; + border-radius: 4px; + cursor: pointer; + --icon-color: ${theme.modalCloseButtonFg}; + + &:hover { + background-color: ${theme.hover}; + } +`); + +const cssBigIcon = styled(icon, ` + padding: 12px; +`); + +const cssModalIndex = styled('div', ` + z-index: ${vars.pricingModalZIndex} +`); + +const cssInput = styled(input, ` + color: ${theme.inputFg}; + background-color: ${theme.inputBg}; + font-size: ${vars.mediumFontSize}; + height: 42px; + line-height: 16px; + width: 100%; + padding: 13px; + border: 1px solid ${theme.inputBorder}; + border-radius: 3px; + outline: none; + + &-invalid { + color: ${theme.inputInvalid}; + } + + &[type=number] { + -moz-appearance: textfield; + } + &[type=number]::-webkit-inner-spin-button, + &[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &::placeholder { + color: ${theme.inputPlaceholderFg}; + } +`); diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index debe362f..9c2fc296 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -93,7 +93,10 @@ function makeTeamSiteIntro(homeModel: HomeModel) { cssIntroLine(t("Get started by inviting your team and creating your first Grist document.")), (!isFeatureEnabled('helpCenter') ? null : cssIntroLine( - 'Learn more in our ', helpCenterLink(), ', or find an expert via our ', sproutsProgram, '.', // TODO i18n + t( + 'Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.', + {helpCenterLink: helpCenterLink(), sproutsProgram} + ), testId('welcome-text') ) ), diff --git a/app/client/ui/ProductUpgradesStub.ts b/app/client/ui/ProductUpgradesStub.ts deleted file mode 100644 index 1e054f68..00000000 --- a/app/client/ui/ProductUpgradesStub.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type {AppModel} from 'app/client/models/AppModel'; -import {commonUrls} from 'app/common/gristUrls'; -import {Disposable, DomArg, DomContents, IDisposableOwner} from 'grainjs'; - -export function buildNewSiteModal(context: Disposable, options: { - planName: string, - selectedPlan?: string, - onCreate?: () => void -}) { - window.location.href = commonUrls.plans; -} - -export function buildUpgradeModal(owner: Disposable, planName: string) { - window.location.href = commonUrls.plans; -} - -export function showTeamUpgradeConfirmation(owner: Disposable) { -} - -export interface UpgradeButton { - showUpgradeCard(...args: DomArg[]): DomContents; - showUpgradeButton(...args: DomArg[]): DomContents; -} - -export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): UpgradeButton { - return { - showUpgradeCard : () => null, - showUpgradeButton : () => null, - }; -} diff --git a/app/client/ui/SupportGristNudge.ts b/app/client/ui/SupportGristNudge.ts index ae13a0b5..e35d16cf 100644 --- a/app/client/ui/SupportGristNudge.ts +++ b/app/client/ui/SupportGristNudge.ts @@ -149,8 +149,8 @@ export class SupportGristNudge extends Disposable { cssCenterAlignedHeader(t('Opted In')), cssParagraph( t( - 'Thank you! Your trust and support is greatly appreciated. ' + - 'Opt out any time from the {{link}} in the user menu.', + 'Thank you! Your trust and support is greatly appreciated.\ + Opt out any time from the {{link}} in the user menu.', {link: adminPanelLink()}, ), ), diff --git a/package.json b/package.json index 3abcaac7..371f14a8 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "install:python3": "buildtools/prepare_python3.sh", "build:prod": "buildtools/build.sh", "start:prod": "sandbox/run.sh", - "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", + "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/nbrowser_with_stubs/**/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", "test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'", + "test:stubs": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser_with_stubs/**/*.js'", "test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'", "test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'", "test:server": "TEST_CLEAN_DATABASE=true TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -g \"${GREP_TESTS}\" -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 3376786e..e3570a0d 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -481,7 +481,8 @@ "Welcome to {{- orgName}}": "Welcome to {{- orgName}}", "Sign in": "Sign in", "To use Grist, please either sign up or sign in.": "To use Grist, please either sign up or sign in.", - "Visit our {{link}} to learn more about Grist.": "Visit our {{link}} to learn more about Grist." + "Visit our {{link}} to learn more about Grist.": "Visit our {{link}} to learn more about Grist.", + "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}." }, "HomeLeftPane": { "Access Details": "Access Details", @@ -1294,6 +1295,7 @@ "Opted In": "Opted In", "Support Grist": "Support Grist", "Support Grist page": "Support Grist page", + "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.", "Admin Panel": "Admin Panel" }, "SupportGristPage": { @@ -1439,6 +1441,20 @@ "Insert section above": "Insert section above", "Insert section below": "Insert section below" }, + "CreateTeamModal": { + "Cancel": "Cancel", + "Choose a name and url for your team site": "Choose a name and url for your team site", + "Create site": "Create site", + "Domain name is invalid": "Domain name is invalid", + "Domain name is required": "Domain name is required", + "Go to your site": "Go to your site", + "Team name": "Team name", + "Team name is required": "Team name is required", + "Team site created": "Team site created", + "Team url": "Team url", + "Work as a Team": "Work as a Team", + "Billing is not supported in grist-core": "Billing is not supported in grist-core" + }, "AdminPanel": { "Admin Panel": "Admin Panel", "Current": "Current", diff --git a/stubs/app/client/ui/ProductUpgrades.ts b/stubs/app/client/ui/ProductUpgrades.ts index 2c583135..839a91d7 100644 --- a/stubs/app/client/ui/ProductUpgrades.ts +++ b/stubs/app/client/ui/ProductUpgrades.ts @@ -1 +1 @@ -export * from 'app/client/ui/ProductUpgradesStub'; +export * from 'app/client/ui/CreateTeamModal'; diff --git a/test/nbrowser_with_stubs/CreateTeamSite.ts b/test/nbrowser_with_stubs/CreateTeamSite.ts new file mode 100644 index 00000000..5c5bdfda --- /dev/null +++ b/test/nbrowser_with_stubs/CreateTeamSite.ts @@ -0,0 +1,62 @@ +import { assert, driver, Key } from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import { cleanupExtraWindows, setupTestSuite } from 'test/nbrowser/testUtils'; + +describe('Create Team Site', function () { + this.timeout(20000); + cleanupExtraWindows(); + const cleanup = setupTestSuite(); + + before(async function () { + const session = await gu.session().teamSite.login(); + await session.tempNewDoc(cleanup); + }); + + async function openCreateTeamModal() { + await driver.findWait('.test-dm-org', 500).click(); + assert.equal(await driver.find('.test-site-switcher-create-new-site').isPresent(), true); + await driver.find('.test-site-switcher-create-new-site').click(); + } + + async function fillCreateTeamModalInputs(name: string, domain: string) { + await driver.findWait('.test-create-team-name', 500).click(); + await gu.sendKeys(name); + await gu.sendKeys(Key.TAB); + await gu.sendKeys(domain); + } + + async function goToNewTeamSite() { + await driver.findWait('.test-create-team-confirmation-link', 500).click(); + } + + async function getTeamSiteName() { + return await driver.findWait('.test-dm-orgname', 500).getText(); + } + + it('should work using the createTeamModal', async () => { + assert.equal(await driver.find('.test-dm-org').isPresent(), true); + const teamSiteName = await getTeamSiteName(); + assert.equal(teamSiteName, 'Test Grist'); + await openCreateTeamModal(); + assert.equal(await driver.find('.test-create-team-creation-title').isPresent(), true); + + await fillCreateTeamModalInputs("Test Create Team Site", "testteamsite"); + await gu.sendKeys(Key.ENTER); + assert.equal(await driver.findWait('.test-create-team-confirmation', 500).isPresent(), true); + await goToNewTeamSite(); + const newTeamSiteName = await getTeamSiteName(); + assert.equal(newTeamSiteName, 'Test Create Team Site'); + }); + + it('should work only with unique domain', async () => { + await openCreateTeamModal(); + await fillCreateTeamModalInputs("Test Create Team Site 1", "same-domain"); + await gu.sendKeys(Key.ENTER); + await goToNewTeamSite(); + await openCreateTeamModal(); + await fillCreateTeamModalInputs("Test Create Team Site 2", "same-domain"); + await gu.sendKeys(Key.ENTER); + const errorMessage = await driver.findWait('.test-notifier-toast-wrapper ', 500).getText(); + assert.include(errorMessage, 'Domain already in use'); + }); +});