Skip to content

Commit

Permalink
Implementing UI for external attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
berhalak committed Dec 19, 2024
1 parent 81e052e commit 4c53590
Show file tree
Hide file tree
Showing 8 changed files with 229 additions and 17 deletions.
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
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
13 changes: 11 additions & 2 deletions app/client/ui2018/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,23 @@ const flash = keyframes(`
* Creates a 32x32 pixel loading spinner. Use by calling `loadingSpinner()`.
*/
export const loadingSpinner = styled('div', `
--loader-fg: ${theme.loaderFg};
--loader-bg: ${theme.loaderBg};
display: inline-block;
box-sizing: border-box;
width: 32px;
height: 32px;
border-radius: 32px;
border: 4px solid ${theme.loaderBg};
border-top-color: ${theme.loaderFg};
border: 4px solid var(--loader-bg);
border-top-color: var(--loader-fg);
animation: ${rotate360} 1s ease-out infinite;
&-inline {
width: 1em;
height: 1em;
line-height: inherit;
border-radius: 50%;
border-width: 1px;
}
`);

/**
Expand Down
Loading

0 comments on commit 4c53590

Please sign in to comment.