Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing UI for external attachments #1348

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion app/client/lib/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { sanitizeHTML } from 'app/client/ui/sanitizeHTML';
import { BindableValue, DomElementMethod, subscribeElem } from 'grainjs';
import {theme} from 'app/client/ui2018/cssVars';
import { BindableValue, DomElementMethod, IDomArgs, styled, subscribeElem } from 'grainjs';
import { marked } from 'marked';

/**
Expand All @@ -24,6 +25,34 @@ export function markdown(markdownObs: BindableValue<string>): DomElementMethod {
return elem => subscribeElem(elem, markdownObs, value => setMarkdownValue(elem, value));
}

/**
* HTML span element that creates a span element with the given markdown string, without
* any surrounding paragraph tags. This is useful when you want to include markdown inside
* a larger element as a single line.
*/
export function cssMarkdownSpan(
markdownObs: BindableValue<string>,
...args: IDomArgs<HTMLSpanElement>
): HTMLSpanElement {
return cssMarkdownLine(markdown(markdownObs), ...args);
}

const cssMarkdownLine = styled('span', `
& p {
margin: 0;
}
& a {
color: ${theme.link};
--icon-color: ${theme.link};
text-decoration: none;
}
& a:hover, & a:focus {
color: ${theme.linkHover};
--icon-color: ${theme.linkHover};
text-decoration: underline;
}
`);

function setMarkdownValue(elem: Element, markdownValue: string): void {
elem.innerHTML = sanitizeHTML(marked(markdownValue, {async: false}));
}
25 changes: 24 additions & 1 deletion app/client/models/entities/DocInfoRec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {DocModel, IRowModel} from 'app/client/models/DocModel';
import * as modelUtil from 'app/client/models/modelUtil';
import {jsonObservable} from 'app/client/models/modelUtil';
import {jsonObservable, savingComputed} from 'app/client/models/modelUtil';
import {DocumentSettings} from 'app/common/DocumentSettings';
import * as ko from 'knockout';

Expand All @@ -9,6 +9,10 @@ export interface DocInfoRec extends IRowModel<"_grist_DocInfo"> {
documentSettingsJson: modelUtil.SaveableObjObservable<DocumentSettings>
defaultViewId: ko.Computed<number>;
newDefaultViewId: ko.Computed<number>;
attachmentStorage: modelUtil.KoSaveableObservable<'internal'|'external'>;
attachmentTransfer: ko.Observable<'done'|'not-started'|'in-progress'|'failed'>;

beginTransfer(): Promise<void>;
}

export function createDocInfoRec(this: DocInfoRec, docModel: DocModel): void {
Expand All @@ -21,4 +25,23 @@ export function createDocInfoRec(this: DocInfoRec, docModel: DocModel): void {
const page = docModel.visibleDocPages()[0];
return page ? page.viewRef() : 0;
}));

const storage = this.autoDispose(ko.observable('internal') as any);

this.attachmentStorage = savingComputed({
read: () => storage(),
write: (setter, val) => storage(val),
});

this.attachmentTransfer = this.autoDispose(ko.observable('done') as any);

this.autoDispose(this.attachmentStorage.subscribe((newVal) => {
this.attachmentTransfer('not-started');
}));

this.beginTransfer = async () => {
this.attachmentTransfer('in-progress');
const timeout = setTimeout(() => this.attachmentTransfer('done'), 3000);
this.autoDisposeCallback(() => clearTimeout(timeout));
};
}
11 changes: 4 additions & 7 deletions app/client/ui/AdminPanelCss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function AdminSectionItem(owner: IDisposableOwner, options: {
options.name,
testId(`admin-panel-item-name-${options.id}`),
prefix.length ? cssItemName.cls('-prefixed') : null,
cssItemName.cls('-full', options.description === undefined),
),
cssItemDescription(options.description),
cssItemValue(options.value,
Expand Down Expand Up @@ -72,7 +73,7 @@ export function AdminSectionItem(owner: IDisposableOwner, options: {

const cssSection = styled('div', `
padding: 24px;
max-width: 600px;
max-width: 700px;
width: 100%;
margin: 16px auto;
border: 1px solid ${theme.widgetBorder};
Expand Down Expand Up @@ -137,15 +138,11 @@ const cssItemName = styled('div', `
align-items: center;
margin-right: 14px;
font-size: ${vars.largeFontSize};
padding-left: 24px;
&-prefixed {
padding-left: 0;
}

@container line (max-width: 500px) {
& {
padding-left: 0;
}
&-full {
width: unset;
}
@media ${mediaSmall} {
& {
Expand Down
149 changes: 144 additions & 5 deletions app/client/ui/DocumentSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import {ACIndexImpl} from 'app/client/lib/ACIndex';
import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect';
import {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {makeT} from 'app/client/lib/localization';
import {cssMarkdownSpan} from 'app/client/lib/markdown';
import {reportError} from 'app/client/models/AppModel';
import type {DocPageModel} from 'app/client/models/DocPageModel';
import {urlState} from 'app/client/models/gristUrlState';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {AdminSection, AdminSectionItem} from 'app/client/ui/AdminPanelCss';
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
import {hoverTooltip, showTransientTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox';
import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars';
Expand Down Expand Up @@ -61,6 +62,29 @@ export class DocSettingsPage extends Disposable {
const isTimingOn = this._gristDoc.isTimingOn;
const isDocOwner = isOwner(docPageModel.currentDoc.get());
const isDocEditor = isOwnerOrEditor(docPageModel.currentDoc.get());
const storage = Computed.create(this, use => {
return use(this._gristDoc.docInfo.attachmentStorage);
});
storage.onWrite(async (val) => {
await this._gristDoc.docInfo.attachmentStorage.setAndSave(val);
});
const options = [{value: 'internal', label: 'Internal'}, {value: 'external', label: 'External'}];


const inProgress = Computed.create(this, use => use(this._gristDoc.docInfo.attachmentTransfer) === 'in-progress');
const notStarted = Computed.create(this, use => use(this._gristDoc.docInfo.attachmentTransfer) === 'not-started');

const stillInternal = Computed.create(this, use => {
const isExternal = use(storage) === 'external';
return isExternal && (use(inProgress) || use(notStarted));
});

const stillExternal = Computed.create(this, use => {
const isInternal = use(storage) === 'internal';
return isInternal && (use(inProgress) || use(notStarted));
});

(window as any).storage = storage;

return cssContainer(
dom.create(AdminSection, t('Document Settings'), [
Expand Down Expand Up @@ -194,9 +218,45 @@ export class DocSettingsPage extends Disposable {
value: cssSmallLinkButton(t('Manage webhooks'), urlState().setLinkUrl({docPage: 'webhook'})),
}),
]),

dom.create(AdminSection, t('Attachment storage'), [
dom.create(AdminSectionItem, {
id: 'preferredStorage',
name: withInfoTooltip(
dom('span', t('Preferred storage for this document')),
'attachmentStorage',
),
value: cssFlex(
dom.maybe(notStarted, () => [
cssButton(t('Start transfer'), dom.on('click', () => this._beginTransfer())),
]),
dom.maybe(inProgress, () => [
cssButton(
cssLoadingSpinner(
loadingSpinner.cls('-inline'),
cssLoadingSpinner.cls('-disabled'),
),
t('Being transfer'),
dom.prop('disabled', true),
),
]),
cssSmallSelect(storage, options, {
disabled: inProgress,
}),
)
}),
dom('div',
dom.maybe(stillInternal, () => stillInternalCopy(inProgress)),
dom.maybe(stillExternal, () => stillExternalCopy(inProgress)),
),
]),
);
}

private async _beginTransfer() {
this._gristDoc.docInfo.beginTransfer().catch(reportError);
}

private async _reloadEngine(ask = true) {
const docPageModel = this._gristDoc.docPageModel;
const handler = async () => {
Expand Down Expand Up @@ -343,6 +403,59 @@ function buildLocaleSelect(
);
}


const learnMore = () => t(
'[learn more]({{learnLink}})',
{learnLink: commonUrls.attachmentStorage}
);

function stillExternalCopy(inProgress: Observable<boolean>) {
const someExternal = () => t(
'**Some existing attachments are still [external]({{externalLink}})**.',
{externalLink: commonUrls.attachmentStorage}
);

const startToInternal = () => t(
'Click "Start transfer" to transfer those to Internal storage (stored in the document SQLite file).'
);

const newInInternal = () => t(
'Newly uploaded attachments will be placed in Internal storage.'
);

return dom.domComputed(inProgress, (yes) => {
if (yes) {
return cssMarkdownSpan(`${someExternal()} ${newInInternal()} ${learnMore()}`);
} else {
return cssMarkdownSpan(`${someExternal()} ${startToInternal()} ${newInInternal()} ${learnMore()}`);
}
});
}

function stillInternalCopy(inProgress: Observable<boolean>) {
const someInternal = () => t(
'**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).',
{internalLink: commonUrls.attachmentStorage}
);

const startToExternal = () => t(
'Click "Start transfer" to transfer those to External storage.'
);

const newInExternal = () => t(
'Newly uploaded attachments will be placed in External storage.'
);

return dom.domComputed(inProgress, (yes) => {
if (yes) {
return cssMarkdownSpan(`${someInternal()} ${newInExternal()} ${learnMore()}`);
} else {
return cssMarkdownSpan(`${someInternal()} ${startToExternal()} ${newInExternal()} ${learnMore()}`);
}
});
}


const cssContainer = styled('div', `
overflow-y: auto;
position: relative;
Expand Down Expand Up @@ -413,10 +526,6 @@ export function getSupportedEngineChoices(): EngineCode[] {
return gristConfig.supportEngines || [];
}

const cssSelect = styled(select, `
min-width: 170px; /* to match the width of the timezone picker */
`);

const TOOLTIP_KEY = 'copy-on-settings';


Expand Down Expand Up @@ -509,3 +618,33 @@ const cssWrap = styled('p', `
const cssRedText = styled('span', `
color: ${theme.errorText};
`);

const cssFlex = styled('div', `
display: flex;
align-items: center;
gap: 8px;
`);

const cssButton = styled(cssSmallButton, `
white-space: nowrap;
`);

const cssSmallSelect = styled(select, `
width: 100px;
`);

const cssSelect = styled(select, `
min-width: 170px; /* to match the width of the timezone picker */
`);

const cssLoadingSpinner = styled(loadingSpinner, `
&-disabled {
--loader-bg: ${theme.loaderBg};
--loader-fg: white;
}
@media (prefers-color-scheme: dark) {
&-disabled {
--loader-bg: #adadad;
}
}
`);
14 changes: 13 additions & 1 deletion app/client/ui/GristTooltips.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as commands from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {cssMarkdownSpan} from 'app/client/lib/markdown';
import {buildHighlightedCode} from 'app/client/ui/CodeHighlight';
import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey';
import {icon} from 'app/client/ui2018/icons';
Expand Down Expand Up @@ -46,7 +47,8 @@ export type Tooltip =
| 'communityWidgets'
| 'twoWayReferences'
| 'twoWayReferencesDisabled'
| 'reasignTwoWayReference';
| 'reasignTwoWayReference'
| 'attachmentStorage';

export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;

Expand Down Expand Up @@ -187,6 +189,16 @@ see or edit which parts of your document.')
),
...args,
),
attachmentStorage: (...args: DomElementArg[]) => cssTooltipContent(
cssMarkdownSpan(t(
"Internal storage means all attachments are stored in the document SQLite file, " +
"while external storage indicates all attachments are stored in the same " +
"external storage. [Learn more]({{link}}).", {
link: commonUrls.attachmentStorage
}
)),
...args,
),
};

export interface BehavioralPromptContent {
Expand Down
11 changes: 7 additions & 4 deletions app/client/ui/sanitizeHTML.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ export function sanitizeTutorialHTML(source: string | Node): string {
}

const defaultPurifier = createDOMPurifier();
defaultPurifier.addHook('uponSanitizeAttribute', handleSanitizeAttribute);

const tutorialPurifier = createDOMPurifier();
tutorialPurifier.addHook('uponSanitizeAttribute', handleSanitizeAttribute);
tutorialPurifier.addHook('uponSanitizeElement', handleSanitizeTutorialElement);

// If we are executed in a browser, we can add hooks to the purifiers to customize their behavior.
if (typeof window !== 'undefined') {
defaultPurifier.addHook('uponSanitizeAttribute', handleSanitizeAttribute);
tutorialPurifier.addHook('uponSanitizeAttribute', handleSanitizeAttribute);
tutorialPurifier.addHook('uponSanitizeElement', handleSanitizeTutorialElement);
}

function handleSanitizeAttribute(node: Element) {
if (!('target' in node)) { return; }
Expand Down
2 changes: 2 additions & 0 deletions app/client/ui/tooltips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ const cssInfoTooltip = styled('div', `
display: flex;
align-items: center;
column-gap: 8px;
font-weight: unset;
`);

const cssTooltipCorner = styled('div', `
Expand Down Expand Up @@ -580,6 +581,7 @@ const cssInfoTooltipIcon = styled('div', `
color: ${theme.controlSecondaryFg};
border-radius: 50%;
user-select: none;
font-weight: initial;

.${cssMenuItem.className}-sel & {
color: ${theme.menuItemSelectedFg};
Expand Down
Loading
Loading