mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Draft version of AI assistant
Summary: The feature is behind a flag GRIST_FORMULA_ASSISTANT (must be "true"). But can be enabled in the developer console by invoking GRIST_FORMULA_ASSISTANT.set(true). Keys can be overriden in the document settings page. Test Plan: For now just a stub test that checks if this feature is disabled by default. Reviewers: paulfitz Reviewed By: paulfitz Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3815
This commit is contained in:
parent
33c08057ad
commit
d29770511c
@ -187,6 +187,10 @@ AceEditor.prototype._setup = function() {
|
|||||||
if (this.gristDoc && this.column) {
|
if (this.gristDoc && this.column) {
|
||||||
const getSuggestions = (prefix) => {
|
const getSuggestions = (prefix) => {
|
||||||
const section = this.gristDoc.viewModel.activeSection();
|
const section = this.gristDoc.viewModel.activeSection();
|
||||||
|
// If section is disposed or is pointing to an empty row, don't try to autocomplete.
|
||||||
|
if (!section?.getRowId()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const tableId = section.table().tableId();
|
const tableId = section.table().tableId();
|
||||||
const columnId = this.column.colId();
|
const columnId = this.column.colId();
|
||||||
const rowId = section.activeRowId();
|
const rowId = section.activeRowId();
|
||||||
|
@ -175,6 +175,9 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
public externalSectionId: Computed<number|null>;
|
public externalSectionId: Computed<number|null>;
|
||||||
public viewLayout: ViewLayout|null = null;
|
public viewLayout: ViewLayout|null = null;
|
||||||
|
|
||||||
|
// Holder for the popped up formula editor.
|
||||||
|
public readonly formulaPopup = Holder.create(this);
|
||||||
|
|
||||||
private _actionLog: ActionLog;
|
private _actionLog: ActionLog;
|
||||||
private _undoStack: UndoStack;
|
private _undoStack: UndoStack;
|
||||||
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
|
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import {KoArray} from 'app/client/lib/koArray';
|
import {KoArray} from 'app/client/lib/koArray';
|
||||||
|
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
|
||||||
import {CellRec, DocModel, IRowModel, recordSet,
|
import {CellRec, DocModel, IRowModel, recordSet,
|
||||||
refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel';
|
refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||||
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil';
|
import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil';
|
||||||
import * as gristTypes from 'app/common/gristTypes';
|
import * as gristTypes from 'app/common/gristTypes';
|
||||||
import {getReferencedTableId} from 'app/common/gristTypes';
|
import {getReferencedTableId} from 'app/common/gristTypes';
|
||||||
@ -11,6 +13,7 @@ import {
|
|||||||
FullFormatterArgs
|
FullFormatterArgs
|
||||||
} from 'app/common/ValueFormatter';
|
} from 'app/common/ValueFormatter';
|
||||||
import {createParser} from 'app/common/ValueParser';
|
import {createParser} from 'app/common/ValueParser';
|
||||||
|
import {Observable} from 'grainjs';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
|
|
||||||
// Column behavior type, used primarily in the UI.
|
// Column behavior type, used primarily in the UI.
|
||||||
@ -77,6 +80,11 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
|
|||||||
formatter: ko.Computed<BaseFormatter>;
|
formatter: ko.Computed<BaseFormatter>;
|
||||||
cells: ko.Computed<KoArray<CellRec>>;
|
cells: ko.Computed<KoArray<CellRec>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current history of chat. This is a temporary array used only in the ui.
|
||||||
|
*/
|
||||||
|
chatHistory: ko.PureComputed<Observable<ChatMessage[]>>;
|
||||||
|
|
||||||
// Helper which adds/removes/updates column's displayCol to match the formula.
|
// Helper which adds/removes/updates column's displayCol to match the formula.
|
||||||
saveDisplayFormula(formula: string): Promise<void>|undefined;
|
saveDisplayFormula(formula: string): Promise<void>|undefined;
|
||||||
|
|
||||||
@ -151,6 +159,12 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.behavior = ko.pureComputed(() => this.isEmpty() ? 'empty' : this.isFormula() ? 'formula' : 'data');
|
this.behavior = ko.pureComputed(() => this.isEmpty() ? 'empty' : this.isFormula() ? 'formula' : 'data');
|
||||||
|
|
||||||
|
this.chatHistory = this.autoDispose(ko.computed(() => {
|
||||||
|
const docId = urlState().state.get().doc ?? '';
|
||||||
|
const key = `formula-assistant-history-${docId}-${this.table().tableId()}-${this.colId()}`;
|
||||||
|
return localStorageJsonObs(key, [] as ChatMessage[]);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatterForRec(
|
export function formatterForRec(
|
||||||
@ -168,3 +182,22 @@ export function formatterForRec(
|
|||||||
};
|
};
|
||||||
return func(args);
|
return func(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chat message. Either send by the user or by the AI.
|
||||||
|
*/
|
||||||
|
export interface ChatMessage {
|
||||||
|
/**
|
||||||
|
* The message to display. It is a prompt typed by the user or a formula returned from the AI.
|
||||||
|
*/
|
||||||
|
message: string;
|
||||||
|
/**
|
||||||
|
* The sender of the message. Either the user or the AI.
|
||||||
|
*/
|
||||||
|
sender: 'user' | 'ai';
|
||||||
|
/**
|
||||||
|
* The formula returned from the AI. It is only set when the sender is the AI. For now it is the same
|
||||||
|
* value as the message, but it might change in the future when we use more conversational AI.
|
||||||
|
*/
|
||||||
|
formula?: string;
|
||||||
|
}
|
||||||
|
@ -10,3 +10,12 @@ export function COMMENTS(): Observable<boolean> {
|
|||||||
}
|
}
|
||||||
return G.window.COMMENTS;
|
return G.window.COMMENTS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GRIST_FORMULA_ASSISTANT(): Observable<boolean> {
|
||||||
|
const G = getBrowserGlobals('document', 'window');
|
||||||
|
if (!G.window.GRIST_FORMULA_ASSISTANT) {
|
||||||
|
G.window.GRIST_FORMULA_ASSISTANT =
|
||||||
|
localStorageBoolObs('GRIST_FORMULA_ASSISTANT', Boolean(getGristConfig().featureFormulaAssistant));
|
||||||
|
}
|
||||||
|
return G.window.GRIST_FORMULA_ASSISTANT;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import * as ace from 'brace';
|
import * as ace from 'brace';
|
||||||
import {BindableValue, dom, DomElementArg, styled, subscribeElem} from 'grainjs';
|
import {BindableValue, dom, DomElementArg, styled, subscribeElem} from 'grainjs';
|
||||||
|
|
||||||
@ -71,3 +71,18 @@ const cssHighlightedCode = styled(cssCodeBlock, `
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
export const cssFieldFormula = styled(buildHighlightedCode, `
|
||||||
|
flex: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 24px;
|
||||||
|
--icon-color: ${theme.accentIcon};
|
||||||
|
|
||||||
|
&-disabled-icon.formula_field_sidepane::before {
|
||||||
|
--icon-color: ${theme.lightText};
|
||||||
|
}
|
||||||
|
&-disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
@ -1,22 +1,21 @@
|
|||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {renderer} from 'app/client/ui/DocTutorialRenderer';
|
import {renderer} from 'app/client/ui/DocTutorialRenderer';
|
||||||
|
import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup';
|
||||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
||||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||||
import {isNarrowScreen, isNarrowScreenObs, mediaXSmall, theme} from 'app/client/ui2018/cssVars';
|
import {mediaXSmall, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {confirmModal, modal} from 'app/client/ui2018/modals';
|
import {confirmModal, modal} from 'app/client/ui2018/modals';
|
||||||
import {parseUrlId} from 'app/common/gristUrls';
|
import {parseUrlId} from 'app/common/gristUrls';
|
||||||
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
|
import {dom, makeTestId, Observable, styled} from 'grainjs';
|
||||||
import {marked} from 'marked';
|
import {marked} from 'marked';
|
||||||
import debounce = require('lodash/debounce');
|
import debounce = require('lodash/debounce');
|
||||||
import range = require('lodash/range');
|
import range = require('lodash/range');
|
||||||
import sortBy = require('lodash/sortBy');
|
import sortBy = require('lodash/sortBy');
|
||||||
|
|
||||||
const POPUP_PADDING_PX = 16;
|
|
||||||
|
|
||||||
interface DocTutorialSlide {
|
interface DocTutorialSlide {
|
||||||
slideContent: string;
|
slideContent: string;
|
||||||
boxContent?: string;
|
boxContent?: string;
|
||||||
@ -26,20 +25,16 @@ interface DocTutorialSlide {
|
|||||||
|
|
||||||
const testId = makeTestId('test-doc-tutorial-');
|
const testId = makeTestId('test-doc-tutorial-');
|
||||||
|
|
||||||
export class DocTutorial extends Disposable {
|
export class DocTutorial extends FloatingPopup {
|
||||||
private _appModel = this._gristDoc.docPageModel.appModel;
|
private _appModel = this._gristDoc.docPageModel.appModel;
|
||||||
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
|
private _currentDoc = this._gristDoc.docPageModel.currentDoc.get();
|
||||||
private _docComm = this._gristDoc.docComm;
|
private _docComm = this._gristDoc.docComm;
|
||||||
private _docData = this._gristDoc.docData;
|
private _docData = this._gristDoc.docData;
|
||||||
private _docId = this._gristDoc.docId();
|
private _docId = this._gristDoc.docId();
|
||||||
private _popupElement: HTMLElement | null = null;
|
|
||||||
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
|
private _slides: Observable<DocTutorialSlide[] | null> = Observable.create(this, null);
|
||||||
private _currentSlideIndex = Observable.create(this,
|
private _currentSlideIndex = Observable.create(this,
|
||||||
this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0);
|
this._currentDoc?.forks?.[0]?.options?.tutorial?.lastSlideIndex ?? 0);
|
||||||
private _isMinimized = Observable.create(this, false);
|
|
||||||
|
|
||||||
private _clientX: number;
|
|
||||||
private _clientY: number;
|
|
||||||
|
|
||||||
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
|
private _saveCurrentSlidePositionDebounced = debounce(this._saveCurrentSlidePosition, 1000, {
|
||||||
// Save new position immediately if at least 1 second has passed since the last change.
|
// Save new position immediately if at least 1 second has passed since the last change.
|
||||||
@ -50,27 +45,107 @@ export class DocTutorial extends Disposable {
|
|||||||
|
|
||||||
constructor(private _gristDoc: GristDoc) {
|
constructor(private _gristDoc: GristDoc) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this._handleMouseDown = this._handleMouseDown.bind(this);
|
|
||||||
this._handleMouseMove = this._handleMouseMove.bind(this);
|
|
||||||
this._handleMouseUp = this._handleMouseUp.bind(this);
|
|
||||||
this._handleTouchStart = this._handleTouchStart.bind(this);
|
|
||||||
this._handleTouchMove = this._handleTouchMove.bind(this);
|
|
||||||
this._handleTouchEnd = this._handleTouchEnd.bind(this);
|
|
||||||
this._handleWindowResize = this._handleWindowResize.bind(this);
|
|
||||||
|
|
||||||
this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup()));
|
|
||||||
|
|
||||||
this.onDispose(() => {
|
|
||||||
this._closePopup();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start() {
|
public async start() {
|
||||||
this._showPopup();
|
this.showPopup();
|
||||||
await this._loadSlides();
|
await this._loadSlides();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _buildTitle() {
|
||||||
|
return dom('span', dom.text(this._gristDoc.docPageModel.currentDocTitle), testId('popup-header'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _buildContent() {
|
||||||
|
return [
|
||||||
|
dom.domComputed(use => {
|
||||||
|
const slides = use(this._slides);
|
||||||
|
const slideIndex = use(this._currentSlideIndex);
|
||||||
|
const slide = slides?.[slideIndex];
|
||||||
|
return cssPopupBody(
|
||||||
|
!slide ? cssSpinner(loadingSpinner()) : [
|
||||||
|
dom('div', elem => {
|
||||||
|
elem.innerHTML = slide.slideContent;
|
||||||
|
}),
|
||||||
|
!slide.boxContent ? null : cssTryItOutBox(
|
||||||
|
dom('div', elem => { elem.innerHTML = slide.boxContent!; }),
|
||||||
|
),
|
||||||
|
dom.on('click', (ev) => {
|
||||||
|
if((ev.target as HTMLElement).tagName !== 'IMG') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._openLightbox((ev.target as HTMLImageElement).src);
|
||||||
|
}),
|
||||||
|
this._restartGIFs(),
|
||||||
|
],
|
||||||
|
testId('popup-body'),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
cssPopupFooter(
|
||||||
|
dom.domComputed(use => {
|
||||||
|
const slides = use(this._slides);
|
||||||
|
if (!slides) { return null; }
|
||||||
|
|
||||||
|
const slideIndex = use(this._currentSlideIndex);
|
||||||
|
const numSlides = slides.length;
|
||||||
|
const isFirstSlide = slideIndex === 0;
|
||||||
|
const isLastSlide = slideIndex === numSlides - 1;
|
||||||
|
return [
|
||||||
|
cssFooterButtonsLeft(
|
||||||
|
cssPopupFooterButton(icon('Undo'),
|
||||||
|
hoverTooltip('Restart Tutorial', {key: 'docTutorialTooltip'}),
|
||||||
|
dom.on('click', () => this._restartTutorial()),
|
||||||
|
testId('popup-restart'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cssProgressBar(
|
||||||
|
range(slides.length).map((i) => cssProgressBarDot(
|
||||||
|
{title: slides[i].slideTitle},
|
||||||
|
cssProgressBarDot.cls('-current', i === slideIndex),
|
||||||
|
i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)),
|
||||||
|
testId(`popup-slide-${i + 1}`),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
cssFooterButtonsRight(
|
||||||
|
basicButton('Previous',
|
||||||
|
dom.on('click', async () => {
|
||||||
|
await this._previousSlide();
|
||||||
|
}),
|
||||||
|
{style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`},
|
||||||
|
testId('popup-previous'),
|
||||||
|
),
|
||||||
|
primaryButton(isLastSlide ? 'Finish': 'Next',
|
||||||
|
isLastSlide
|
||||||
|
? dom.on('click', async () => await this._finishTutorial())
|
||||||
|
: dom.on('click', async () => await this._nextSlide()),
|
||||||
|
testId('popup-next'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
testId('popup-footer'),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _buildArgs() {
|
||||||
|
return [
|
||||||
|
dom.cls('doc-tutorial-popup'),
|
||||||
|
testId('popup'),
|
||||||
|
// Pre-fetch images from all slides and store them in a hidden div.
|
||||||
|
dom.maybe(this._slides, slides =>
|
||||||
|
dom('div',
|
||||||
|
{style: 'display: none;'},
|
||||||
|
dom.forEach(slides, slide => {
|
||||||
|
if (slide.imageUrls.length === 0) { return null; }
|
||||||
|
return dom('div', slide.imageUrls.map(src => dom('img', {src})));
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private async _loadSlides() {
|
private async _loadSlides() {
|
||||||
const tableId = 'GristDocTutorial';
|
const tableId = 'GristDocTutorial';
|
||||||
if (!this._docData.getTable(tableId)) {
|
if (!this._docData.getTable(tableId)) {
|
||||||
@ -134,103 +209,6 @@ export class DocTutorial extends Disposable {
|
|||||||
this._slides.set(slides);
|
this._slides.set(slides);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _showPopup() {
|
|
||||||
this._popupElement = this._buildPopup();
|
|
||||||
document.body.appendChild(this._popupElement);
|
|
||||||
|
|
||||||
const topPaddingPx = getTopPopupPaddingPx();
|
|
||||||
const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_PADDING_PX;
|
|
||||||
const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx;
|
|
||||||
this._popupElement.style.left = `${initialLeft}px`;
|
|
||||||
this._popupElement.style.top = `${initialTop}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _closePopup() {
|
|
||||||
if (!this._popupElement) { return; }
|
|
||||||
|
|
||||||
document.body.removeChild(this._popupElement);
|
|
||||||
dom.domDispose(this._popupElement);
|
|
||||||
this._popupElement = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleMouseDown(ev: MouseEvent) {
|
|
||||||
this._clientX = ev.clientX;
|
|
||||||
this._clientY = ev.clientY;
|
|
||||||
document.addEventListener('mousemove', this._handleMouseMove);
|
|
||||||
document.addEventListener('mouseup', this._handleMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleTouchStart(ev: TouchEvent) {
|
|
||||||
this._clientX = ev.touches[0].clientX;
|
|
||||||
this._clientY = ev.touches[0].clientY;
|
|
||||||
document.addEventListener('touchmove', this._handleTouchMove);
|
|
||||||
document.addEventListener('touchend', this._handleTouchEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleMouseMove({clientX, clientY}: MouseEvent) {
|
|
||||||
this._handleMove(clientX, clientY);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleTouchMove({touches}: TouchEvent) {
|
|
||||||
this._handleMove(touches[0].clientX, touches[0].clientY);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleMove(clientX: number, clientY: number) {
|
|
||||||
const deltaX = clientX - this._clientX;
|
|
||||||
const deltaY = clientY - this._clientY;
|
|
||||||
let newLeft = this._popupElement!.offsetLeft + deltaX;
|
|
||||||
let newTop = this._popupElement!.offsetTop + deltaY;
|
|
||||||
|
|
||||||
const topPaddingPx = getTopPopupPaddingPx();
|
|
||||||
if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; }
|
|
||||||
if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; }
|
|
||||||
if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {
|
|
||||||
newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX;
|
|
||||||
}
|
|
||||||
if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) {
|
|
||||||
newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._popupElement!.style.left = `${newLeft}px`;
|
|
||||||
this._popupElement!.style.top = `${newTop}px`;
|
|
||||||
this._clientX = clientX;
|
|
||||||
this._clientY = clientY;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleMouseUp() {
|
|
||||||
document.removeEventListener('mousemove', this._handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', this._handleMouseUp);
|
|
||||||
document.body.removeEventListener('mouseleave', this._handleMouseUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleTouchEnd() {
|
|
||||||
document.removeEventListener('touchmove', this._handleTouchMove);
|
|
||||||
document.removeEventListener('touchend', this._handleTouchEnd);
|
|
||||||
document.body.removeEventListener('touchcancel', this._handleTouchEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleWindowResize() {
|
|
||||||
this._repositionPopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _repositionPopup() {
|
|
||||||
let newLeft = this._popupElement!.offsetLeft;
|
|
||||||
let newTop = this._popupElement!.offsetTop;
|
|
||||||
|
|
||||||
const topPaddingPx = getTopPopupPaddingPx();
|
|
||||||
if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; }
|
|
||||||
if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; }
|
|
||||||
if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {
|
|
||||||
newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX;
|
|
||||||
}
|
|
||||||
if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) {
|
|
||||||
newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._popupElement!.style.left = `${newLeft}px`;
|
|
||||||
this._popupElement!.style.top = `${newTop}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _saveCurrentSlidePosition() {
|
private async _saveCurrentSlidePosition() {
|
||||||
const currentOptions = this._currentDoc?.options ?? {};
|
const currentOptions = this._currentDoc?.options ?? {};
|
||||||
await this._appModel.api.updateDoc(this._docId, {
|
await this._appModel.api.updateDoc(this._docId, {
|
||||||
@ -290,128 +268,6 @@ export class DocTutorial extends Disposable {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildPopup() {
|
|
||||||
return cssPopup(
|
|
||||||
{tabIndex: '-1'},
|
|
||||||
cssPopupHeader(
|
|
||||||
dom.domComputed(this._isMinimized, isMinimized => {
|
|
||||||
return [
|
|
||||||
cssPopupHeaderSpacer(),
|
|
||||||
cssPopupTitle(
|
|
||||||
cssPopupTitleText(dom.text(this._gristDoc.docPageModel.currentDocTitle)),
|
|
||||||
testId('popup-title'),
|
|
||||||
),
|
|
||||||
cssPopupHeaderButton(
|
|
||||||
isMinimized ? icon('Maximize'): icon('Minimize'),
|
|
||||||
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
|
|
||||||
dom.on('click', () => {
|
|
||||||
this._isMinimized.set(!this._isMinimized.get());
|
|
||||||
this._repositionPopup();
|
|
||||||
}),
|
|
||||||
testId('popup-minimize-maximize'),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
dom.on('mousedown', this._handleMouseDown),
|
|
||||||
dom.on('touchstart', this._handleTouchStart),
|
|
||||||
testId('popup-header'),
|
|
||||||
),
|
|
||||||
dom.maybe(use => !use(this._isMinimized), () => [
|
|
||||||
dom.domComputed(use => {
|
|
||||||
const slides = use(this._slides);
|
|
||||||
const slideIndex = use(this._currentSlideIndex);
|
|
||||||
const slide = slides?.[slideIndex];
|
|
||||||
return cssPopupBody(
|
|
||||||
!slide ? cssSpinner(loadingSpinner()) : [
|
|
||||||
dom('div', elem => {
|
|
||||||
elem.innerHTML = slide.slideContent;
|
|
||||||
}),
|
|
||||||
!slide.boxContent ? null : cssTryItOutBox(
|
|
||||||
dom('div', elem => { elem.innerHTML = slide.boxContent!; }),
|
|
||||||
),
|
|
||||||
dom.on('click', (ev) => {
|
|
||||||
if((ev.target as HTMLElement).tagName !== 'IMG') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._openLightbox((ev.target as HTMLImageElement).src);
|
|
||||||
}),
|
|
||||||
this._restartGIFs(),
|
|
||||||
],
|
|
||||||
testId('popup-body'),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
cssPopupFooter(
|
|
||||||
dom.domComputed(use => {
|
|
||||||
const slides = use(this._slides);
|
|
||||||
if (!slides) { return null; }
|
|
||||||
|
|
||||||
const slideIndex = use(this._currentSlideIndex);
|
|
||||||
const numSlides = slides.length;
|
|
||||||
const isFirstSlide = slideIndex === 0;
|
|
||||||
const isLastSlide = slideIndex === numSlides - 1;
|
|
||||||
return [
|
|
||||||
cssFooterButtonsLeft(
|
|
||||||
cssPopupFooterButton(icon('Undo'),
|
|
||||||
hoverTooltip('Restart Tutorial', {key: 'docTutorialTooltip'}),
|
|
||||||
dom.on('click', () => this._restartTutorial()),
|
|
||||||
testId('popup-restart'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
cssProgressBar(
|
|
||||||
range(slides.length).map((i) => cssProgressBarDot(
|
|
||||||
{title: slides[i].slideTitle},
|
|
||||||
cssProgressBarDot.cls('-current', i === slideIndex),
|
|
||||||
i === slideIndex ? null : dom.on('click', () => this._changeSlide(i)),
|
|
||||||
testId(`popup-slide-${i + 1}`),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
cssFooterButtonsRight(
|
|
||||||
basicButton('Previous',
|
|
||||||
dom.on('click', async () => {
|
|
||||||
await this._previousSlide();
|
|
||||||
}),
|
|
||||||
{style: `visibility: ${isFirstSlide ? 'hidden' : 'visible'}`},
|
|
||||||
testId('popup-previous'),
|
|
||||||
),
|
|
||||||
primaryButton(isLastSlide ? 'Finish': 'Next',
|
|
||||||
isLastSlide
|
|
||||||
? dom.on('click', async () => await this._finishTutorial())
|
|
||||||
: dom.on('click', async () => await this._nextSlide()),
|
|
||||||
testId('popup-next'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
testId('popup-footer'),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
// Pre-fetch images from all slides and store them in a hidden div.
|
|
||||||
dom.maybe(this._slides, slides =>
|
|
||||||
dom('div',
|
|
||||||
{style: 'display: none;'},
|
|
||||||
dom.forEach(slides, slide => {
|
|
||||||
if (slide.imageUrls.length === 0) { return null; }
|
|
||||||
|
|
||||||
return dom('div', slide.imageUrls.map(src => dom('img', {src})));
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
() => { window.addEventListener('resize', this._handleWindowResize); },
|
|
||||||
dom.onDispose(() => {
|
|
||||||
document.removeEventListener('mousemove', this._handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', this._handleMouseUp);
|
|
||||||
document.removeEventListener('touchmove', this._handleTouchMove);
|
|
||||||
document.removeEventListener('touchend', this._handleTouchEnd);
|
|
||||||
window.removeEventListener('resize', this._handleWindowResize);
|
|
||||||
}),
|
|
||||||
cssPopup.cls('-minimized', this._isMinimized),
|
|
||||||
cssPopup.cls('-mobile', isNarrowScreenObs()),
|
|
||||||
dom.cls('doc-tutorial-popup'),
|
|
||||||
testId('popup'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _openLightbox(src: string) {
|
private _openLightbox(src: string) {
|
||||||
modal((ctl) => {
|
modal((ctl) => {
|
||||||
this.onDispose(ctl.close);
|
this.onDispose(ctl.close);
|
||||||
@ -429,85 +285,6 @@ export class DocTutorial extends Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopPopupPaddingPx(): number {
|
|
||||||
// On mobile, we need additional padding to avoid blocking the top and bottom bars.
|
|
||||||
return POPUP_PADDING_PX + (isNarrowScreen() ? 50 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const POPUP_HEIGHT = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`;
|
|
||||||
const POPUP_HEIGHT_MOBILE = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px) - (2 * 50px)))`;
|
|
||||||
const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`;
|
|
||||||
|
|
||||||
const cssPopup = styled('div', `
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border: 2px solid ${theme.accentBorder};
|
|
||||||
border-radius: 5px;
|
|
||||||
z-index: 999;
|
|
||||||
height: ${POPUP_HEIGHT};
|
|
||||||
width: ${POPUP_WIDTH};
|
|
||||||
background-color: ${theme.popupBg};
|
|
||||||
box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};
|
|
||||||
outline: unset;
|
|
||||||
|
|
||||||
&-mobile {
|
|
||||||
height: ${POPUP_HEIGHT_MOBILE};
|
|
||||||
}
|
|
||||||
|
|
||||||
&-minimized {
|
|
||||||
max-width: 225px;
|
|
||||||
height: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-minimized:not(&-mobile) {
|
|
||||||
max-height: ${POPUP_HEIGHT};
|
|
||||||
}
|
|
||||||
|
|
||||||
&-minimized&-mobile {
|
|
||||||
max-height: ${POPUP_HEIGHT_MOBILE};
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssPopupHeader = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
color: ${theme.tutorialsPopupHeaderFg};
|
|
||||||
--icon-color: ${theme.tutorialsPopupHeaderFg};
|
|
||||||
background-color: ${theme.accentBorder};
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-shrink: 0;
|
|
||||||
cursor: grab;
|
|
||||||
padding-left: 4px;
|
|
||||||
padding-right: 4px;
|
|
||||||
height: 30px;
|
|
||||||
user-select: none;
|
|
||||||
column-gap: 8px;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssPopupTitle = styled('div', `
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 600;
|
|
||||||
overflow: hidden;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssPopupTitleText = styled('div', `
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssPopupBody = styled('div', `
|
|
||||||
flex-grow: 1;
|
|
||||||
padding: 24px;
|
|
||||||
overflow: auto;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssPopupFooter = styled('div', `
|
const cssPopupFooter = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -526,20 +303,7 @@ const cssTryItOutBox = styled('div', `
|
|||||||
background-color: ${theme.tutorialsPopupBoxBg};
|
background-color: ${theme.tutorialsPopupBoxBg};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssPopupHeaderButton = styled('div', `
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: ${theme.hover};
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssPopupHeaderSpacer = styled('div', `
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const cssPopupFooterButton = styled('div', `
|
const cssPopupFooterButton = styled('div', `
|
||||||
--icon-color: ${theme.controlSecondaryFg};
|
--icon-color: ${theme.controlSecondaryFg};
|
||||||
|
@ -2,26 +2,26 @@
|
|||||||
* This module export a component for editing some document settings consisting of the timezone,
|
* This module export a component for editing some document settings consisting of the timezone,
|
||||||
* (new settings to be added here ...).
|
* (new settings to be added here ...).
|
||||||
*/
|
*/
|
||||||
import {makeT} from 'app/client/lib/localization';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs';
|
import {ACIndexImpl} from 'app/client/lib/ACIndex';
|
||||||
import {ACSelectItem, buildACSelect} from "app/client/lib/ACSelect";
|
import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
|
||||||
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
||||||
import {ACIndexImpl} from "app/client/lib/ACIndex";
|
import {makeT} from 'app/client/lib/localization';
|
||||||
import {docListHeader} from "app/client/ui/DocMenuCss";
|
import {reportError} from 'app/client/models/AppModel';
|
||||||
|
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
||||||
|
import {docListHeader} from 'app/client/ui/DocMenuCss';
|
||||||
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||||
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {select} from 'app/client/ui2018/menus';
|
import {select} from 'app/client/ui2018/menus';
|
||||||
|
import {confirmModal} from 'app/client/ui2018/modals';
|
||||||
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
|
import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker';
|
||||||
import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete';
|
import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete';
|
||||||
import {EngineCode} from 'app/common/DocumentSettings';
|
import {EngineCode} from 'app/common/DocumentSettings';
|
||||||
import {GristLoadConfig} from 'app/common/gristUrls';
|
import {GristLoadConfig} from 'app/common/gristUrls';
|
||||||
import {propertyCompare} from "app/common/gutil";
|
import {propertyCompare} from 'app/common/gutil';
|
||||||
import {getCurrency, locales} from "app/common/Locales";
|
import {getCurrency, locales} from 'app/common/Locales';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs';
|
||||||
import * as moment from "moment-timezone";
|
import * as moment from 'moment-timezone';
|
||||||
import {KoSaveableObservable} from 'app/client/models/modelUtil';
|
|
||||||
import {reportError} from 'app/client/models/AppModel';
|
|
||||||
import {confirmModal} from 'app/client/ui2018/modals';
|
|
||||||
|
|
||||||
const t = makeT('DocumentSettings');
|
const t = makeT('DocumentSettings');
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import {CursorPos} from 'app/client/components/Cursor';
|
|||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
|
import {BEHAVIOR, ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||||
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
import {buildHighlightedCode, cssCodeBlock} from 'app/client/ui/CodeHighlight';
|
||||||
|
import {buildAiButton} from 'app/client/ui/FormulaAssistance';
|
||||||
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
import {GristTooltips} from 'app/client/ui/GristTooltips';
|
||||||
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
import {cssBlockedCursor, cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||||
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
import {withInfoTooltip} from 'app/client/ui/tooltips';
|
||||||
@ -12,6 +13,7 @@ import {testId, theme} from 'app/client/ui2018/cssVars';
|
|||||||
import {textInput} from 'app/client/ui2018/editableLabel';
|
import {textInput} from 'app/client/ui2018/editableLabel';
|
||||||
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
import {cssIconButton, icon} from 'app/client/ui2018/icons';
|
||||||
import {IconName} from 'app/client/ui2018/IconList';
|
import {IconName} from 'app/client/ui2018/IconList';
|
||||||
|
import {GRIST_FORMULA_ASSISTANT} from 'app/client/models/features';
|
||||||
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
|
import {selectMenu, selectOption, selectTitle} from 'app/client/ui2018/menus';
|
||||||
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
|
import {createFormulaErrorObs, cssError} from 'app/client/widgets/FormulaEditor';
|
||||||
import {sanitizeIdent} from 'app/common/gutil';
|
import {sanitizeIdent} from 'app/common/gutil';
|
||||||
@ -356,6 +358,7 @@ export function buildFormulaConfig(
|
|||||||
]),
|
]),
|
||||||
formulaBuilder(onSaveConvertToFormula),
|
formulaBuilder(onSaveConvertToFormula),
|
||||||
cssEmptySeparator(),
|
cssEmptySeparator(),
|
||||||
|
dom.maybe(GRIST_FORMULA_ASSISTANT(), () => cssRow(buildAiButton(gristDoc, origColumn))),
|
||||||
cssRow(textButton(
|
cssRow(textButton(
|
||||||
t("Convert to trigger formula"),
|
t("Convert to trigger formula"),
|
||||||
dom.on("click", convertFormulaToTrigger),
|
dom.on("click", convertFormulaToTrigger),
|
||||||
|
330
app/client/ui/FloatingPopup.ts
Normal file
330
app/client/ui/FloatingPopup.ts
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||||
|
import {isNarrowScreen, isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
|
||||||
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {Disposable, dom, DomArg, DomContents, makeTestId, Observable, styled} from 'grainjs';
|
||||||
|
|
||||||
|
const POPUP_PADDING_PX = 16;
|
||||||
|
|
||||||
|
const testId = makeTestId('test-floating-popup-');
|
||||||
|
|
||||||
|
export interface PopupOptions {
|
||||||
|
title?: () => DomContents;
|
||||||
|
content?: () => DomContents;
|
||||||
|
onClose?: () => void;
|
||||||
|
closeButton?: boolean;
|
||||||
|
autoHeight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FloatingPopup extends Disposable {
|
||||||
|
protected _isMinimized = Observable.create(this, false);
|
||||||
|
private _popupElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
private _clientX: number;
|
||||||
|
private _clientY: number;
|
||||||
|
|
||||||
|
constructor(protected _options: PopupOptions = {}, private _args: DomArg[] = []) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this._handleMouseDown = this._handleMouseDown.bind(this);
|
||||||
|
this._handleMouseMove = this._handleMouseMove.bind(this);
|
||||||
|
this._handleMouseUp = this._handleMouseUp.bind(this);
|
||||||
|
this._handleTouchStart = this._handleTouchStart.bind(this);
|
||||||
|
this._handleTouchMove = this._handleTouchMove.bind(this);
|
||||||
|
this._handleTouchEnd = this._handleTouchEnd.bind(this);
|
||||||
|
this._handleWindowResize = this._handleWindowResize.bind(this);
|
||||||
|
|
||||||
|
this.autoDispose(isNarrowScreenObs().addListener(() => this._repositionPopup()));
|
||||||
|
|
||||||
|
this.onDispose(() => {
|
||||||
|
this._closePopup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public showPopup() {
|
||||||
|
this._popupElement = this._buildPopup();
|
||||||
|
document.body.appendChild(this._popupElement);
|
||||||
|
const topPaddingPx = getTopPopupPaddingPx();
|
||||||
|
const initialLeft = document.body.offsetWidth - this._popupElement.offsetWidth - POPUP_PADDING_PX;
|
||||||
|
const initialTop = document.body.offsetHeight - this._popupElement.offsetHeight - topPaddingPx;
|
||||||
|
this._popupElement.style.left = `${initialLeft}px`;
|
||||||
|
this._popupElement.style.top = `${initialTop}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _closePopup() {
|
||||||
|
if (!this._popupElement) { return; }
|
||||||
|
document.body.removeChild(this._popupElement);
|
||||||
|
dom.domDispose(this._popupElement);
|
||||||
|
this._popupElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _buildTitle(): DomContents {
|
||||||
|
return this._options.title?.() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _buildContent(): DomContents {
|
||||||
|
return this._options.content?.() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _buildArgs(): any {
|
||||||
|
return this._args;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMouseDown(ev: MouseEvent) {
|
||||||
|
if (ev.button !== 0) { return; } // Only handle left-click.
|
||||||
|
this._clientX = ev.clientX;
|
||||||
|
this._clientY = ev.clientY;
|
||||||
|
document.addEventListener('mousemove', this._handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', this._handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleTouchStart(ev: TouchEvent) {
|
||||||
|
this._clientX = ev.touches[0].clientX;
|
||||||
|
this._clientY = ev.touches[0].clientY;
|
||||||
|
document.addEventListener('touchmove', this._handleTouchMove);
|
||||||
|
document.addEventListener('touchend', this._handleTouchEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMouseMove({clientX, clientY}: MouseEvent) {
|
||||||
|
this._handleMove(clientX, clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleTouchMove({touches}: TouchEvent) {
|
||||||
|
this._handleMove(touches[0].clientX, touches[0].clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMove(clientX: number, clientY: number) {
|
||||||
|
const deltaX = clientX - this._clientX;
|
||||||
|
const deltaY = clientY - this._clientY;
|
||||||
|
let newLeft = this._popupElement!.offsetLeft + deltaX;
|
||||||
|
let newTop = this._popupElement!.offsetTop + deltaY;
|
||||||
|
|
||||||
|
const topPaddingPx = getTopPopupPaddingPx();
|
||||||
|
if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; }
|
||||||
|
if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; }
|
||||||
|
if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {
|
||||||
|
newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX;
|
||||||
|
}
|
||||||
|
if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) {
|
||||||
|
newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._popupElement!.style.left = `${newLeft}px`;
|
||||||
|
this._popupElement!.style.top = `${newTop}px`;
|
||||||
|
this._clientX = clientX;
|
||||||
|
this._clientY = clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMouseUp() {
|
||||||
|
document.removeEventListener('mousemove', this._handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', this._handleMouseUp);
|
||||||
|
document.body.removeEventListener('mouseleave', this._handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleTouchEnd() {
|
||||||
|
document.removeEventListener('touchmove', this._handleTouchMove);
|
||||||
|
document.removeEventListener('touchend', this._handleTouchEnd);
|
||||||
|
document.body.removeEventListener('touchcancel', this._handleTouchEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleWindowResize() {
|
||||||
|
this._repositionPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _repositionPopup() {
|
||||||
|
let newLeft = this._popupElement!.offsetLeft;
|
||||||
|
let newTop = this._popupElement!.offsetTop;
|
||||||
|
|
||||||
|
const topPaddingPx = getTopPopupPaddingPx();
|
||||||
|
if (newLeft - POPUP_PADDING_PX < 0) { newLeft = POPUP_PADDING_PX; }
|
||||||
|
if (newTop - topPaddingPx < 0) { newTop = topPaddingPx; }
|
||||||
|
if (newLeft + POPUP_PADDING_PX > document.body.offsetWidth - this._popupElement!.offsetWidth) {
|
||||||
|
newLeft = document.body.offsetWidth - this._popupElement!.offsetWidth - POPUP_PADDING_PX;
|
||||||
|
}
|
||||||
|
if (newTop + topPaddingPx > document.body.offsetHeight - this._popupElement!.offsetHeight) {
|
||||||
|
newTop = document.body.offsetHeight - this._popupElement!.offsetHeight - topPaddingPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._popupElement!.style.left = `${newLeft}px`;
|
||||||
|
this._popupElement!.style.top = `${newTop}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildPopup() {
|
||||||
|
const body = cssPopup(
|
||||||
|
{tabIndex: '-1'},
|
||||||
|
cssPopup.cls('-auto', this._options.autoHeight ?? false),
|
||||||
|
cssPopupHeader(
|
||||||
|
dom.domComputed(this._isMinimized, isMinimized => {
|
||||||
|
return [
|
||||||
|
// Copy buttons on the left side of the header, to automatically
|
||||||
|
// center the title.
|
||||||
|
cssPopupButtons(
|
||||||
|
!this._options.closeButton ? null : cssPopupHeaderButton(
|
||||||
|
icon('CrossSmall'),
|
||||||
|
),
|
||||||
|
cssPopupHeaderButton(
|
||||||
|
icon('Maximize')
|
||||||
|
),
|
||||||
|
dom.style('visibility', 'hidden'),
|
||||||
|
),
|
||||||
|
cssPopupTitle(
|
||||||
|
cssPopupTitleText(this._buildTitle()),
|
||||||
|
testId('title'),
|
||||||
|
),
|
||||||
|
cssPopupButtons(
|
||||||
|
!this._options.closeButton ? null : cssPopupHeaderButton(
|
||||||
|
icon('CrossSmall'),
|
||||||
|
dom.on('click', () => {
|
||||||
|
this._options.onClose?.() ?? this._closePopup();
|
||||||
|
}),
|
||||||
|
testId('close'),
|
||||||
|
),
|
||||||
|
cssPopupHeaderButton(
|
||||||
|
isMinimized ? icon('Maximize'): icon('Minimize'),
|
||||||
|
hoverTooltip(isMinimized ? 'Maximize' : 'Minimize', {key: 'docTutorialTooltip'}),
|
||||||
|
dom.on('click', () => {
|
||||||
|
this._isMinimized.set(!this._isMinimized.get());
|
||||||
|
this._repositionPopup();
|
||||||
|
}),
|
||||||
|
testId('minimize-maximize'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
dom.on('mousedown', this._handleMouseDown),
|
||||||
|
dom.on('touchstart', this._handleTouchStart),
|
||||||
|
testId('header'),
|
||||||
|
),
|
||||||
|
dom.maybe(use => !use(this._isMinimized), () => this._buildContent()),
|
||||||
|
() => { window.addEventListener('resize', this._handleWindowResize); },
|
||||||
|
dom.onDispose(() => {
|
||||||
|
document.removeEventListener('mousemove', this._handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', this._handleMouseUp);
|
||||||
|
document.removeEventListener('touchmove', this._handleTouchMove);
|
||||||
|
document.removeEventListener('touchend', this._handleTouchEnd);
|
||||||
|
window.removeEventListener('resize', this._handleWindowResize);
|
||||||
|
}),
|
||||||
|
cssPopup.cls('-minimized', this._isMinimized),
|
||||||
|
cssPopup.cls('-mobile', isNarrowScreenObs()),
|
||||||
|
testId('window'),
|
||||||
|
this._buildArgs()
|
||||||
|
);
|
||||||
|
|
||||||
|
// For auto-height popups, we need to reposition the popup when the content changes.
|
||||||
|
// It is important for auto-grow and to prevent popup from going off-screen.
|
||||||
|
if (this._options.autoHeight) {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
this._repositionPopup();
|
||||||
|
});
|
||||||
|
observer.observe(body, {childList: true, subtree: true});
|
||||||
|
dom.update(body,
|
||||||
|
dom.onDispose(() => observer.disconnect())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopPopupPaddingPx(): number {
|
||||||
|
// On mobile, we need additional padding to avoid blocking the top and bottom bars.
|
||||||
|
return POPUP_PADDING_PX + (isNarrowScreen() ? 50 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const POPUP_HEIGHT = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`;
|
||||||
|
const POPUP_HEIGHT_MOBILE = `min(711px, calc(100% - (2 * ${POPUP_PADDING_PX}px) - (2 * 50px)))`;
|
||||||
|
const POPUP_WIDTH = `min(436px, calc(100% - (2 * ${POPUP_PADDING_PX}px)))`;
|
||||||
|
|
||||||
|
const cssPopup = styled('div', `
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 2px solid ${theme.accentBorder};
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 999;
|
||||||
|
height: ${POPUP_HEIGHT};
|
||||||
|
width: ${POPUP_WIDTH};
|
||||||
|
background-color: ${theme.popupBg};
|
||||||
|
box-shadow: 0 2px 18px 0 ${theme.popupInnerShadow}, 0 0 1px 0 ${theme.popupOuterShadow};
|
||||||
|
outline: unset;
|
||||||
|
|
||||||
|
&-mobile {
|
||||||
|
height: ${POPUP_HEIGHT_MOBILE};
|
||||||
|
}
|
||||||
|
|
||||||
|
&-minimized {
|
||||||
|
max-width: 225px;
|
||||||
|
height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-minimized:not(&-mobile) {
|
||||||
|
max-height: ${POPUP_HEIGHT};
|
||||||
|
}
|
||||||
|
|
||||||
|
&-minimized&-mobile {
|
||||||
|
max-height: ${POPUP_HEIGHT_MOBILE};
|
||||||
|
}
|
||||||
|
|
||||||
|
&-auto {
|
||||||
|
height: auto;
|
||||||
|
max-height: ${POPUP_HEIGHT};
|
||||||
|
}
|
||||||
|
|
||||||
|
&-auto&-mobile {
|
||||||
|
max-height: ${POPUP_HEIGHT_MOBILE};
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPopupHeader = styled('div', `
|
||||||
|
color: ${theme.tutorialsPopupHeaderFg};
|
||||||
|
--icon-color: ${theme.tutorialsPopupHeaderFg};
|
||||||
|
background-color: ${theme.accentBorder};
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: grab;
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
height: 30px;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPopupButtons = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
column-gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPopupTitle = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPopupTitleText = styled('div', `
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const cssPopupBody = styled('div', `
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 24px;
|
||||||
|
overflow: auto;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPopupHeaderButton = styled('div', `
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: ${theme.hover};
|
||||||
|
}
|
||||||
|
`);
|
578
app/client/ui/FormulaAssistance.ts
Normal file
578
app/client/ui/FormulaAssistance.ts
Normal file
@ -0,0 +1,578 @@
|
|||||||
|
import * as AceEditor from 'app/client/components/AceEditor';
|
||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
|
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
|
||||||
|
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
|
||||||
|
import {FloatingPopup} from 'app/client/ui/FloatingPopup';
|
||||||
|
import {createUserImage} from 'app/client/ui/UserImage';
|
||||||
|
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
||||||
|
import {theme} from 'app/client/ui2018/cssVars';
|
||||||
|
import {cssTextInput, rawTextInput} from 'app/client/ui2018/editableLabel';
|
||||||
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
|
import {Suggestion} from 'app/common/AssistancePrompts';
|
||||||
|
import {Disposable, dom, makeTestId, MultiHolder, obsArray, Observable, styled} from 'grainjs';
|
||||||
|
import noop from 'lodash/noop';
|
||||||
|
|
||||||
|
const testId = makeTestId('test-assistant-');
|
||||||
|
|
||||||
|
export function buildAiButton(grist: GristDoc, column: ColumnRec) {
|
||||||
|
column = grist.docModel.columns.createFloatingRowModel(column.origColRef);
|
||||||
|
return textButton(
|
||||||
|
dom.autoDispose(column),
|
||||||
|
'Open AI assistant',
|
||||||
|
testId('open-button'),
|
||||||
|
dom.on('click', () => openAIAssistant(grist, column))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
grist: GristDoc;
|
||||||
|
column: ColumnRec;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFormula(owner: MultiHolder, props: Context) {
|
||||||
|
const { grist, column } = props;
|
||||||
|
const formula = Observable.create(owner, column.formula.peek());
|
||||||
|
const calcSize = (fullDom: HTMLElement, size: any) => {
|
||||||
|
return {
|
||||||
|
width: fullDom.clientWidth,
|
||||||
|
height: size.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const editor = AceEditor.create({
|
||||||
|
column,
|
||||||
|
gristDoc: grist,
|
||||||
|
calcSize,
|
||||||
|
editorState: formula,
|
||||||
|
});
|
||||||
|
owner.autoDispose(editor);
|
||||||
|
const buildDom = () => {
|
||||||
|
return cssFormulaWrapper(
|
||||||
|
dom.cls('formula_field_sidepane'),
|
||||||
|
editor.buildDom((aceObj: any) => {
|
||||||
|
aceObj.setFontSize(11);
|
||||||
|
aceObj.setHighlightActiveLine(false);
|
||||||
|
aceObj.getSession().setUseWrapMode(false);
|
||||||
|
aceObj.renderer.setPadding(0);
|
||||||
|
setTimeout(() => editor.editor.focus());
|
||||||
|
editor.setValue(formula.get());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
buildDom,
|
||||||
|
set(value: string) {
|
||||||
|
editor.setValue(value);
|
||||||
|
},
|
||||||
|
get() {
|
||||||
|
return editor.getValue();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildControls(
|
||||||
|
owner: MultiHolder,
|
||||||
|
props: Context & {
|
||||||
|
currentFormula: () => string;
|
||||||
|
savedClicked: () => void,
|
||||||
|
robotClicked: () => void,
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
|
||||||
|
const hasHistory = props.column.chatHistory.peek().get().length > 0;
|
||||||
|
|
||||||
|
// State variables, to show various parts of the UI.
|
||||||
|
const saveButtonVisible = Observable.create(owner, true);
|
||||||
|
const previewButtonVisible = Observable.create(owner, true);
|
||||||
|
const robotWellVisible = Observable.create(owner, !hasHistory);
|
||||||
|
const robotButtonVisible = Observable.create(owner, !hasHistory && !robotWellVisible.get());
|
||||||
|
const helpWellVisible = Observable.create(owner, !hasHistory);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Click handlers
|
||||||
|
const saveClicked = async () => {
|
||||||
|
await preview();
|
||||||
|
props.savedClicked();
|
||||||
|
};
|
||||||
|
const previewClicked = async () => await preview();
|
||||||
|
const robotClicked = () => props.robotClicked();
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
const preview = async () => {
|
||||||
|
// Currently we don't have a preview, so just save.
|
||||||
|
const formula = props.currentFormula();
|
||||||
|
const tableId = props.column.table.peek().tableId.peek();
|
||||||
|
const colId = props.column.colId.peek();
|
||||||
|
props.grist.docData.sendAction([
|
||||||
|
'ModifyColumn',
|
||||||
|
tableId,
|
||||||
|
colId,
|
||||||
|
{ formula, isFormula: true },
|
||||||
|
]).catch(reportError);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildWells = () => {
|
||||||
|
return cssContainer(
|
||||||
|
cssWell(
|
||||||
|
'Grist’s AI Formula Assistance. Need help? Our AI assistant can help. ',
|
||||||
|
textButton('Ask the bot.', dom.on('click', robotClicked)),
|
||||||
|
dom.show(robotWellVisible)
|
||||||
|
),
|
||||||
|
cssWell(
|
||||||
|
'Formula Help. See our Function List and Formula Cheat Sheet, or visit our Community for more help.',
|
||||||
|
dom.show(helpWellVisible)
|
||||||
|
),
|
||||||
|
dom.show(use => use(robotWellVisible) || use(helpWellVisible))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const buildDom = () => {
|
||||||
|
return [
|
||||||
|
cssButtonsWrapper(
|
||||||
|
cssButtons(
|
||||||
|
primaryButton('Save', dom.show(saveButtonVisible), dom.on('click', saveClicked)),
|
||||||
|
basicButton('Preview', dom.show(previewButtonVisible), dom.on('click', previewClicked)),
|
||||||
|
textButton('🤖', dom.show(robotButtonVisible), dom.on('click', robotClicked)),
|
||||||
|
dom.show(
|
||||||
|
use => use(previewButtonVisible) || use(saveButtonVisible) || use(robotButtonVisible)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
buildWells(),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
buildDom,
|
||||||
|
preview,
|
||||||
|
hideHelp() {
|
||||||
|
robotWellVisible.set(false);
|
||||||
|
helpWellVisible.set(false);
|
||||||
|
},
|
||||||
|
hideRobot() {
|
||||||
|
robotButtonVisible.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChat(owner: Disposable, context: Context & { formulaClicked: (formula: string) => void }) {
|
||||||
|
const { grist, column } = context;
|
||||||
|
|
||||||
|
const history = owner.autoDispose(obsArray(column.chatHistory.peek().get()));
|
||||||
|
const hasHistory = history.get().length > 0;
|
||||||
|
const enabled = Observable.create(owner, hasHistory);
|
||||||
|
const introVisible = Observable.create(owner, !hasHistory);
|
||||||
|
owner.autoDispose(history.addListener((cur) => {
|
||||||
|
column.chatHistory.peek().set([...cur]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
// Ask about suggestion, and send the whole history. Currently the chat is implemented by just sending
|
||||||
|
// all previous user prompts back to the AI. This is subject to change (and probably should be done in the backend).
|
||||||
|
const prompt = history.get().filter(x => x.sender === 'user')
|
||||||
|
.map(entry => entry.message)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
console.debug('prompt', prompt);
|
||||||
|
const { suggestedActions } = await askAI(grist, column, prompt);
|
||||||
|
console.debug('suggestedActions', suggestedActions);
|
||||||
|
const firstAction = suggestedActions[0] as any;
|
||||||
|
// Add the formula to the history.
|
||||||
|
const formula = firstAction[3].formula as string;
|
||||||
|
// Add to history
|
||||||
|
history.push({
|
||||||
|
message: formula,
|
||||||
|
sender: 'ai',
|
||||||
|
formula
|
||||||
|
});
|
||||||
|
return formula;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatEnterClicked = async (val: string) => {
|
||||||
|
if (!val) { return; }
|
||||||
|
// Hide intro.
|
||||||
|
introVisible.set(false);
|
||||||
|
// Add question to the history.
|
||||||
|
history.push({
|
||||||
|
message: val,
|
||||||
|
sender: 'user',
|
||||||
|
});
|
||||||
|
// Submit all questions to the AI.
|
||||||
|
context.formulaClicked(await submit());
|
||||||
|
};
|
||||||
|
|
||||||
|
const regenerateClick = async () => {
|
||||||
|
// Remove the last AI response from the history.
|
||||||
|
history.pop();
|
||||||
|
// And submit again.
|
||||||
|
context.formulaClicked(await submit());
|
||||||
|
};
|
||||||
|
|
||||||
|
const newChat = () => {
|
||||||
|
// Clear the history.
|
||||||
|
history.set([]);
|
||||||
|
// Show intro.
|
||||||
|
introVisible.set(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const userPrompt = Observable.create(owner, '');
|
||||||
|
|
||||||
|
const userImage = () => {
|
||||||
|
const user = grist.app.topAppModel.appObs.get()?.currentUser || null;
|
||||||
|
if (user) {
|
||||||
|
return (createUserImage(user, 'medium'));
|
||||||
|
} else {
|
||||||
|
// TODO: this will not happen, as this should be only for logged in users.
|
||||||
|
return (dom('div', ''));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildHistory = () => {
|
||||||
|
return cssVBox(
|
||||||
|
dom.forEach(history, entry => {
|
||||||
|
if (entry.sender === 'user') {
|
||||||
|
return cssMessage(
|
||||||
|
cssAvatar(userImage()),
|
||||||
|
dom.text(entry.message),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return cssAiMessage(
|
||||||
|
cssAvatar(cssAiImage()),
|
||||||
|
buildHighlightedCode(entry.message, { maxLines: 10 }, cssCodeStyles.cls('')),
|
||||||
|
cssCopyIconWrapper(
|
||||||
|
icon('Copy', dom.on('click', () => context.formulaClicked(entry.message))),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildIntro = () => {
|
||||||
|
return cssVBox(
|
||||||
|
dom.cls(cssTopGreenBorder.className),
|
||||||
|
dom.cls(cssTypography.className),
|
||||||
|
dom.style('flex-grow', '1'),
|
||||||
|
dom.style('min-height', '0'),
|
||||||
|
dom.style('overflow-y', 'auto'),
|
||||||
|
dom.maybe(introVisible, () =>
|
||||||
|
cssHContainer(
|
||||||
|
dom.style('margin-bottom', '10px'),
|
||||||
|
cssVBox(
|
||||||
|
dom('h4', 'Grist’s AI Assistance'),
|
||||||
|
dom('h5', 'Tips'),
|
||||||
|
cssWell(
|
||||||
|
'“Example prompt” Some instructions for how to draft a prompt. A link to even more examples in support.'
|
||||||
|
),
|
||||||
|
cssWell(
|
||||||
|
'Example Values. Instruction about entering example values in the column, maybe with an image?'
|
||||||
|
),
|
||||||
|
dom('h5', 'Capabilities'),
|
||||||
|
cssWell(
|
||||||
|
'Formula Assistance Only. Python code. Spreadsheet functions? May sometimes get it wrong. '
|
||||||
|
),
|
||||||
|
cssWell('Conversational. Remembers what was said and allows follow-up corrections.'),
|
||||||
|
dom('h5', 'Data'),
|
||||||
|
cssWell(
|
||||||
|
'Data Usage. Something about how we can see prompts to improve the feature and product, but cannot see doc.'
|
||||||
|
),
|
||||||
|
cssWell(
|
||||||
|
'Data Sharing. Something about OpenAI, what’s being transmitted. How does it expose doc data?'
|
||||||
|
),
|
||||||
|
textButton('Learn more', dom.style('align-self', 'flex-start'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dom.maybe(
|
||||||
|
use => !use(introVisible),
|
||||||
|
() => buildHistory()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildButtons = () => {
|
||||||
|
// We will show buttons only if we have a history.
|
||||||
|
return dom.maybe(use => use(history).length > 0, () => cssVContainer(
|
||||||
|
cssHBox(
|
||||||
|
cssPlainButton(icon('Script'), 'New Chat', dom.on('click', newChat)),
|
||||||
|
cssPlainButton(icon('Revert'), 'Regenerate', dom.on('click', regenerateClick), dom.style('margin-left', '8px')),
|
||||||
|
),
|
||||||
|
dom.style('padding-bottom', '0'),
|
||||||
|
dom.style('padding-top', '12px'),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildInput = () => {
|
||||||
|
return cssHContainer(
|
||||||
|
dom.cls(cssTopBorder.className),
|
||||||
|
dom.cls(cssVSpace.className),
|
||||||
|
cssInputWrapper(
|
||||||
|
dom.cls(cssTextInput.className),
|
||||||
|
dom.cls(cssTypography.className),
|
||||||
|
rawTextInput(userPrompt, chatEnterClicked, noop),
|
||||||
|
icon('FieldAny')
|
||||||
|
),
|
||||||
|
buildButtons()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDom = () => {
|
||||||
|
return dom.maybe(enabled, () => cssVFullBox(
|
||||||
|
buildIntro(),
|
||||||
|
cssSpacer(),
|
||||||
|
buildInput(),
|
||||||
|
dom.style('overflow', 'hidden'),
|
||||||
|
dom.style('flex-grow', '1')
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildDom,
|
||||||
|
show() {
|
||||||
|
enabled.set(true);
|
||||||
|
introVisible.set(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and opens up a Formula Popup with an AI assistant.
|
||||||
|
*/
|
||||||
|
function openAIAssistant(grist: GristDoc, column: ColumnRec) {
|
||||||
|
const owner = MultiHolder.create(null);
|
||||||
|
const props: Context = { grist, column };
|
||||||
|
|
||||||
|
// Build up all components, and wire up them to each other.
|
||||||
|
|
||||||
|
// First is the formula editor displayed in the upper part of the popup.
|
||||||
|
const formulaEditor = buildFormula(owner, props);
|
||||||
|
|
||||||
|
// Next are the buttons in the middle. It has a Save, Preview, and Robot button, and probably some wells
|
||||||
|
// with tips or other buttons.
|
||||||
|
const controls = buildControls(owner, {
|
||||||
|
...props,
|
||||||
|
// Pass a formula accessor, it is used to get the current formula and apply or preview it.
|
||||||
|
currentFormula: () => formulaEditor.get(),
|
||||||
|
// Event or saving, we listen to it to close the popup.
|
||||||
|
savedClicked() {
|
||||||
|
grist.formulaPopup.clear();
|
||||||
|
},
|
||||||
|
// Handler for robot icon click. We hide the robot icon and the help, and show the chat area.
|
||||||
|
robotClicked() {
|
||||||
|
chat.show();
|
||||||
|
controls.hideHelp();
|
||||||
|
controls.hideRobot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now, the chat area. It has a history of previous questions, and a prompt for the user to ask a new
|
||||||
|
// question.
|
||||||
|
const chat = buildChat(owner, {...props,
|
||||||
|
// When a formula is clicked (or just was returned from the AI), we set it in the formula editor and hit
|
||||||
|
// the preview button.
|
||||||
|
formulaClicked: (formula: string) => {
|
||||||
|
formulaEditor.set(formula);
|
||||||
|
controls.preview().catch(reportError);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = `${column.table.peek().tableNameDef.peek()}.${column.label.peek()}`;
|
||||||
|
const popup = FloatingPopup.create(null, {
|
||||||
|
title: () => header,
|
||||||
|
content: () => [
|
||||||
|
formulaEditor.buildDom(),
|
||||||
|
controls.buildDom(),
|
||||||
|
chat.buildDom(),
|
||||||
|
],
|
||||||
|
onClose: () => grist.formulaPopup.clear(),
|
||||||
|
closeButton: true,
|
||||||
|
autoHeight: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
popup.autoDispose(owner);
|
||||||
|
popup.showPopup();
|
||||||
|
|
||||||
|
// Add this popup to the main holder (and dispose the previous one).
|
||||||
|
grist.formulaPopup.autoDispose(popup);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askAI(grist: GristDoc, column: ColumnRec, description: string): Promise<Suggestion> {
|
||||||
|
const tableId = column.table.peek().tableId.peek();
|
||||||
|
const colId = column.colId.peek();
|
||||||
|
try {
|
||||||
|
const result = await grist.docComm.getAssistance({tableId, colId, description});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cssVBox = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssFormulaWrapper = styled('div.formula_field_edit.formula_editor', `
|
||||||
|
position: relative;
|
||||||
|
padding: 5px 0 5px 24px;
|
||||||
|
flex: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVFullBox = styled(cssVBox, `
|
||||||
|
flex-grow: 1;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssHBox = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVSpace = styled('div', `
|
||||||
|
padding-top: 18px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssHContainer = styled('div', `
|
||||||
|
padding-left: 18px;
|
||||||
|
padding-right: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssButtonsWrapper = styled(cssHContainer, `
|
||||||
|
padding-top: 0;
|
||||||
|
background-color: ${theme.formulaEditorBg};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssVContainer = styled('div', `
|
||||||
|
padding-top: 18px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssContainer = styled('div', `
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssSpacer = styled('div', `
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssButtons = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssWell = styled('div', `
|
||||||
|
padding: 8px;
|
||||||
|
color: ${theme.inputFg};
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: ${theme.rightPanelBg};
|
||||||
|
& + & {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssMessage = styled('div', `
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr;
|
||||||
|
padding-right: 54px;
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssAiMessage = styled('div', `
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr 54px;
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
background: #D9D9D94f;
|
||||||
|
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssCodeStyles = styled('div', `
|
||||||
|
background: #E3E3E3;
|
||||||
|
border: none;
|
||||||
|
& .ace-chrome {
|
||||||
|
background: #E3E3E3;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssAvatar = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssAiImage = styled('div', `
|
||||||
|
flex: none;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: white;
|
||||||
|
background-image: var(--icon-GristLogo);
|
||||||
|
background-size: 22px 22px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTopGreenBorder = styled('div', `
|
||||||
|
border-top: 1px solid ${theme.accentBorder};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTypography = styled('div', `
|
||||||
|
color: ${theme.inputFg};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTopBorder = styled('div', `
|
||||||
|
border-top: 1px solid ${theme.inputBorder};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssCopyIconWrapper = styled('div', `
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
cursor: pointer;
|
||||||
|
.${cssAiMessage.className}:hover & {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssInputWrapper = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
background-color: ${theme.mainPanelBg};
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: 8px !important;
|
||||||
|
--icon-color: ${theme.controlSecondaryFg};
|
||||||
|
&:hover, &:focus-within {
|
||||||
|
--icon-color: ${theme.accentIcon};
|
||||||
|
}
|
||||||
|
& > input {
|
||||||
|
outline: none;
|
||||||
|
padding: 0px;
|
||||||
|
align-self: stretch;
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssPlainButton = styled(basicButton, `
|
||||||
|
border-color: ${theme.inputBorder};
|
||||||
|
color: ${theme.controlSecondaryFg};
|
||||||
|
--icon-color: ${theme.controlSecondaryFg};
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
padding-right: 13px;
|
||||||
|
`);
|
@ -62,7 +62,7 @@ const cssSizer = styled('div', `
|
|||||||
|
|
||||||
enum Status { NORMAL, EDITING, SAVING }
|
enum Status { NORMAL, EDITING, SAVING }
|
||||||
|
|
||||||
type SaveFunc = (value: string) => Promise<void>;
|
type SaveFunc = (value: string) => void|PromiseLike<void>;
|
||||||
|
|
||||||
export interface EditableLabelOptions {
|
export interface EditableLabelOptions {
|
||||||
save: SaveFunc;
|
save: SaveFunc;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {ActionGroup} from 'app/common/ActionGroup';
|
import {ActionGroup} from 'app/common/ActionGroup';
|
||||||
|
import {Prompt, Suggestion} from 'app/common/AssistancePrompts';
|
||||||
import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
|
import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
|
||||||
import {FormulaProperties} from 'app/common/GranularAccessClause';
|
import {FormulaProperties} from 'app/common/GranularAccessClause';
|
||||||
import {UIRowId} from 'app/common/UIRowId';
|
import {UIRowId} from 'app/common/UIRowId';
|
||||||
@ -322,7 +323,7 @@ export interface ActiveDocAPI {
|
|||||||
/**
|
/**
|
||||||
* Generates a formula code based on the AI suggestions, it also modifies the column and sets it type to a formula.
|
* Generates a formula code based on the AI suggestions, it also modifies the column and sets it type to a formula.
|
||||||
*/
|
*/
|
||||||
getAssistance(tableId: string, colId: string, description: string): Promise<void>;
|
getAssistance(userPrompt: Prompt): Promise<Suggestion>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch content at a url.
|
* Fetch content at a url.
|
||||||
|
@ -583,6 +583,9 @@ export interface GristLoadConfig {
|
|||||||
// TODO: remove when comments will be released.
|
// TODO: remove when comments will be released.
|
||||||
featureComments?: boolean;
|
featureComments?: boolean;
|
||||||
|
|
||||||
|
// TODO: remove once released.
|
||||||
|
featureFormulaAssistant?: boolean;
|
||||||
|
|
||||||
// Email address of the support user.
|
// Email address of the support user.
|
||||||
supportEmail?: string;
|
supportEmail?: string;
|
||||||
|
|
||||||
|
@ -11,10 +11,13 @@ export const DEPS = { fetch };
|
|||||||
export async function sendForCompletion(prompt: string): Promise<string> {
|
export async function sendForCompletion(prompt: string): Promise<string> {
|
||||||
let completion: string|null = null;
|
let completion: string|null = null;
|
||||||
let retries: number = 0;
|
let retries: number = 0;
|
||||||
|
const openApiKey = process.env.OPENAI_API_KEY;
|
||||||
|
const model = process.env.COMPLETION_MODEL || "text-davinci-002";
|
||||||
|
|
||||||
while(retries++ < 3) {
|
while(retries++ < 3) {
|
||||||
try {
|
try {
|
||||||
if (process.env.OPENAI_API_KEY) {
|
if (openApiKey) {
|
||||||
completion = await sendForCompletionOpenAI(prompt);
|
completion = await sendForCompletionOpenAI(prompt, openApiKey, model);
|
||||||
}
|
}
|
||||||
if (process.env.HUGGINGFACE_API_KEY) {
|
if (process.env.HUGGINGFACE_API_KEY) {
|
||||||
completion = await sendForCompletionHuggingFace(prompt);
|
completion = await sendForCompletionHuggingFace(prompt);
|
||||||
@ -33,8 +36,7 @@ export async function sendForCompletion(prompt: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function sendForCompletionOpenAI(prompt: string) {
|
async function sendForCompletionOpenAI(prompt: string, apiKey: string, model = "text-davinci-002") {
|
||||||
const apiKey = process.env.OPENAI_API_KEY;
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("OPENAI_API_KEY not set");
|
throw new Error("OPENAI_API_KEY not set");
|
||||||
}
|
}
|
||||||
@ -51,7 +53,7 @@ async function sendForCompletionOpenAI(prompt: string) {
|
|||||||
max_tokens: 150,
|
max_tokens: 150,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
// COMPLETION_MODEL of `code-davinci-002` may be better if you have access to it.
|
// COMPLETION_MODEL of `code-davinci-002` may be better if you have access to it.
|
||||||
model: process.env.COMPLETION_MODEL || "text-davinci-002",
|
model,
|
||||||
stop: ["\n\n"],
|
stop: ["\n\n"],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {getPageTitleSuffix, GristLoadConfig, HideableUiElements, IHideableUiElement} from 'app/common/gristUrls';
|
import {getPageTitleSuffix, GristLoadConfig, HideableUiElements, IHideableUiElement} from 'app/common/gristUrls';
|
||||||
|
import {isAffirmative} from 'app/common/gutil';
|
||||||
import {getTagManagerSnippet} from 'app/common/tagManager';
|
import {getTagManagerSnippet} from 'app/common/tagManager';
|
||||||
import {Document} from 'app/common/UserAPI';
|
import {Document} from 'app/common/UserAPI';
|
||||||
import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager';
|
import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager';
|
||||||
@ -60,10 +61,11 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
|
|||||||
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
|
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
|
||||||
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
|
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
|
||||||
activation: getActivation(req as RequestWithLogin | undefined),
|
activation: getActivation(req as RequestWithLogin | undefined),
|
||||||
enableCustomCss: process.env.APP_STATIC_INCLUDE_CUSTOM_CSS === 'true',
|
enableCustomCss: isAffirmative(process.env.APP_STATIC_INCLUDE_CUSTOM_CSS),
|
||||||
supportedLngs: readLoadedLngs(req?.i18n),
|
supportedLngs: readLoadedLngs(req?.i18n),
|
||||||
namespaces: readLoadedNamespaces(req?.i18n),
|
namespaces: readLoadedNamespaces(req?.i18n),
|
||||||
featureComments: process.env.COMMENTS === "true",
|
featureComments: isAffirmative(process.env.COMMENTS),
|
||||||
|
featureFormulaAssistant: isAffirmative(process.env.GRIST_FORMULA_ASSISTANT),
|
||||||
supportEmail: SUPPORT_EMAIL,
|
supportEmail: SUPPORT_EMAIL,
|
||||||
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
|
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
|
||||||
...extra,
|
...extra,
|
||||||
|
@ -55,7 +55,7 @@ describe('DocTutorial', function () {
|
|||||||
|
|
||||||
it('shows a popup containing slides generated from the GristDocTutorial table', async function() {
|
it('shows a popup containing slides generated from the GristDocTutorial table', async function() {
|
||||||
assert.isTrue(await driver.findWait('.test-doc-tutorial-popup', 2000).isDisplayed());
|
assert.isTrue(await driver.findWait('.test-doc-tutorial-popup', 2000).isDisplayed());
|
||||||
assert.equal(await driver.find('.test-doc-tutorial-popup-title').getText(), 'DocTutorial');
|
assert.equal(await driver.find('.test-floating-popup-header').getText(), 'DocTutorial');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await driver.findWait('.test-doc-tutorial-popup h1', 2000).getText(),
|
await driver.findWait('.test-doc-tutorial-popup h1', 2000).getText(),
|
||||||
'Intro'
|
'Intro'
|
||||||
@ -172,12 +172,12 @@ describe('DocTutorial', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('can be minimized and maximized', async function() {
|
it('can be minimized and maximized', async function() {
|
||||||
await driver.find('.test-doc-tutorial-popup-minimize-maximize').click();
|
await driver.find('.test-floating-popup-minimize-maximize').click();
|
||||||
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
|
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
|
||||||
assert.isFalse(await driver.find('.test-doc-tutorial-popup-body').isPresent());
|
assert.isFalse(await driver.find('.test-doc-tutorial-popup-body').isPresent());
|
||||||
assert.isFalse(await driver.find('.test-doc-tutorial-popup-footer').isPresent());
|
assert.isFalse(await driver.find('.test-doc-tutorial-popup-footer').isPresent());
|
||||||
|
|
||||||
await driver.find('.test-doc-tutorial-popup-minimize-maximize').click();
|
await driver.find('.test-floating-popup-minimize-maximize').click();
|
||||||
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
|
assert.isTrue(await driver.find('.test-doc-tutorial-popup-header').isDisplayed());
|
||||||
assert.isTrue(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
|
assert.isTrue(await driver.find('.test-doc-tutorial-popup-body').isDisplayed());
|
||||||
assert.isTrue(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
|
assert.isTrue(await driver.find('.test-doc-tutorial-popup-footer').isDisplayed());
|
||||||
@ -262,7 +262,7 @@ describe('DocTutorial', function () {
|
|||||||
assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Zane Rails']);
|
assert.deepEqual(await gu.getVisibleGridCells({cols: [0], rowNums: [1]}), ['Zane Rails']);
|
||||||
|
|
||||||
// Check that changes made to the tutorial since the last fork are included.
|
// Check that changes made to the tutorial since the last fork are included.
|
||||||
assert.equal(await driver.find('.test-doc-tutorial-popup-title').getText(),
|
assert.equal(await driver.find('.test-doc-tutorial-popup-header').getText(),
|
||||||
'DocTutorial V2');
|
'DocTutorial V2');
|
||||||
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'NewTable']);
|
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'NewTable']);
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user