first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import ChatPane from '../js/features/chat/components/chat-pane'
import useFetchMock from './hooks/use-fetch-mock'
import { generateMessages } from './fixtures/chat-messages'
import { ScopeDecorator } from './decorators/scope'
import { UserProvider } from '@/shared/context/user-context'
export const Conversation = args => {
useFetchMock(fetchMock => {
fetchMock.get(/messages/, generateMessages(35)).post(/messages/, {})
})
return <ChatPane {...args} />
}
export const NoMessages = args => {
useFetchMock(fetchMock => {
fetchMock.get(/messages/, [])
})
return <ChatPane {...args} />
}
export const Loading = args => {
useFetchMock(fetchMock => {
fetchMock.get(/messages/, generateMessages(6), {
delay: 1000 * 10,
})
})
return <ChatPane {...args} />
}
export const LoadingError = args => {
useFetchMock(fetchMock => {
fetchMock.get(/messages/, 500)
})
return <ChatPane {...args} />
}
export default {
title: 'Editor / Chat',
component: ChatPane,
argTypes: {
resetUnreadMessages: { action: 'resetUnreadMessages' },
},
args: {
resetUnreadMessages: () => {},
},
decorators: [
ScopeDecorator,
Story => (
<UserProvider>
<Story />
</UserProvider>
),
],
}

View File

@@ -0,0 +1,61 @@
import useFetchMock from './hooks/use-fetch-mock'
import CloneProjectModal from '../js/features/clone-project-modal/components/clone-project-modal'
import { ScopeDecorator } from './decorators/scope'
export const Success = args => {
useFetchMock(fetchMock => {
fetchMock.post(
'express:/project/:projectId/clone',
{ status: 200 },
{ delay: 250 }
)
})
return <CloneProjectModal {...args} />
}
export const GenericErrorResponse = args => {
useFetchMock(fetchMock => {
fetchMock.post(
'express:/project/:projectId/clone',
{ status: 500 },
{ delay: 250 }
)
})
return <CloneProjectModal {...args} />
}
export const SpecificErrorResponse = args => {
useFetchMock(fetchMock => {
fetchMock.post(
'express:/project/:projectId/clone',
{ status: 400, body: 'The project name is not valid' },
{ delay: 250 }
)
})
return <CloneProjectModal {...args} />
}
export default {
title: 'Editor / Modals / Clone Project',
component: CloneProjectModal,
args: {
show: true,
projectName: 'Project 1',
projectTags: [
{
_id: 'tag-1',
name: 'Category 1',
color: '#c0ffee',
},
],
},
argTypes: {
handleHide: { action: 'close modal' },
openProject: { action: 'open project' },
handleAfterCloned: { action: 'after cloned' },
},
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,88 @@
import { ComponentProps, useCallback, useState } from 'react'
import useFetchMock from './hooks/use-fetch-mock'
import ContactUsModal from '../../modules/support/frontend/js/components/contact-us-modal'
import fixedHelpSuggestionSearch from '../../modules/support/test/frontend/util/fixed-help-suggestion-search'
import { ScopeDecorator } from './decorators/scope'
import OLButton from '@/features/ui/components/ol/ol-button'
type ContactUsModalProps = ComponentProps<typeof ContactUsModal>
function GenericContactUsModal(args: ContactUsModalProps) {
useFetchMock(fetchMock => {
fetchMock.post('/support', { status: 200 }, { delay: 1000 })
})
return (
<ContactUsModal
helpSuggestionSearch={fixedHelpSuggestionSearch}
{...args}
/>
)
}
export const Generic = (args: ContactUsModalProps) => (
<GenericContactUsModal {...args} />
)
const ContactUsModalWithRequestError = (args: ContactUsModalProps) => {
useFetchMock(fetchMock => {
fetchMock.post('/support', { status: 404 }, { delay: 250 })
})
return (
<ContactUsModal
helpSuggestionSearch={fixedHelpSuggestionSearch}
{...args}
/>
)
}
export const RequestError = (args: ContactUsModalProps) => (
<ContactUsModalWithRequestError {...args} />
)
const ContactUsModalWithAcknowledgement = (
args: Omit<ContactUsModalProps, 'show' | 'handleHide'>
) => {
useFetchMock(fetchMock => {
fetchMock.post('/support', { status: 200 }, { delay: 1000 })
})
const [show, setShow] = useState(false)
const hideModal = useCallback((event?: Event) => {
event?.preventDefault()
setShow(false)
}, [])
return (
<>
<OLButton onClick={() => setShow(true)}>Contact Us</OLButton>
<ContactUsModal
show={show}
handleHide={hideModal}
helpSuggestionSearch={fixedHelpSuggestionSearch}
{...args}
/>
</>
)
}
export const WithAcknowledgement = (args: ContactUsModalProps) => {
const { show, handleHide, ...rest } = args
return <ContactUsModalWithAcknowledgement {...rest} />
}
export default {
title: 'Shared / Modals / Contact Us',
component: ContactUsModal,
args: {
show: true,
handleHide: () => {},
autofillProjectUrl: true,
},
argTypes: {
handleHide: { action: 'close modal' },
},
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,247 @@
import React, { FC, useEffect, useState } from 'react'
import { get } from 'lodash'
import { User, UserId } from '../../../types/user'
import { Project } from '../../../types/project'
import {
mockBuildFile,
mockCompile,
mockCompileError,
} from '../fixtures/compile'
import useFetchMock from '../hooks/use-fetch-mock'
import { useMeta } from '../hooks/use-meta'
import SocketIOShim, { SocketIOMock } from '@/ide/connection/SocketIoShim'
import { IdeContext } from '@/shared/context/ide-context'
import {
IdeReactContext,
createReactScopeValueStore,
} from '@/features/ide-react/context/ide-react-context'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
import { ConnectionContext } from '@/features/ide-react/context/connection-context'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { ConnectionState } from '@/features/ide-react/connection/types/connection-state'
import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
const scopeWatchers: [string, (value: any) => void][] = []
const initialize = () => {
const user: User = {
id: 'story-user' as UserId,
email: 'story-user@example.com',
allowedFreeTrial: true,
features: { dropbox: true, symbolPalette: true },
}
const project: Project = {
_id: '63e21c07946dd8c76505f85a',
name: 'A Project',
features: { mendeley: true, zotero: true, referencesSearch: true },
tokens: {},
owner: {
_id: 'a-user',
email: 'stories@overleaf.com',
},
members: [],
invites: [],
rootDoc_id: '5e74f1a7ce17ae0041dfd056',
rootFolder: [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{ _id: 'test-file-id', name: 'testfile.tex' },
{ _id: 'test-bib-file-id', name: 'testsources.bib' },
],
fileRefs: [{ _id: 'test-image-id', name: 'frog.jpg', hash: '42' }],
folders: [],
},
],
}
const scope = {
user,
project,
$watch: (key: string, callback: () => void) => {
scopeWatchers.push([key, callback])
},
$applyAsync: (callback: () => void) => {
window.setTimeout(() => {
callback()
for (const [key, watcher] of scopeWatchers) {
watcher(get(ide.$scope, key))
}
}, 0)
},
$on: (eventName: string, callback: () => void) => {
//
},
$broadcast: () => {},
ui: {
chatOpen: true,
pdfLayout: 'flat',
},
settings: {
pdfViewer: 'js',
syntaxValidation: true,
},
editor: {
richText: false,
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
hasBufferedOps: () => false,
},
open_doc_name: 'testfile.tex',
},
hasLintingError: false,
permissionsLevel: 'owner',
}
const ide = {
$scope: scope,
socket: new SocketIOShim.SocketShimNoop(
new SocketIOMock()
) as unknown as Socket,
}
// window.metaAttributesCache is reset in preview.tsx
window.metaAttributesCache.set('ol-user', user)
window.metaAttributesCache.set('ol-project_id', project._id)
window.metaAttributesCache.set(
'ol-gitBridgePublicBaseUrl',
'https://git.stories.com'
)
window._ide = ide
}
type ScopeDecoratorOptions = {
mockCompileOnLoad: boolean
providers?: Record<string, any>
}
export const ScopeDecorator = (
Story: any,
opts: ScopeDecoratorOptions = { mockCompileOnLoad: true },
meta: Record<string, any> = {}
) => {
initialize()
// mock compile on load
useFetchMock(fetchMock => {
if (opts.mockCompileOnLoad) {
mockCompile(fetchMock)
mockCompileError(fetchMock)
mockBuildFile(fetchMock)
}
})
// clear scopeWatchers on unmount
useEffect(() => {
return () => {
scopeWatchers.length = 0
}
}, [])
// set values on window.metaAttributesCache (created in initialize, above)
useMeta(meta)
return (
<ReactContextRoot
providers={{
ConnectionProvider,
IdeReactProvider,
...opts.providers,
}}
>
<Story />
</ReactContextRoot>
)
}
const ConnectionProvider: FC = ({ children }) => {
const [value] = useState(() => {
const connectionState: ConnectionState = {
readyState: WebSocket.OPEN,
forceDisconnected: false,
inactiveDisconnect: false,
reconnectAt: null,
forcedDisconnectDelay: 0,
lastConnectionAttempt: 0,
error: '',
}
return {
socket: window._ide.socket as Socket,
connectionState,
isConnected: true,
isStillReconnecting: false,
secondsUntilReconnect: () => 0,
tryReconnectNow: () => {},
registerUserActivity: () => {},
closeConnection: () => {},
getSocketDebuggingInfo: () => ({
client_id: 'fakeClientId',
transport: 'fakeTransport',
publicId: 'fakePublicId',
lastUserActivity: 0,
connectionState,
externalHeartbeat: {
currentStart: 0,
lastSuccess: 0,
lastLatency: 0,
},
}),
}
})
return (
<ConnectionContext.Provider value={value}>
{children}
</ConnectionContext.Provider>
)
}
const IdeReactProvider: FC = ({ children }) => {
const projectId = 'project-123'
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
const [ideReactContextValue] = useState(() => ({
projectId,
eventEmitter: new IdeEventEmitter(),
startedFreeTrial,
setStartedFreeTrial,
reportError: () => {},
projectJoined: true,
}))
const [ideContextValue] = useState(() => {
const ide = window._ide
const scopeStore = createReactScopeValueStore(projectId)
for (const [key, value] of Object.entries(ide.$scope)) {
scopeStore.set(key, value)
}
const scopeEventEmitter = new ReactScopeEventEmitter(new IdeEventEmitter())
window.overleaf = {
...window.overleaf,
unstable: {
...window.overleaf?.unstable,
store: scopeStore,
},
}
return {
...ide,
scopeStore,
scopeEventEmitter,
}
})
return (
<IdeReactContext.Provider value={ideReactContextValue}>
<IdeContext.Provider value={ideContextValue}>
{children}
</IdeContext.Provider>
</IdeReactContext.Provider>
)
}

View File

@@ -0,0 +1,13 @@
import { Meta, StoryObj } from '@storybook/react'
import { DeprecatedBrowser } from '@/shared/components/deprecated-browser'
const meta: Meta = {
title: 'Project List / Deprecated Browser',
component: DeprecatedBrowser,
}
export default meta
type Story = StoryObj<typeof DeprecatedBrowser>
export const Notification: Story = {}

View File

@@ -0,0 +1,57 @@
import ActionsMenu from '../../js/features/editor-left-menu/components/actions-menu'
import { ScopeDecorator } from '../decorators/scope'
import { mockCompile, mockCompileError } from '../fixtures/compile'
import { document, mockDocument } from '../fixtures/document'
import useFetchMock from '../hooks/use-fetch-mock'
import { useScope } from '../hooks/use-scope'
export default {
title: 'Editor / Left Menu / Actions Menu',
component: ActionsMenu,
decorators: [
(Story: any) => ScopeDecorator(Story, { mockCompileOnLoad: false }),
],
}
export const NotCompiled = () => {
window.metaAttributesCache.set('ol-anonymous', false)
useFetchMock(fetchMock => {
mockCompileError(fetchMock, 'failure')
})
return (
<div id="left-menu" className="shown">
<ActionsMenu />
</div>
)
}
export const CompileSuccess = () => {
window.metaAttributesCache.set('ol-anonymous', false)
useScope({
editor: {
sharejs_doc: mockDocument(document.tex),
},
})
useFetchMock(fetchMock => {
mockCompile(fetchMock)
fetchMock.get('express:/project/:projectId/wordcount', {
texcount: {
encode: 'ascii',
textWords: 10,
headers: 11,
mathInline: 12,
mathDisplay: 13,
},
})
})
return (
<div id="left-menu" className="shown">
<ActionsMenu />
</div>
)
}

View File

@@ -0,0 +1,44 @@
import DownloadMenu from '../../js/features/editor-left-menu/components/download-menu'
import { ScopeDecorator } from '../decorators/scope'
import { mockCompile, mockCompileError } from '../fixtures/compile'
import { document, mockDocument } from '../fixtures/document'
import useFetchMock from '../hooks/use-fetch-mock'
import { useScope } from '../hooks/use-scope'
export default {
title: 'Editor / Left Menu / Download Menu',
component: DownloadMenu,
decorators: [
(Story: any) => ScopeDecorator(Story, { mockCompileOnLoad: false }),
],
}
export const NotCompiled = () => {
useFetchMock(fetchMock => {
mockCompileError(fetchMock, 'failure')
})
return (
<div id="left-menu" className="shown">
<DownloadMenu />
</div>
)
}
export const CompileSuccess = () => {
useScope({
editor: {
sharejs_doc: mockDocument(document.tex),
},
})
useFetchMock(fetchMock => {
mockCompile(fetchMock)
})
return (
<div id="left-menu" className="shown">
<DownloadMenu />
</div>
)
}

View File

@@ -0,0 +1,33 @@
import HelpMenu from '../../js/features/editor-left-menu/components/help-menu'
import { ScopeDecorator } from '../decorators/scope'
export default {
title: 'Editor / Left Menu / Help Menu',
component: HelpMenu,
decorators: [ScopeDecorator],
}
export const ShowSupport = () => {
window.metaAttributesCache.set('ol-showSupport', true)
window.metaAttributesCache.set('ol-user', {
email: 'sherlock@holmes.co.uk',
first_name: 'Sherlock',
last_name: 'Holmes',
})
return (
<div id="left-menu" className="shown">
<HelpMenu />
</div>
)
}
export const HideSupport = () => {
window.metaAttributesCache.set('ol-showSupport', false)
return (
<div id="left-menu" className="shown">
<HelpMenu />
</div>
)
}

View File

@@ -0,0 +1,37 @@
import SyncMenu from '../../js/features/editor-left-menu/components/sync-menu'
import { ScopeDecorator } from '../decorators/scope'
import { useScope } from '../hooks/use-scope'
export default {
title: 'Editor / Left Menu / Sync Menu',
component: SyncMenu,
decorators: [ScopeDecorator],
}
export const WriteAccess = () => {
window.metaAttributesCache.set('ol-anonymous', false)
window.metaAttributesCache.set('ol-gitBridgeEnabled', true)
useScope({
permissionsLevel: 'owner',
})
return (
<div id="left-menu" className="shown">
<SyncMenu />
</div>
)
}
export const ReadOnlyAccess = () => {
window.metaAttributesCache.set('ol-anonymous', false)
window.metaAttributesCache.set('ol-gitBridgeEnabled', true)
useScope({
permissionsLevel: 'readOnly',
})
return (
<div id="left-menu" className="shown">
<SyncMenu />
</div>
)
}

View File

@@ -0,0 +1,42 @@
import ToolbarHeader from '../js/features/editor-navigation-toolbar/components/toolbar-header'
import { ScopeDecorator } from './decorators/scope'
export const UpToThreeConnectedUsers = args => {
return <ToolbarHeader {...args} />
}
UpToThreeConnectedUsers.args = {
onlineUsers: ['a', 'c', 'd'].map(c => ({
user_id: c,
name: `${c}_user name`,
})),
}
export const ManyConnectedUsers = args => {
return <ToolbarHeader {...args} />
}
ManyConnectedUsers.args = {
onlineUsers: ['a', 'c', 'd', 'e', 'f'].map(c => ({
user_id: c,
name: `${c}_user name`,
})),
}
export default {
title: 'Editor / Toolbar',
component: ToolbarHeader,
argTypes: {
goToUser: { action: 'goToUser' },
renameProject: { action: 'renameProject' },
toggleHistoryOpen: { action: 'toggleHistoryOpen' },
toggleReviewPanelOpen: { action: 'toggleReviewPanelOpen' },
toggleChatOpen: { action: 'toggleChatOpen' },
openShareModal: { action: 'openShareModal' },
onShowLeftMenuClick: { action: 'onShowLeftMenuClick' },
},
args: {
projectName: 'Overleaf Project',
onlineUsers: [{ user_id: 'abc', name: 'overleaf' }],
unreadMessageCount: 0,
},
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,12 @@
import EditorSwitch from '../js/features/source-editor/components/editor-switch'
import { ScopeDecorator } from './decorators/scope'
export default {
title: 'Editor / Switch',
component: EditorSwitch,
decorators: [ScopeDecorator],
}
export const Switcher = () => {
return <EditorSwitch />
}

View File

@@ -0,0 +1,35 @@
import { LayoutDropdownButtonUi } from '@/features/editor-navigation-toolbar/components/layout-dropdown-button'
import { Meta } from '@storybook/react'
import { ComponentProps } from 'react'
export const LayoutDropdown = (
props: ComponentProps<typeof LayoutDropdownButtonUi>
) => (
<div className="toolbar toolbar-header justify-content-end m-4">
<div className="toolbar-right">
<LayoutDropdownButtonUi {...props} />
</div>
</div>
)
const meta: Meta<typeof LayoutDropdownButtonUi> = {
title: 'Editor / Toolbar / Layout Dropdown',
component: LayoutDropdownButtonUi,
argTypes: {
view: {
control: 'select',
options: [null, 'editor', 'file', 'pdf', 'history'],
},
detachRole: {
control: 'select',
options: ['detacher', 'detached'],
},
pdfLayout: {
control: 'select',
options: ['sideBySide', 'flat'],
},
},
parameters: { actions: { argTypesRegex: '^handle.*' } },
}
export default meta

View File

@@ -0,0 +1,54 @@
import { FC } from 'react'
import type { Meta } from '@storybook/react'
import PdfCompileButton from '@/features/pdf-preview/components/pdf-compile-button'
import { ScopeDecorator } from '../decorators/scope'
import { CompileContext } from '@/shared/context/local-compile-context'
import { DetachCompileContext } from '@/shared/context/detach-compile-context'
export const CompileButton: FC<CompileContext> = (props: CompileContext) => (
<DetachCompileContext.Provider value={props}>
<div className="pdf m-5">
<div className="toolbar toolbar-pdf toolbar-pdf-hybrid btn-toolbar">
<div className="toolbar-pdf-left">
<PdfCompileButton />
</div>
</div>
</div>
</DetachCompileContext.Provider>
)
const args: Partial<CompileContext> = {
autoCompile: false,
compiling: false,
draft: false,
hasChanges: false,
stopOnFirstError: false,
stopOnValidationError: false,
animateCompileDropdownArrow: false,
}
const meta: Meta<typeof CompileButton> = {
title: 'Editor / Toolbar / Compile Button',
component: CompileButton,
// @ts-ignore
decorators: [ScopeDecorator],
argTypes: {
startCompile: { action: 'startCompile' },
setAutoCompile: { action: 'setAutoCompile' },
setCompiling: { action: 'setCompiling' },
setDraft: { action: 'setDraft' },
setStopOnFirstError: { action: 'setStopOnFirstError' },
setError: { action: 'setError' },
setHasLintingError: { action: 'setHasLintingError' },
setPosition: { action: 'setPosition' },
setStopOnValidationError: { action: 'setStopOnValidationError' },
recompileFromScratch: { action: 'recompileFromScratch' },
stopCompile: { action: 'stopCompile' },
setAnimateCompileDropdownArrow: {
action: 'setAnimateCompileDropdownArrow',
},
},
args,
}
export default meta

View File

@@ -0,0 +1,35 @@
import { ScopeDecorator } from './decorators/scope'
import { FeedbackBadge } from '@/shared/components/feedback-badge'
export const WithDefaultText = () => {
return (
<FeedbackBadge
url="https://example.com"
id="storybook-feedback-with-text"
/>
)
}
export const WithCustomText = () => {
const FeedbackContent = () => (
<>
This is an example.
<br />
Click to find out more
</>
)
return (
<FeedbackBadge
url="https://example.com"
id="storybook-feedback-with-text"
text={<FeedbackContent />}
/>
)
}
export default {
title: 'Shared / Components / Feedback Badge',
component: FeedbackBadge,
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,180 @@
import { rootFolderBase } from './fixtures/file-tree-base'
import { rootFolderLimit } from './fixtures/file-tree-limit'
import FileTreeRoot from '../js/features/file-tree/components/file-tree-root'
import FileTreeError from '../js/features/file-tree/components/file-tree-error'
import useFetchMock from './hooks/use-fetch-mock'
import { ScopeDecorator } from './decorators/scope'
import { useScope } from './hooks/use-scope'
import { useIdeContext } from '@/shared/context/ide-context'
const MOCK_DELAY = 2000
const DEFAULT_PROJECT = {
_id: '123abc',
name: 'Some Project',
rootDocId: '5e74f1a7ce17ae0041dfd056',
rootFolder: rootFolderBase,
}
function defaultSetupMocks(fetchMock, socket) {
fetchMock
.post(
/\/project\/\w+\/(file|doc|folder)\/\w+\/rename/,
(path, req) => {
const body = JSON.parse(req.body)
const entityId = path.match(/([^/]+)\/rename$/)[1]
socket.emitToClient('reciveEntityRename', entityId, body.name)
return 204
},
{
delay: MOCK_DELAY,
}
)
.post(
/\/project\/\w+\/folder/,
(_path, req) => {
const body = JSON.parse(req.body)
const newFolder = {
folders: [],
fileRefs: [],
docs: [],
_id: Math.random().toString(16).replace(/0\./, 'random-test-id-'),
name: body.name,
}
socket.emitToClient('reciveNewFolder', body.parent_folder_id, newFolder)
return newFolder
},
{
delay: MOCK_DELAY,
}
)
.delete(
/\/project\/\w+\/(file|doc|folder)\/\w+/,
path => {
const entityId = path.match(/[^/]+$/)[0]
socket.emitToClient('removeEntity', entityId)
return 204
},
{
delay: MOCK_DELAY,
}
)
.post(/\/project\/\w+\/(file|doc|folder)\/\w+\/move/, (path, req) => {
const body = JSON.parse(req.body)
const entityId = path.match(/([^/]+)\/move/)[1]
socket.emitToClient('reciveEntityMove', entityId, body.folder_id)
return 204
})
}
export const FullTree = args => {
const { socket } = useIdeContext()
useFetchMock(fetchMock => defaultSetupMocks(fetchMock, socket))
useScope({
project: DEFAULT_PROJECT,
permissionsLevel: 'owner',
})
return <FileTreeRoot {...args} />
}
export const ReadOnly = args => {
useScope({
project: DEFAULT_PROJECT,
permissionsLevel: 'readOnly',
})
return <FileTreeRoot {...args} />
}
export const Disconnected = args => {
useScope({
project: DEFAULT_PROJECT,
permissionsLevel: 'owner',
})
return <FileTreeRoot {...args} />
}
Disconnected.args = { isConnected: false }
export const NetworkErrors = args => {
useFetchMock(fetchMock => {
fetchMock
.post(/\/project\/\w+\/folder/, 500, {
delay: MOCK_DELAY,
})
.post(/\/project\/\w+\/(file|doc|folder)\/\w+\/rename/, 500, {
delay: MOCK_DELAY,
})
.post(/\/project\/\w+\/(file|doc|folder)\/\w+\/move/, 500, {
delay: MOCK_DELAY,
})
.delete(/\/project\/\w+\/(file|doc|folder)\/\w+/, 500, {
delay: MOCK_DELAY,
})
})
useScope({
project: DEFAULT_PROJECT,
permissionsLevel: 'owner',
})
return <FileTreeRoot {...args} />
}
export const FallbackError = args => {
useScope({
project: DEFAULT_PROJECT,
})
return <FileTreeError {...args} />
}
export const FilesLimit = args => {
const { socket } = useIdeContext()
useFetchMock(fetchMock => defaultSetupMocks(fetchMock, socket))
useScope({
project: {
...DEFAULT_PROJECT,
rootFolder: rootFolderLimit,
},
permissionsLevel: 'owner',
})
return <FileTreeRoot {...args} />
}
export default {
title: 'Editor / File Tree',
component: FileTreeRoot,
args: {
setStartedFreeTrial: () => {
console.log('started free trial')
},
refProviders: {},
setRefProviderEnabled: provider => {
console.log(`ref provider ${provider} enabled`)
},
isConnected: true,
},
argTypes: {
onInit: { action: 'onInit' },
onSelect: { action: 'onSelect' },
},
decorators: [
ScopeDecorator,
Story => (
<>
<style>{'html, body, .file-tree { height: 100%; width: 100%; }'}</style>
<div className="editor-sidebar full-size">
<div className="file-tree">
<Story />
</div>
</div>
</>
),
],
}

View File

@@ -0,0 +1,203 @@
import FileView from '../../js/features/file-view/components/file-view'
import useFetchMock from '../hooks/use-fetch-mock'
import { ScopeDecorator } from '../decorators/scope'
const bodies = {
latex: `\\documentclass{article}
\\begin{document}
First document. This is a simple example, with no
extra parameters or packages included.
\\end{document}`,
bibtex: `@book{latexcompanion,
author = "Michel Goossens and Frank Mittelbach and Alexander Samarin",
title = "The \\LaTeX\\ Companion",
year = "1993",
publisher = "Addison-Wesley",
address = "Reading, Massachusetts"
}`,
text: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`,
}
const setupFetchMock = fetchMock => {
return fetchMock
.head('express:/project/:project_id/blob/:hash', {
status: 201,
headers: { 'Content-Length': 10000 },
})
.post('express:/project/:project_id/linked_file/:file_id/refresh', {
status: 204,
})
.post('express:/project/:project_id/references/indexAll', {
status: 204,
})
}
const fileData = {
id: 'file-id',
name: 'file.tex',
created: new Date().toISOString(),
}
export const FileFromUrl = args => {
useFetchMock(fetchMock =>
setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', {
body: bodies.latex,
})
)
return <FileView {...args} />
}
FileFromUrl.args = {
file: {
...fileData,
linkedFileData: {
url: 'https://example.com/source-file.tex',
provider: 'url',
},
},
}
export const FileFromProjectWithLinkableProjectId = args => {
useFetchMock(fetchMock =>
setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', {
body: bodies.latex,
})
)
return <FileView {...args} />
}
FileFromProjectWithLinkableProjectId.args = {
file: {
...fileData,
linkedFileData: {
source_project_id: 'source-project-id',
source_entity_path: '/source-file.tex',
provider: 'project_file',
},
},
}
export const FileFromProjectWithoutLinkableProjectId = args => {
useFetchMock(fetchMock =>
setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', {
body: bodies.latex,
})
)
return <FileView {...args} />
}
FileFromProjectWithoutLinkableProjectId.args = {
file: {
...fileData,
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_entity_path: '/source-file.tex',
provider: 'project_file',
},
},
}
export const FileFromProjectOutputWithLinkableProject = args => {
useFetchMock(fetchMock =>
setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', {
body: bodies.latex,
})
)
return <FileView {...args} />
}
FileFromProjectOutputWithLinkableProject.args = {
file: {
...fileData,
linkedFileData: {
source_project_id: 'source_project_id',
source_output_file_path: '/source-file.tex',
provider: 'project_output_file',
},
},
}
export const FileFromProjectOutputWithoutLinkableProjectId = args => {
useFetchMock(fetchMock =>
setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', {
body: bodies.latex,
})
)
return <FileView {...args} />
}
FileFromProjectOutputWithoutLinkableProjectId.args = {
file: {
...fileData,
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_output_file_path: '/source-file.tex',
provider: 'project_output_file',
},
},
}
export const ImageFile = args => {
useFetchMock(setupFetchMock) // NOTE: can't mock img src request
return <FileView {...args} />
}
ImageFile.storyName = 'Image File (Error)'
ImageFile.args = {
file: {
...fileData,
id: '60097ca20454610027c442a8',
name: 'file.jpg',
linkedFileData: {
source_project_id: 'source_project_id',
source_entity_path: '/source-file.jpg',
provider: 'project_file',
},
},
}
export const TextFile = args => {
useFetchMock(fetchMock =>
setupFetchMock(fetchMock).get('express:/project/:project_id/blob/:hash', {
body: bodies.text,
})
)
return <FileView {...args} />
}
TextFile.args = {
file: {
...fileData,
linkedFileData: {
source_project_id: 'source-project-id',
source_entity_path: '/source-file.txt',
provider: 'project_file',
},
name: 'file.txt',
},
}
export const UploadedFile = args => {
useFetchMock(fetchMock =>
setupFetchMock(fetchMock).head('express:/project/:project_id/blob/:hash', {
status: 500,
})
)
return <FileView {...args} />
}
UploadedFile.storyName = 'Uploaded File (Error)'
UploadedFile.args = {
file: {
...fileData,
linkedFileData: null,
name: 'file.jpg',
},
}
export default {
title: 'Editor / FileView',
component: FileView,
argTypes: {
storeReferencesKeys: { action: 'store references keys' },
},
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,34 @@
const ONE_MINUTE = 60 * 1000
const user = {
id: 'fake_user',
first_name: 'mortimer',
email: 'fake@example.com',
}
const user2 = {
id: 'another_fake_user',
first_name: 'leopold',
email: 'another_fake@example.com',
}
let nextMessageId = 1
export function generateMessages(count) {
const messages = []
let timestamp = new Date().getTime() // newest message goes first
for (let i = 0; i <= count; i++) {
const author = Math.random() > 0.5 ? user : user2
// modify the timestamp so the previous message has 70% chances to be within 5 minutes from
// the current one, for grouping purposes
timestamp -= (4.3 + Math.random()) * ONE_MINUTE
messages.push({
id: '' + nextMessageId++,
content: `message #${i}`,
user: author,
timestamp,
})
}
return messages
}

View File

@@ -0,0 +1,220 @@
import examplePdf from './storybook-example.pdf'
import { cloneDeep } from 'lodash'
export const dispatchDocChanged = () => {
window.dispatchEvent(
new CustomEvent('doc:changed', { detail: { doc_id: 'foo' } })
)
}
export const outputFiles = [
{
path: 'output.pdf',
build: '123',
url: '/build/output.pdf',
type: 'pdf',
},
{
path: 'output.bbl',
build: '123',
url: '/build/output.bbl',
type: 'bbl',
},
{
path: 'output.bib',
build: '123',
url: '/build/output.bib',
type: 'bib',
},
{
path: 'example.txt',
build: '123',
url: '/build/example.txt',
type: 'txt',
},
{
path: 'output.log',
build: '123',
url: '/build/output.log',
type: 'log',
},
{
path: 'output.blg',
build: '123',
url: '/build/output.blg',
type: 'blg',
},
]
export const mockCompile = (fetchMock, delay = 1000) =>
fetchMock.post(
'express:/project/:projectId/compile',
{
body: {
status: 'success',
clsiServerId: 'foo',
compileGroup: 'priority',
pdfDownloadDomain: '',
outputFiles: cloneDeep(outputFiles),
},
},
{ delay }
)
export const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
fetchMock.post(
'express:/project/:projectId/compile',
{
body: {
status,
clsiServerId: 'foo',
compileGroup: 'priority',
},
},
{ delay, overwriteRoutes: true }
)
export const mockCompileValidationIssues = (
fetchMock,
validationProblems,
delay = 1000
) =>
fetchMock.post(
'express:/project/:projectId/compile',
() => {
return {
body: {
status: 'validation-problems',
validationProblems,
clsiServerId: 'foo',
compileGroup: 'priority',
},
}
},
{ delay }
)
export const mockClearCache = fetchMock =>
fetchMock.delete('express:/project/:projectId/output', 204, {
delay: 1000,
})
export const mockBuildFile = fetchMock =>
fetchMock.get('express:/build/:file', (url, options, request) => {
const { pathname } = new URL(url, 'https://example.com')
switch (pathname) {
case '/build/output.blg':
return 'This is BibTeX, Version 4.0' // FIXME
case '/build/output.log':
return `
The LaTeX compiler output
* With a lot of details
Wrapped in an HTML <pre> element with
preformatted text which is to be presented exactly
as written in the HTML file
(whitespace included™)
The text is typically rendered using a non-proportional ("monospace") font.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <7> on input line 18.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <5> on input line 18.
! Undefined control sequence.
<recently read> \\Zlpha
main.tex, line 23
`
case '/build/output.pdf':
return new Promise(resolve => {
const xhr = new XMLHttpRequest()
xhr.addEventListener('load', () => {
resolve({
status: 200,
headers: {
'Content-Length': xhr.getResponseHeader('Content-Length'),
'Content-Type': xhr.getResponseHeader('Content-Type'),
},
body: xhr.response,
})
})
xhr.open('GET', examplePdf)
xhr.responseType = 'arraybuffer'
xhr.send()
})
default:
console.log(pathname)
return 404
}
})
const mockHighlights = [
{
page: 1,
h: 85.03936,
v: 509.999878,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 486.089539,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 498.044708,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 521.955078,
width: 441.921265,
height: 8.855677,
},
]
export const mockEventTracking = fetchMock =>
fetchMock.get('express:/event/:event', 204)
export const mockValidPdf = fetchMock =>
fetchMock.get('express:/build/output.pdf', (url, options, request) => {
return new Promise(resolve => {
const xhr = new XMLHttpRequest()
xhr.addEventListener('load', () => {
resolve({
status: 200,
headers: {
'Content-Length': xhr.getResponseHeader('Content-Length'),
'Content-Type': xhr.getResponseHeader('Content-Type'),
'Accept-Ranges': 'bytes',
},
body: xhr.response,
})
})
xhr.open('GET', examplePdf)
xhr.responseType = 'arraybuffer'
xhr.send()
})
})
export const mockSynctex = fetchMock =>
fetchMock
.get('express:/project/:projectId/sync/code', () => {
return { pdf: cloneDeep(mockHighlights) }
})
.get('express:/project/:projectId/sync/pdf', () => {
return { code: [{ file: 'main.tex', line: 100 }] }
})

View File

@@ -0,0 +1,41 @@
export const contacts = [
// user with edited name
{
type: 'user',
email: 'test-user@example.com',
first_name: 'Test',
last_name: 'User',
name: 'Test User',
},
// user with default name (email prefix)
{
type: 'user',
email: 'test@example.com',
first_name: 'test',
},
// no last name
{
type: 'user',
first_name: 'Eratosthenes',
email: 'eratosthenes@example.com',
},
// more users
{
type: 'user',
first_name: 'Claudius',
last_name: 'Ptolemy',
email: 'ptolemy@example.com',
},
{
type: 'user',
first_name: 'Abd al-Rahman',
last_name: 'Al-Sufi',
email: 'al-sufi@example.com',
},
{
type: 'user',
first_name: 'Nicolaus',
last_name: 'Copernicus',
email: 'copernicus@example.com',
},
]

View File

@@ -0,0 +1,40 @@
export function mockDocument(text: string) {
return {
doc_id: 'story-doc',
getSnapshot: () => text,
hasBufferedOps: () => false,
}
}
export const document = {
tex: `\\documentclass{article}
% Language setting
% Replace \`english' with e.g. \`spanish' to change the document language
\\usepackage[english]{babel}
% Set page size and margins
% Replace \`letterpaper' with\`a4paper' for UK/EU standard size
\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry}
% Useful packages
\\usepackage{amsmath}
\\usepackage{graphicx}
\\usepackage[colorlinks=true, allcolors=blue]{hyperref}
\\title{Your Paper}
\\author{You}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract.
\\end{abstract}
\\section{Introduction}
Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.
Once you're familiar with the editor, you can find various project setting in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}.`,
}

View File

@@ -0,0 +1,44 @@
export const rootFolderBase = [
{
_id: '5e74f1a7ce17ae0041dfd054',
name: 'rootFolder',
folders: [
{
_id: '5f638e58b652df0026c5c8f5',
name: 'a folder',
folders: [
{
_id: '5f956f62700e19000177daa0',
name: 'sub folder',
folders: [],
fileRefs: [],
docs: [],
},
],
fileRefs: [
{ _id: '5cffb9d93da45d3995d05362', name: 'file-in-a-folder.pdf' },
],
docs: [
{ _id: '5f46786322d556004e72a555', name: 'doc-in-a-folder.tex' },
],
},
{
_id: '5f638e68b652df0026c5c8f6',
name: 'another folder',
folders: [],
fileRefs: [],
docs: [],
},
],
fileRefs: [{ _id: '5f11c78e0924770027412a67', name: 'univers.jpg' }],
docs: [
{ _id: '5e74f1a7ce17ae0041dfd056', name: 'main.tex' },
{ _id: '5f46789522d556004e72a556', name: 'perso.bib' },
{
_id: '5da532e29019e800015321c6',
name: 'zotero.bib',
linkedFileData: { provider: 'zotero' },
},
],
},
]

View File

@@ -0,0 +1,52 @@
const FILE_PER_FOLDER = 2
const DOC_PER_FOLDER = 3
const FOLDER_PER_FOLDER = 2
const MAX_DEPTH = 7
function fakeId() {
return Math.random().toString(16).replace(/0\./, 'random-test-id-')
}
function makeFileRefs(path) {
const fileRefs = []
for (let index = 0; index < FILE_PER_FOLDER; index++) {
fileRefs.push({ _id: fakeId(), name: `${path}-file-${index}.jpg` })
}
return fileRefs
}
function makeDocs(path) {
const docs = []
for (let index = 0; index < DOC_PER_FOLDER; index++) {
docs.push({ _id: fakeId(), name: `${path}-doc-${index}.tex` })
}
return docs
}
function makeFolders(path, depth = 0) {
const folders = []
for (let index = 0; index < FOLDER_PER_FOLDER; index++) {
const folderPath = `${path}-folder-${index}`
folders.push({
_id: fakeId(),
name: folderPath,
folders: depth < MAX_DEPTH ? makeFolders(folderPath, depth + 1) : [],
fileRefs: makeFileRefs(folderPath),
docs: makeDocs(folderPath),
})
}
return folders
}
export const rootFolderLimit = [
{
_id: fakeId(),
name: 'rootFolder',
folders: makeFolders('root'),
fileRefs: makeFileRefs('root'),
docs: makeDocs('root'),
},
]

View File

@@ -0,0 +1,48 @@
import { Project } from '../../../types/project'
export const project: Project = {
_id: '63e21c07946dd8c76505f85a',
name: 'A Project',
features: {
collaborators: -1, // unlimited
},
publicAccesLevel: 'private',
tokens: {
readOnly: 'ro-token',
readAndWrite: 'rw-token',
},
owner: {
_id: 'project-owner',
email: 'stories@overleaf.com',
},
members: [
{
_id: 'viewer-member',
type: 'user',
privileges: 'readOnly',
name: 'Viewer User',
email: 'viewer@example.com',
},
{
_id: 'author-member',
type: 'user',
privileges: 'readAndWrite',
name: 'Author User',
email: 'author@example.com',
},
],
invites: [
{
_id: 'test-invite-1',
privileges: 'readOnly',
name: 'Invited Viewer',
email: 'invited-viewer@example.com',
},
{
_id: 'test-invite-2',
privileges: 'readAndWrite',
name: 'Invited Author',
email: 'invited-author@example.com',
},
],
}

View File

@@ -0,0 +1,157 @@
import { ScopeDecorator } from '../decorators/scope'
import DocumentDiffViewer from '../../js/features/history/components/diff-view/document-diff-viewer'
import React from 'react'
import { Highlight } from '../../js/features/history/services/types/doc'
const highlights: Highlight[] = [
{
type: 'addition',
range: { from: 3, to: 10 },
hue: 200,
label: 'Added by Wombat on Monday',
},
{
type: 'deletion',
range: { from: 15, to: 25 },
hue: 62,
label: 'Deleted by Duck on Monday',
},
{
type: 'addition',
range: { from: 100, to: 400 },
hue: 200,
label: 'Added by Wombat on Friday',
},
{
type: 'deletion',
range: { from: 564, to: 565 },
hue: 200,
label: 'Deleted by Wombat on Friday',
},
{
type: 'addition',
range: { from: 1770, to: 1780 },
hue: 200,
label: 'Added by Wombat on Tuesday',
},
]
const doc = `\\documentclass{article}
% Language setting
% Replace \`english' with e.g. \`spanish' to change the document language
\\usepackage[english]{babel}
% Set page size and margins
% Replace \`letterpaper' with \`a4paper' for UK/EU standard size
\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry}
% Useful packages
\\usepackage{amsmath}
\\usepackage{graphicx}
\\usepackage[colorlinks=true, allcolors=blue]{hyperref}
\\title{Your Paper}
\\author{You}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract.
\\end{abstract}
\\section{Introduction}
Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.
Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}.
\\begin{enumerate}
\\item The labels consists of sequential numbers
\\begin{itemize}
\\item The individual entries are indicated with a black dot, a so-called bullet
\\item The text in the entries may be of any length
\\begin{description}
\\item[Note:] I would like to describe something here
\\item[Caveat!] And give a warning
\\end{description}
\\end{itemize}
\\item The numbers starts at 1 with each use of the \\text{enumerate} environment
\\end{enumerate}
\\bibliographystyle{alpha}
\\bibliography{sample}
\\end{document}`
export default {
title: 'History / Document Diff Viewer',
component: DocumentDiffViewer,
args: { doc, highlights },
argTypes: {
doc: {
table: { disable: true },
},
highlights: {
table: { disable: true },
},
},
decorators: [
ScopeDecorator,
(Story: React.ComponentType) => (
<div style={{ height: '90vh' }}>
<Story />
</div>
),
],
}
export const Highlights = (
args: React.ComponentProps<typeof DocumentDiffViewer>
) => {
return <DocumentDiffViewer {...args} />
}
export const ScrollToFirstHighlight = (
args: React.ComponentProps<typeof DocumentDiffViewer>
) => {
const lastHighlightOnly = args.highlights.slice(-1)
return <DocumentDiffViewer {...args} highlights={lastHighlightOnly} />
}

View File

@@ -0,0 +1,94 @@
import HistoryVersionComponent from '../../js/features/history/components/change-list/history-version'
import { ScopeDecorator } from '../decorators/scope'
import { HistoryProvider } from '../../js/features/history/context/history-context'
import { disableControlsOf } from '../utils/arg-types'
const update = {
fromV: 3,
toV: 4,
meta: {
users: [
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '631710ab1094c5002647184e',
},
],
start_ts: 1681220036419,
end_ts: 1681220036419,
},
labels: [
{
id: '643561cdfa2b2beac88f0024',
comment: 'tag-1',
version: 4,
user_id: '123',
created_at: '2023-04-11T13:34:05.856Z',
user_display_name: 'john.doe',
},
{
id: '643561d1fa2b2beac88f0025',
comment: 'tag-2',
version: 4,
user_id: '123',
created_at: '2023-04-11T13:34:09.280Z',
user_display_name: 'john.doe',
},
],
pathnames: [],
project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
}
export const HistoryVersion = (
args: React.ComponentProps<typeof HistoryVersionComponent>
) => {
return (
<HistoryProvider>
<HistoryVersionComponent {...args} />
</HistoryProvider>
)
}
export default {
title: 'History / Change list',
component: HistoryVersionComponent,
args: {
update,
currentUserId: '1',
projectId: '123',
comparing: false,
faded: false,
showDivider: false,
selectionState: false,
setSelection: () => {},
dropdownOpen: false,
dropdownActive: false,
setActiveDropdownItem: () => {},
closeDropdownForItem: () => {},
},
argTypes: {
...disableControlsOf(
'update',
'currentUserId',
'projectId',
'setSelection',
'dropdownOpen',
'dropdownActive',
'setActiveDropdownItem',
'closeDropdownForItem'
),
},
decorators: [
ScopeDecorator,
(Story: React.ComponentType) => (
<div className="history-react">
<div className="change-list">
<div className="history-version-list-container">
<Story />
</div>
</div>
</div>
),
],
}

View File

@@ -0,0 +1,77 @@
import LabelListItemComponent from '../../js/features/history/components/change-list/label-list-item'
import { ScopeDecorator } from '../decorators/scope'
import { HistoryProvider } from '../../js/features/history/context/history-context'
import { disableControlsOf } from '../utils/arg-types'
const labels = [
{
id: '643561cdfa2b2beac88f0024',
comment: 'tag-1',
version: 1,
user_id: '123',
created_at: '2023-04-11T13:34:05.856Z',
user_display_name: 'john.doe',
},
{
id: '643561d1fa2b2beac88f0025',
comment: 'tag-2',
version: 1,
user_id: '123',
created_at: '2023-04-11T13:34:09.280Z',
user_display_name: 'john.doe',
},
]
export const LabelVersion = (
args: React.ComponentProps<typeof LabelListItemComponent>
) => {
return (
<HistoryProvider>
<LabelListItemComponent {...args} />
</HistoryProvider>
)
}
export default {
title: 'History / Change list',
component: LabelListItemComponent,
args: {
labels,
version: 1,
currentUserId: '1',
projectId: '123',
comparing: false,
selectionState: false,
selectable: false,
setSelection: () => {},
dropdownOpen: false,
dropdownActive: false,
setActiveDropdownItem: () => {},
closeDropdownForItem: () => {},
},
argTypes: {
...disableControlsOf(
'labels',
'version',
'currentUserId',
'projectId',
'setSelection',
'dropdownOpen',
'dropdownActive',
'setActiveDropdownItem',
'closeDropdownForItem'
),
},
decorators: [
ScopeDecorator,
(Story: React.ComponentType) => (
<div className="history-react">
<div className="change-list">
<div className="history-version-list-container">
<Story />
</div>
</div>
</div>
),
],
}

View File

@@ -0,0 +1,46 @@
import { useState } from 'react'
import ToggleSwitchComponent from '../../js/features/history/components/change-list/toggle-switch'
import { ScopeDecorator } from '../decorators/scope'
import { HistoryProvider } from '../../js/features/history/context/history-context'
export const HistoryAndLabelsToggleSwitch = () => {
const [labelsOnly, setLabelsOnly] = useState(false)
return (
<HistoryProvider>
<ToggleSwitchComponent
labelsOnly={labelsOnly}
setLabelsOnly={setLabelsOnly}
/>
</HistoryProvider>
)
}
export default {
title: 'History / Change list',
component: ToggleSwitchComponent,
argTypes: {
labelsOnly: {
table: {
disable: true,
},
},
setLabelsOnly: {
table: {
disable: true,
},
},
},
decorators: [
ScopeDecorator,
(Story: React.ComponentType) => (
<div className="history-react">
<div className="change-list">
<div className="history-header history-toggle-switch-container">
<Story />
</div>
</div>
</div>
),
],
}

View File

@@ -0,0 +1,19 @@
import { useLayoutEffect } from 'react'
import fetchMock from 'fetch-mock'
/**
* Run callback to mock fetch routes, call removeRoutes() and unmockGlobal() when unmounted
*/
export default function useFetchMock(
callback: (value: typeof fetchMock) => void
) {
fetchMock.mockGlobal()
useLayoutEffect(() => {
callback(fetchMock)
return () => {
fetchMock.removeRoutes()
fetchMock.unmockGlobal()
}
}, [callback])
}

View File

@@ -0,0 +1,10 @@
import { PartialMeta } from '@/utils/meta'
/**
* Set values on window.metaAttributesCache, for use in Storybook stories
*/
export const useMeta = (meta: PartialMeta) => {
for (const [key, value] of Object.entries(meta)) {
window.metaAttributesCache.set(key as keyof PartialMeta, value)
}
}

View File

@@ -0,0 +1,30 @@
import { merge } from 'lodash'
import { useLayoutEffect, useRef } from 'react'
/**
* Merge properties with the scope object, for use in Storybook stories
*/
export const useScope = (scope: Record<string, unknown>) => {
const scopeRef = useRef<typeof scope | null>(null)
if (scopeRef.current === null) {
scopeRef.current = scope
}
useLayoutEffect(() => {
if (scopeRef.current) {
for (const [path, value] of Object.entries(scopeRef.current)) {
let existingValue: typeof value | undefined
try {
existingValue = window.overleaf.unstable.store.get(path)
} catch {
// allowed not to exist
}
if (typeof existingValue === 'object' && typeof value === 'object') {
window.overleaf.unstable.store.set(path, merge(existingValue, value))
} else {
window.overleaf.unstable.store.set(path, value)
}
}
}
}, [])
}

View File

@@ -0,0 +1,27 @@
import HotkeysModal from '../js/features/hotkeys-modal/components/hotkeys-modal'
export const ReviewEnabled = args => {
return <HotkeysModal {...args} />
}
export const ReviewDisabled = args => {
return <HotkeysModal {...args} trackChangesVisible={false} />
}
export const MacModifier = args => {
return <HotkeysModal {...args} isMac />
}
export default {
title: 'Editor / Modals / Hotkeys',
component: HotkeysModal,
args: {
animation: false,
show: true,
isMac: false,
trackChangesVisible: true,
},
argTypes: {
handleHide: { action: 'handleHide' },
},
}

View File

@@ -0,0 +1,50 @@
import Icon from '../js/shared/components/icon'
export const Type = args => {
return (
<>
<Icon {...args} />
<div>
<a
href="https://fontawesome.com/v4.7.0/icons/"
target="_blank"
rel="noopener noreferrer"
>
Font Awesome icons
</a>
</div>
</>
)
}
Type.args = {
type: 'tasks',
}
export const Spinner = args => {
return <Icon {...args} />
}
Spinner.args = {
type: 'spinner',
spin: true,
}
export const FixedWidth = args => {
return <Icon {...args} />
}
FixedWidth.args = {
type: 'tasks',
fw: true,
}
export const AccessibilityLabel = args => {
return <Icon {...args} />
}
AccessibilityLabel.args = {
type: 'check',
accessibilityLabel: 'Check',
}
export default {
title: 'Shared / Components / Icon',
component: Icon,
}

View File

@@ -0,0 +1,26 @@
import OLFormSwitch from '@/features/ui/components/ol/ol-form-switch'
import { disableControlsOf } from './utils/arg-types'
export const Unchecked = () => {
return <OLFormSwitch onChange={() => {}} checked={false} />
}
export const UncheckedDisabled = () => {
return <OLFormSwitch onChange={() => {}} checked={false} disabled />
}
export const Checked = () => {
return <OLFormSwitch onChange={() => {}} checked />
}
export const CheckedDisabled = () => {
return <OLFormSwitch onChange={() => {}} checked disabled />
}
export default {
title: 'Shared / Components / Input Switch',
component: OLFormSwitch,
argTypes: {
...disableControlsOf('inputRef'),
},
}

View File

@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from '@storybook/react'
import { LoadingUI } from '@/features/ide-react/components/loading'
import { EditorProviders } from '../../../test/frontend/helpers/editor-providers'
import { PartialMeta } from '@/utils/meta'
const meta: Meta<typeof LoadingUI> = {
title: 'Loading Page / Loading',
component: LoadingUI,
argTypes: {
errorCode: {
control: 'select',
options: [
'',
'io-not-loaded',
'unable-to-join',
'i18n-error',
'unhandled-error-code',
],
},
progress: { control: { type: 'range', min: 0, max: 100 } },
},
}
export default meta
type Story = StoryObj<typeof LoadingUI>
const errorMessages = {
translationIoNotLoaded: 'Could not connect to the WebSocket server',
translationLoadErrorMessage: 'Could not load translations',
translationUnableToJoin: 'Could not connect to collaboration server',
}
export const LoadingPage: Story = {
render: args => {
for (const [key, value] of Object.entries(errorMessages)) {
window.metaAttributesCache.set(`ol-${key}` as keyof PartialMeta, value)
}
return (
<EditorProviders>
<LoadingUI {...args} />
</EditorProviders>
)
},
}

View File

@@ -0,0 +1,35 @@
import { DropdownDivider } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { MenuBar } from '@/shared/components/menu-bar/menu-bar'
import { MenuBarDropdown } from '@/shared/components/menu-bar/menu-bar-dropdown'
import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option'
import { Meta } from '@storybook/react/*'
export const Default = () => {
return (
<MenuBar id="toolbar-menu-bar-item">
<MenuBarDropdown title="File" id="file">
<MenuBarOption title="New File" />
<MenuBarOption title="New Project" />
</MenuBarDropdown>
<MenuBarDropdown title="Edit" id="edit">
<MenuBarOption title="Undo" />
<MenuBarOption title="Redo" />
<DropdownDivider />
<MenuBarOption title="Cut" />
<MenuBarOption title="Copy" />
<MenuBarOption title="Paste" />
</MenuBarDropdown>
<MenuBarDropdown title="View" id="view">
<MenuBarOption title="PDF only" />
</MenuBarDropdown>
</MenuBar>
)
}
const meta: Meta<typeof MenuBar> = {
title: 'Shared / Components / MenuBar',
component: MenuBar,
argTypes: {},
}
export default meta

View File

@@ -0,0 +1,118 @@
import { useEffect } from 'react'
import FileTreeContext from '../../../js/features/file-tree/components/file-tree-context'
import FileTreeCreateNameProvider from '../../../js/features/file-tree/contexts/file-tree-create-name'
import FileTreeCreateFormProvider from '../../../js/features/file-tree/contexts/file-tree-create-form'
import { useFileTreeActionable } from '../../../js/features/file-tree/contexts/file-tree-actionable'
import PropTypes from 'prop-types'
const defaultFileTreeContextProps = {
refProviders: { mendeley: false, zotero: false },
setRefProviderEnabled: provider => {
console.log(`ref provider ${provider} enabled`)
},
setStartedFreeTrial: () => {
console.log('started free trial')
},
initialSelectedEntityId: 'entity-1',
onSelect: () => {
console.log('selected')
},
}
export const mockCreateFileModalFetch = fetchMock =>
fetchMock
.get('path:/user/projects', {
projects: [
{
_id: 'project-1',
name: 'Project One',
},
{
_id: 'project-2',
name: 'Project Two',
},
],
})
.get('path:/mendeley/groups', {
groups: [
{
id: 'group-1',
name: 'Group One',
},
{
id: 'group-2',
name: 'Group Two',
},
],
})
.get('path:/zotero/groups', {
groups: [
{
id: 'group-1',
name: 'Group One',
},
{
id: 'group-2',
name: 'Group Two',
},
],
})
.get('express:/project/:projectId/entities', {
entities: [
{
path: '/foo.tex',
},
{
path: '/bar.tex',
},
],
})
.post('express:/project/:projectId/doc', (path, req) => {
console.log({ path, req })
return 204
})
.post('express:/project/:projectId/upload', (path, req) => {
console.log({ path, req })
return 204
})
.post('express:/project/:projectId/linked_file', (path, req) => {
console.log({ path, req })
return 204
})
export const createFileModalDecorator =
(fileTreeContextProps = {}, createMode = 'doc') =>
// eslint-disable-next-line react/display-name
Story => {
return (
<FileTreeContext
{...defaultFileTreeContextProps}
{...fileTreeContextProps}
>
<FileTreeCreateNameProvider>
<FileTreeCreateFormProvider>
<OpenCreateFileModal createMode={createMode}>
<Story />
</OpenCreateFileModal>
</FileTreeCreateFormProvider>
</FileTreeCreateNameProvider>
</FileTreeContext>
)
}
function OpenCreateFileModal({ children, createMode }) {
const { startCreatingFile } = useFileTreeActionable()
useEffect(() => {
startCreatingFile(createMode)
}, [createMode]) // eslint-disable-line react-hooks/exhaustive-deps
return <>{children}</>
}
OpenCreateFileModal.propTypes = {
createMode: PropTypes.string,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
}

View File

@@ -0,0 +1,62 @@
import {
ModalFooterDecorator,
ModalContentDecorator,
} from '../modal-decorators'
import { FileTreeModalCreateFileFooterContent } from '../../../js/features/file-tree/components/file-tree-create/file-tree-modal-create-file-footer'
export const Valid = args => <FileTreeModalCreateFileFooterContent {...args} />
export const Invalid = args => (
<FileTreeModalCreateFileFooterContent {...args} />
)
Invalid.args = {
valid: false,
}
export const Inflight = args => (
<FileTreeModalCreateFileFooterContent {...args} />
)
Inflight.args = {
inFlight: true,
}
export const FileLimitWarning = args => (
<FileTreeModalCreateFileFooterContent {...args} />
)
FileLimitWarning.args = {
fileCount: {
status: 'warning',
value: 1990,
limit: 2000,
},
}
export const FileLimitError = args => (
<FileTreeModalCreateFileFooterContent {...args} />
)
FileLimitError.args = {
fileCount: {
status: 'error',
value: 2000,
limit: 2000,
},
}
export default {
title: 'Editor / Modals / Create File / Footer',
component: FileTreeModalCreateFileFooterContent,
args: {
fileCount: {
status: 'success',
limit: 10,
value: 1,
},
valid: true,
inFlight: false,
newFileCreateMode: 'doc',
},
argTypes: {
cancel: { action: 'cancel' },
},
decorators: [ModalFooterDecorator, ModalContentDecorator],
}

View File

@@ -0,0 +1,89 @@
import {
createFileModalDecorator,
mockCreateFileModalFetch,
} from './create-file-modal-decorator'
import FileTreeModalCreateFile from '../../../js/features/file-tree/components/modals/file-tree-modal-create-file'
import useFetchMock from '../../hooks/use-fetch-mock'
import { ScopeDecorator } from '../../decorators/scope'
import { useScope } from '../../hooks/use-scope'
import getMeta from '@/utils/meta'
export const MinimalFeatures = args => {
useFetchMock(mockCreateFileModalFetch)
Object.assign(getMeta('ol-ExposedSettings'), {
hasLinkUrlFeature: false,
hasLinkedProjectFileFeature: false,
hasLinkedProjectOutputFileFeature: false,
})
return <FileTreeModalCreateFile {...args} />
}
MinimalFeatures.decorators = [createFileModalDecorator()]
export const WithExtraFeatures = args => {
useFetchMock(mockCreateFileModalFetch)
getMeta('ol-ExposedSettings').hasLinkUrlFeature = true
return <FileTreeModalCreateFile {...args} />
}
WithExtraFeatures.decorators = [
createFileModalDecorator({
refProviders: { mendeley: true, zotero: true },
}),
]
export const ErrorImportingFileFromExternalURL = args => {
useFetchMock(fetchMock => {
mockCreateFileModalFetch(fetchMock)
fetchMock.post('express:/project/:projectId/linked_file', 500)
})
getMeta('ol-ExposedSettings').hasLinkUrlFeature = true
return <FileTreeModalCreateFile {...args} />
}
ErrorImportingFileFromExternalURL.decorators = [createFileModalDecorator()]
export const ErrorImportingFileFromReferenceProvider = args => {
useFetchMock(fetchMock => {
mockCreateFileModalFetch(fetchMock)
fetchMock.post('express:/project/:projectId/linked_file', 500)
})
return <FileTreeModalCreateFile {...args} />
}
ErrorImportingFileFromReferenceProvider.decorators = [
createFileModalDecorator({
refProviders: { mendeley: true, zotero: true },
}),
]
export const FileLimitReached = args => {
useFetchMock(mockCreateFileModalFetch)
useScope({
project: {
rootFolder: {
_id: 'root-folder-id',
name: 'rootFolder',
docs: Array.from({ length: 10 }, (_, index) => ({
_id: `entity-${index}`,
})),
fileRefs: [],
folders: [],
},
},
})
return <FileTreeModalCreateFile {...args} />
}
FileLimitReached.decorators = [createFileModalDecorator()]
export default {
title: 'Editor / Modals / Create File',
component: FileTreeModalCreateFile,
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,67 @@
import FileTreeCreateNameInput from '../../../js/features/file-tree/components/file-tree-create/file-tree-create-name-input'
import FileTreeCreateNameProvider from '../../../js/features/file-tree/contexts/file-tree-create-name'
import {
BlockedFilenameError,
DuplicateFilenameError,
} from '../../../js/features/file-tree/errors'
import { ModalBodyDecorator, ModalContentDecorator } from '../modal-decorators'
export const DefaultLabel = args => (
<FileTreeCreateNameProvider initialName="example.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
export const CustomLabel = args => (
<FileTreeCreateNameProvider initialName="example.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
CustomLabel.args = {
label: 'File Name in this Project',
}
export const FocusName = args => (
<FileTreeCreateNameProvider initialName="example.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
FocusName.args = {
focusName: true,
}
export const CustomPlaceholder = args => (
<FileTreeCreateNameProvider>
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
CustomPlaceholder.args = {
placeholder: 'Enter a file name…',
}
export const DuplicateError = args => (
<FileTreeCreateNameProvider initialName="main.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
DuplicateError.args = {
error: new DuplicateFilenameError(),
}
export const BlockedError = args => (
<FileTreeCreateNameProvider initialName="main.tex">
<FileTreeCreateNameInput {...args} />
</FileTreeCreateNameProvider>
)
BlockedError.args = {
error: new BlockedFilenameError(),
}
export default {
title: 'Editor / Modals / Create File / File Name Input',
component: FileTreeCreateNameInput,
decorators: [ModalBodyDecorator, ModalContentDecorator],
args: {
inFlight: false,
},
}

View File

@@ -0,0 +1,106 @@
import ErrorMessage from '../../../js/features/file-tree/components/file-tree-create/error-message'
import { FetchError } from '../../../js/infrastructure/fetch-json'
import {
BlockedFilenameError,
DuplicateFilenameError,
InvalidFilenameError,
} from '../../../js/features/file-tree/errors'
export const KeyedErrors = () => {
return (
<>
<ErrorMessage error="name-exists" />
<ErrorMessage error="too-many-files" />
<ErrorMessage error="remote-service-error" />
<ErrorMessage error="rate-limit-hit" />
{/* <ErrorMessage error="not-logged-in" /> */}
<ErrorMessage error="something-else" />
</>
)
}
export const FetchStatusErrors = () => {
return (
<>
<ErrorMessage
error={
new FetchError(
'There was an error',
'/story',
{},
new Response(null, { status: 400 })
)
}
/>
<ErrorMessage
error={
new FetchError(
'There was an error',
'/story',
{},
new Response(null, { status: 403 })
)
}
/>
<ErrorMessage
error={
new FetchError(
'There was an error',
'/story',
{},
new Response(null, { status: 429 })
)
}
/>
<ErrorMessage
error={
new FetchError(
'There was an error',
'/story',
{},
new Response(null, { status: 500 })
)
}
/>
</>
)
}
export const FetchDataErrors = () => {
return (
<>
<ErrorMessage
error={
new FetchError('Error', '/story', {}, new Response(), {
message: 'There was an error!',
})
}
/>
<ErrorMessage
error={
new FetchError('Error', '/story', {}, new Response(), {
message: {
text: 'There was an error with some text!',
},
})
}
/>
</>
)
}
export const SpecificClassErrors = () => {
return (
<>
<ErrorMessage error={new DuplicateFilenameError()} />
<ErrorMessage error={new InvalidFilenameError()} />
<ErrorMessage error={new BlockedFilenameError()} />
<ErrorMessage error={new Error()} />
</>
)
}
export default {
title: 'Editor / Modals / Create File / Error Message',
component: ErrorMessage,
}

View File

@@ -0,0 +1,29 @@
/**
* Wrap modal content in modal classes, without modal behaviours
*/
export function ModalContentDecorator(Story) {
return (
<div className="modal-dialog">
<div className="modal-content">
<Story />
</div>
</div>
)
}
export function ModalBodyDecorator(Story) {
return (
<div className="modal-body">
<Story />
</div>
)
}
export function ModalFooterDecorator(Story) {
return (
<div className="modal-footer">
<Story />
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { Meta, StoryObj } from '@storybook/react'
import { UnsavedDocsLockedAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-locked-alert'
import { ScopeDecorator } from '../decorators/scope'
export default {
title: 'Editor / Modals / Unsaved Docs Locked',
component: UnsavedDocsLockedAlert,
decorators: [Story => ScopeDecorator(Story)],
} satisfies Meta
type Story = StoryObj<typeof UnsavedDocsLockedAlert>
export const Locked: Story = {}

View File

@@ -0,0 +1,383 @@
import fetchMock from 'fetch-mock'
import Notification from '../js/shared/components/notification'
import { postJSON } from '../js/infrastructure/fetch-json'
import useAsync from '../js/shared/hooks/use-async'
type Args = React.ComponentProps<typeof Notification>
export const NotificationInfo = (args: Args) => {
return <Notification {...args} isDismissible />
}
export const NotificationSuccess = (args: Args) => {
return <Notification {...args} isDismissible type="success" />
}
export const NotificationWarning = (args: Args) => {
return <Notification {...args} isDismissible type="warning" />
}
export const NotificationError = (args: Args) => {
return <Notification {...args} isDismissible type="error" />
}
export const NotificationWithActionBelowContent = (args: Args) => {
return (
<Notification
{...args}
content={
<div>
<p>The CTA will always go below the content on small screens.</p>
<p>
We can also opt to always put the CTA below the content on all
screens
</p>
</div>
}
isDismissible
isActionBelowContent
/>
)
}
export const NotificationWithTitle = (args: Args) => {
return <Notification {...args} title="Some title" />
}
export const NotificationWithAction = (args: Args) => {
return <Notification {...args} isDismissible={false} />
}
export const NotificationDismissible = (args: Args) => {
return <Notification {...args} action={undefined} />
}
export const APlainNotification = (args: Args) => {
return <Notification {...args} action={undefined} isDismissible={false} />
}
export const NotificationWithMultipleParagraphsAndActionAndDismissible = (
args: Args
) => {
return (
<Notification
{...args}
content={
<div>
<p>
<b>Lorem ipsum</b>
</p>
<p>
Dolor sit amet, consectetur adipiscing elit. Proin lacus velit,
faucibus vitae feugiat sit amet, <a href="/">Some link</a> iaculis
ut mi.
</p>
<p>
Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum
iaculis eu non diam phasellus.
</p>
<p>Aliquam at tempor risus. Vestibulum bibendum ut </p>
</div>
}
/>
)
}
export const NotificationWithMultipleParagraphsAndDismissible = (
args: Args
) => {
return (
<Notification
{...args}
action={undefined}
content={
<div>
<p>
<b>Lorem ipsum</b>
</p>
<p>
Dolor sit amet, consectetur adipiscing elit. Proin lacus velit,
faucibus vitae feugiat sit amet, <a href="/">Some link</a> iaculis
ut mi.
</p>
<p>
Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum
iaculis eu non diam phasellus.
</p>
<p>Aliquam at tempor risus. Vestibulum bibendum ut </p>
</div>
}
/>
)
}
export const MultipleParagraphsAndAction = (args: Args) => {
return (
<Notification
{...args}
isDismissible={false}
content={
<div>
<p>
<b>Lorem ipsum</b>
</p>
<p>
Dolor sit amet, consectetur adipiscing elit. Proin lacus velit,
faucibus vitae feugiat sit amet, <a href="/">Some link</a> iaculis
ut mi.
</p>
<p>
Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum
iaculis eu non diam phasellus.
</p>
<p>Aliquam at tempor risus. Vestibulum bibendum ut </p>
</div>
}
/>
)
}
export const MultipleParagraphs = (args: Args) => {
return (
<Notification
{...args}
action={undefined}
isDismissible={false}
content={
<div>
<p>
<b>Lorem ipsum</b>
</p>
<p>
Dolor sit amet, consectetur adipiscing elit. Proin lacus velit,
faucibus vitae feugiat sit amet, <a href="/">Some link</a> iaculis
ut mi.
</p>
<p>
Vel eros donec ac odio tempor orci dapibus ultrices in. Fermentum
iaculis eu non diam phasellus.
</p>
<p>Aliquam at tempor risus. Vestibulum bibendum ut </p>
</div>
}
/>
)
}
export const ShortText = (args: Args) => {
return (
<Notification
{...args}
action={undefined}
isDismissible={false}
content={<p>Lorem ipsum</p>}
/>
)
}
export const ShortTextAndDismissible = (args: Args) => {
return (
<Notification {...args} action={undefined} content={<p>Lorem ipsum</p>} />
)
}
export const ShortTextAndActionLinkAsButton = (args: Args) => {
return (
<Notification
{...args}
isDismissible={false}
content={<p>Lorem ipsum</p>}
/>
)
}
export const ShortTextAndActionAsLink = (args: Args) => {
return (
<Notification
{...args}
content={<p>Lorem ipsum</p>}
action={<a href="/">An action</a>}
isDismissible={false}
/>
)
}
export const ShortTextAndActionAsLinkButStyledAsButton = (args: Args) => {
return (
<Notification
{...args}
content={<p>Lorem ipsum</p>}
action={
<a href="/" className="btn btn-secondary btn-sm">
An action
</a>
}
isDismissible={false}
/>
)
}
export const LongActionButton = (args: Args) => {
return (
<Notification
{...args}
action={
<button className="btn btn-secondary btn-sm">
Action that has a lot of text
</button>
}
/>
)
}
export const LongActionLink = (args: Args) => {
return (
<Notification
{...args}
action={<a href="/">Action that has a lot of text</a>}
/>
)
}
export const CustomIcon = (args: Args) => {
return (
<Notification
{...args}
customIcon={<div style={{ marginTop: '-4px' }}>🎉</div>}
/>
)
}
export const MultipleButtons = (args: Args) => {
return (
<Notification
{...args}
content={<p>Lorem ipsum</p>}
action={
<>
<button className="btn btn-secondary btn-sm">button1</button>
<button className="btn btn-secondary btn-sm">button2</button>
</>
}
type="info"
isActionBelowContent
isDismissible
/>
)
}
export const OverlayedWithCustomClass = (args: Args) => {
return (
<>
<Notification
{...args}
content={
<p>
This can be <b>any HTML</b> passed to the component. For example,
paragraphs, headers, <code>code samples</code>,{' '}
<a href="/">links</a>, etc are all supported.
</p>
}
className="ol-overlay"
action={
<>
<button className="btn btn-secondary btn-sm">button1</button>
<button className="btn btn-secondary btn-sm">button2</button>
</>
}
type="info"
isActionBelowContent
isDismissible
/>
<div>
<p>we need filler content, so here are some jokes</p>
<ul>
<li>Did you hear about the circus fire? It was in tents!</li>
<li>How do you catch a squirrel? Climb a tree and act like a nut!</li>
<li>
Did you hear about the guy who invented Lifesavers? They say he made
a mint!
</li>
<li>
Why couldn't the bicycle stand up by itself? It was two tired.
</li>
<li>
did one hat say to the other?" "Stay here! I'm going on ahead.
</li>
<li>
Why did Billy get fired from the banana factory? He kept throwing
away the bent ones.
</li>
</ul>
</div>
</>
)
}
export const SuccessFlow = (args: Args) => {
console.log('.....render')
fetchMock.post('express:/test-success', { status: 200 }, { delay: 250 })
const { isLoading, isSuccess, runAsync } = useAsync()
function handleClick() {
console.log('clicked')
runAsync(postJSON('/test-success')).catch(console.error)
}
const ctaText = isLoading ? 'Processing' : 'Click'
const action = (
<button
className="btn btn-secondary btn-sm"
onClick={() => handleClick()}
disabled={isLoading}
>
{ctaText}
</button>
)
const startNotification = (
<Notification
{...args}
action={action}
title="An example notification flow"
content={
<p>
This story shows 2 notifications, and it's up to the parent component
to determine which to show. There's a successful request made after
clicking the action and so the parent component then renders the
success notification.
</p>
}
/>
)
const successNotification = (
<Notification
{...args}
action={<a href="/">Now follow this link to go home</a>}
type="success"
content={<p>Success! You made a successful request.</p>}
/>
)
if (isSuccess) return successNotification
return startNotification
}
export const ContentAsAString = (args: Args) => {
return <Notification {...args} content="An alert" />
}
export default {
title: 'Shared / Components / Notification',
component: Notification,
args: {
content: (
<p>
This can be <b>any HTML</b> passed to the component. For example,
paragraphs, headers, <code>code samples</code>, <a href="/">links</a>,
etc are all supported.
</p>
),
action: <button className="btn btn-secondary btn-sm">An action</button>,
isDismissible: true,
},
}

View File

@@ -0,0 +1,61 @@
import OutlinePane from '../js/features/outline/components/outline-pane'
import { ScopeDecorator } from './decorators/scope'
export const Basic = args => <OutlinePane {...args} />
Basic.args = {
outline: [{ line: 1, title: 'Hello', level: 1 }],
}
export const Nested = args => <OutlinePane {...args} />
Nested.args = {
outline: [
{
line: 1,
title: 'Section',
level: 1,
children: [
{
line: 2,
title: 'Subsection',
level: 2,
children: [
{
line: 3,
title: 'Subsubsection',
level: 3,
},
],
},
],
},
],
}
export const NoSections = args => <OutlinePane {...args} />
NoSections.args = {}
export const NonTexFile = args => <OutlinePane {...args} />
NonTexFile.args = {
isTexFile: false,
}
export const PartialResult = args => <OutlinePane {...args} />
PartialResult.args = {
isPartial: true,
}
export default {
title: 'Editor / Outline',
component: OutlinePane,
argTypes: {
jumpToLine: { action: 'jumpToLine' },
onToggle: { action: 'onToggle' },
toggleExpanded: { action: 'toggleExpanded' },
},
args: {
isTexFile: true,
outline: [],
expanded: true,
},
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,19 @@
import Pagination from '../js/shared/components/pagination'
export const Interactive = args => {
return <Pagination {...args} />
}
export default {
title: 'Shared / Components / Pagination',
component: Pagination,
args: {
currentPage: 1,
totalPages: 10,
handlePageClick: () => {},
},
argTypes: {
currentPage: { control: { type: 'number', min: 1, max: 10, step: 1 } },
totalPages: { control: { disable: true } },
},
}

View File

@@ -0,0 +1,74 @@
import PdfLogEntry from '@/features/pdf-preview/components/pdf-log-entry'
import type { Meta, StoryObj } from '@storybook/react'
import { ruleIds } from '@/ide/human-readable-logs/HumanReadableLogsHints'
import { ScopeDecorator } from './decorators/scope'
import { useMeta } from './hooks/use-meta'
import { FC, ReactNode } from 'react'
import { useScope } from './hooks/use-scope'
import { EditorView } from '@codemirror/view'
import { LogEntry } from '@/features/pdf-preview/util/types'
const fakeSourceLocation = {
file: 'file.tex',
line: 12,
column: 5,
}
const fakeLogEntry: LogEntry = {
key: 'fake',
ruleId: 'hint_misplaced_alignment_tab_character',
message: 'Fake message',
messageComponent: 'Fake message component',
content: 'Fake content',
type: 'Error: ',
level: 'error',
contentDetails: ['Fake detail 1', 'Fake detail 2'],
file: 'fake.tex',
line: 12,
column: 5,
raw: 'Fake raw',
}
const fakeArgs = {
headerTitle: 'PDF Preview',
formattedContent: 'This is a log entry',
level: 'error' as const,
extraInfoURL: 'https://example.com',
showCloseButton: true,
showSourceLocationLink: true,
rawContent: 'This is a raw log entry',
contentDetails: ['detail 1', 'detail 2'],
ruleId: 'hint_misplaced_alignment_tab_character' as const,
sourceLocation: fakeSourceLocation,
logEntry: fakeLogEntry,
logType: 'Fake type',
}
const meta: Meta<typeof PdfLogEntry> = {
title: 'Editor / PDF Preview / Logs',
component: PdfLogEntry,
// @ts-ignore
decorators: [ScopeDecorator],
argTypes: {
ruleId: { control: 'select', options: [...ruleIds, 'other'] },
},
args: fakeArgs,
}
export default meta
type Story = StoryObj<typeof PdfLogEntry>
const Provider: FC<{ children: ReactNode }> = ({ children }) => {
useMeta({ 'ol-showAiErrorAssistant': true })
useScope({ 'editor.view': new EditorView({ doc: '\\begin{document' }) })
return <div className="logs-pane p-2">{children}</div>
}
export const PdfLogEntryWithControls: Story = {
render: args => (
<Provider>
<PdfLogEntry {...args} />
</Provider>
),
}

View File

@@ -0,0 +1,20 @@
import PdfPreviewErrorBoundaryFallback from '../js/features/pdf-preview/components/pdf-preview-error-boundary-fallback'
import { ScopeDecorator } from './decorators/scope'
export default {
title: 'Editor / PDF Preview / Error Boundary',
component: PdfPreviewErrorBoundaryFallback,
decorators: [ScopeDecorator],
}
export const PreviewErrorBoundary = () => {
return <PdfPreviewErrorBoundaryFallback type="preview" />
}
export const PdfErrorBoundary = () => {
return <PdfPreviewErrorBoundaryFallback type="pdf" />
}
export const LogsErrorBoundary = () => {
return <PdfPreviewErrorBoundaryFallback type="logs" />
}

View File

@@ -0,0 +1,27 @@
import { ScopeDecorator } from './decorators/scope'
import { useLocalCompileContext } from '../js/shared/context/local-compile-context'
import { useEffect } from 'react'
import { PdfPreviewMessages } from '../js/features/pdf-preview/components/pdf-preview-messages'
import CompileTimeWarningUpgradePrompt from '@/features/pdf-preview/components/compile-time-warning-upgrade-prompt'
export default {
title: 'Editor / PDF Preview / Messages',
component: PdfPreviewMessages,
decorators: [ScopeDecorator],
}
export const Dismissible = () => {
const { setShowCompileTimeWarning } = useLocalCompileContext()
useEffect(() => {
setShowCompileTimeWarning(true)
}, [setShowCompileTimeWarning])
return (
<div style={{ width: 800, position: 'relative' }}>
<PdfPreviewMessages>
<CompileTimeWarningUpgradePrompt />
</PdfPreviewMessages>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { ScopeDecorator } from './decorators/scope'
import { PdfPreviewMessages } from '@/features/pdf-preview/components/pdf-preview-messages'
import { CompileTimeWarningUpgradePromptInner } from '@/features/pdf-preview/components/compile-time-warning-upgrade-prompt-inner'
export default {
title: 'Editor / PDF Preview / Messages',
component: PdfPreviewMessages,
decorators: [
ScopeDecorator,
(Story: any) => (
<div style={{ width: 800, position: 'relative' }}>
<PdfPreviewMessages>
<Story />
</PdfPreviewMessages>
</div>
),
],
}
export const CompileTimeoutWarningActive = (args: any) => {
return <CompileTimeWarningUpgradePromptInner {...args} />
}
CompileTimeoutWarningActive.argTypes = {
handleDismissWarning: { action: 'dismiss warning' },
}

View File

@@ -0,0 +1,375 @@
import { useCallback, useMemo, useState } from 'react'
import useFetchMock from './hooks/use-fetch-mock'
import OLButton from '@/features/ui/components/ol/ol-button'
import PdfPreviewPane from '../js/features/pdf-preview/components/pdf-preview-pane'
import PdfPreview from '../js/features/pdf-preview/components/pdf-preview'
import PdfFileList from '../js/features/pdf-preview/components/pdf-file-list'
import { buildFileList } from '../js/features/pdf-preview/util/file-list'
import PdfLogsViewer from '../js/features/pdf-preview/components/pdf-logs-viewer'
import PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error'
import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
import { useDetachCompileContext as useCompileContext } from '../js/shared/context/detach-compile-context'
import {
dispatchDocChanged,
mockBuildFile,
mockClearCache,
mockCompile,
mockCompileError,
mockCompileValidationIssues,
mockEventTracking,
outputFiles,
} from './fixtures/compile'
import { cloneDeep } from 'lodash'
import { ScopeDecorator } from './decorators/scope'
import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider'
import {
Dropdown,
DropdownMenu,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
export default {
title: 'Editor / PDF Preview',
component: PdfPreview,
subcomponents: {
PdfPreviewHybridToolbar,
PdfFileList,
PdfPreviewError,
},
decorators: [ScopeDecorator],
}
export const Interactive = () => {
useFetchMock(fetchMock => {
mockCompile(fetchMock)
mockBuildFile(fetchMock)
mockClearCache(fetchMock)
})
const Inner = () => {
const context = useCompileContext()
const { setHasLintingError } = context
const toggleLintingError = useCallback(() => {
setHasLintingError(value => !value)
}, [setHasLintingError])
const values = useMemo(() => {
const entries = Object.entries(context).sort((a, b) => {
return a[0].localeCompare(b[0])
})
const values = { boolean: [], other: [] }
for (const entry of entries) {
const type = typeof entry[1]
if (type === 'boolean') {
values.boolean.push(entry)
} else if (type !== 'function') {
values.other.push(entry)
}
}
return values
}, [context])
return (
<div
style={{
padding: 20,
background: 'white',
float: 'left',
zIndex: 10,
position: 'absolute',
top: 60,
bottom: 60,
right: 20,
left: 400,
overflow: 'hidden',
boxShadow: '0px 2px 5px #ccc',
borderRadius: 3,
}}
>
<div
style={{
display: 'flex',
fontSize: 14,
gap: 20,
height: '100%',
overflow: 'hidden',
}}
>
<div style={{ height: '100%', overflow: 'auto', flexShrink: 0 }}>
<table>
<tbody>
{values.boolean.map(([key, value]) => {
return (
<tr key={key} style={{ border: '1px solid #ddd' }}>
<td style={{ padding: 5 }}>{value ? '🟢' : '🔴'}</td>
<th style={{ padding: 5 }}>{key}</th>
</tr>
)
})}
</tbody>
</table>
<div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
margin: '10px 0',
}}
>
<OLButton onClick={dispatchDocChanged}>
trigger doc change
</OLButton>
<OLButton onClick={toggleLintingError}>
toggle linting error
</OLButton>
</div>
</div>
</div>
<div style={{ height: '100%', overflow: 'auto' }}>
<table
style={{
width: '100%',
overflow: 'hidden',
}}
>
<tbody>
{values.other.map(([key, value]) => {
return (
<tr
key={key}
style={{
width: '100%',
overflow: 'hidden',
border: '1px solid #ddd',
}}
>
<th
style={{
verticalAlign: 'top',
padding: 5,
}}
>
{key}
</th>
<td
style={{
overflow: 'auto',
padding: 5,
}}
>
<pre
style={{
margin: '0 10px',
fontSize: 10,
}}
>
{JSON.stringify(value, null, 2)}
</pre>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}
return (
<div className="pdf-viewer">
<PdfPreviewPane />
<Inner />
</div>
)
}
const compileStatuses = [
'autocompile-backoff',
'clear-cache',
'clsi-maintenance',
'compile-in-progress',
'exited',
'failure',
'generic',
'project-too-large',
'rate-limited',
'success',
'terminated',
'timedout',
'too-recently-compiled',
'unavailable',
'validation-problems',
'foo',
]
export const CompileError = () => {
const [status, setStatus] = useState('success')
useFetchMock(fetchMock => {
mockCompileError(fetchMock, status, 0)
mockBuildFile(fetchMock)
})
const Inner = () => {
const { startCompile } = useCompileContext()
const handleStatusChange = useCallback(
event => {
setStatus(event.target.value)
window.setTimeout(() => {
startCompile()
}, 0)
},
[startCompile]
)
return (
<div
style={{
position: 'absolute',
bottom: 10,
left: 10,
background: 'white',
padding: 10,
zIndex: 100,
}}
>
<label>
{'status: '}
<select value={status} onInput={handleStatusChange}>
{compileStatuses.map(status => (
<option key={status}>{status}</option>
))}
</select>
</label>
</div>
)
}
return (
<>
<PdfPreviewPane />
<Inner />
</>
)
}
const compileErrors = [
'autocompile-backoff',
'clear-cache',
'clsi-maintenance',
'compile-in-progress',
'exited',
'failure',
'generic',
'project-too-large',
'rate-limited',
'success',
'terminated',
'timedout',
'too-recently-compiled',
'unavailable',
'validation-problems',
'foo',
]
export const DisplayError = () => {
useFetchMock(fetchMock => {
mockCompile(fetchMock)
})
return (
<div className="logs-pane">
{compileErrors.map(error => (
<div
key={error}
style={{ background: '#5d6879', padding: 10, margin: 5 }}
>
<div style={{ fontFamily: 'monospace', color: 'white' }}>{error}</div>
<PdfPreviewError error={error} />
</div>
))}
</div>
)
}
export const HybridToolbar = () => {
useFetchMock(fetchMock => {
mockCompile(fetchMock, 500)
mockBuildFile(fetchMock)
mockEventTracking(fetchMock)
})
return (
<div className="pdf">
<PdfPreviewHybridToolbar />
</div>
)
}
export const FileList = () => {
const fileList = useMemo(() => {
return buildFileList(cloneDeep(outputFiles))
}, [])
return (
<Dropdown>
<DropdownMenu id="dropdown-files-logs-pane-list" show>
<PdfFileList fileList={fileList} />
</DropdownMenu>
</Dropdown>
)
}
export const Logs = () => {
useFetchMock(fetchMock => {
mockCompileError(fetchMock, 400, 0)
mockBuildFile(fetchMock)
mockClearCache(fetchMock)
})
return (
<div className="pdf">
<PdfPreviewProvider>
<PdfLogsViewer />
</PdfPreviewProvider>
</div>
)
}
const validationProblems = {
sizeCheck: {
resources: [
{ path: 'foo/bar', kbSize: 76221 },
{ path: 'bar/baz', kbSize: 2342 },
],
},
mainFile: true,
conflictedPaths: [
{
path: 'foo/bar',
},
{
path: 'foo/baz',
},
],
}
export const ValidationIssues = () => {
useFetchMock(fetchMock => {
mockCompileValidationIssues(fetchMock, validationProblems, 0)
mockBuildFile(fetchMock)
})
return <PdfPreviewPane />
}

View File

@@ -0,0 +1,50 @@
import useFetchMock from './hooks/use-fetch-mock'
import PdfSynctexControls from '../js/features/pdf-preview/components/pdf-synctex-controls'
import PdfViewer from '../js/features/pdf-preview/components/pdf-viewer'
import {
mockBuildFile,
mockCompile,
mockSynctex,
mockValidPdf,
} from './fixtures/compile'
import { useEffect, Suspense } from 'react'
import { ScopeDecorator } from './decorators/scope'
import { PdfPreviewProvider } from '@/features/pdf-preview/components/pdf-preview-provider'
export default {
title: 'Editor / PDF Viewer',
component: PdfViewer,
decorators: [ScopeDecorator],
}
export const Interactive = () => {
useFetchMock(fetchMock => {
mockCompile(fetchMock)
mockBuildFile(fetchMock)
mockValidPdf(fetchMock)
mockSynctex(fetchMock)
})
useEffect(() => {
window.dispatchEvent(
new CustomEvent(`cursor:editor:update`, {
detail: { row: 10, position: 10 },
})
)
}, [])
return (
<div>
<div className="pdf-viewer">
<Suspense fallback={null}>
<PdfPreviewProvider>
<PdfViewer />
</PdfPreviewProvider>
</Suspense>
</div>
<div style={{ position: 'absolute', top: 150, left: 50 }}>
<PdfSynctexControls />
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import AddAffiliation from '../../js/features/project-list/components/add-affiliation'
import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
import useFetchMock from '../hooks/use-fetch-mock'
import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data'
import getMeta from '@/utils/meta'
export const Add = (args: any) => {
Object.assign(getMeta('ol-ExposedSettings'), {
isOverleaf: true,
})
window.metaAttributesCache.set('ol-userAffiliations', [])
useFetchMock(fetchMock => {
fetchMock.post(/\/api\/project/, {
projects: projectsData,
totalSize: projectsData.length,
})
})
return (
<ProjectListProvider>
<AddAffiliation {...args} />
</ProjectListProvider>
)
}
export default {
title: 'Project List / Affiliation',
component: AddAffiliation,
}

View File

@@ -0,0 +1,15 @@
import { ColorPicker } from '../../js/features/project-list/components/color-picker/color-picker'
import { ColorPickerProvider } from '../../js/features/project-list/context/color-picker-context'
export const Select = (args: any) => {
return (
<ColorPickerProvider>
<ColorPicker {...args} />
</ColorPickerProvider>
)
}
export default {
title: 'Project List / Color Picker',
component: ColorPicker,
}

View File

@@ -0,0 +1,65 @@
import ProjectListTable from '../../js/features/project-list/components/table/project-list-table'
import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
import useFetchMock from '../hooks/use-fetch-mock'
import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data'
export const Successful = (args: any) => {
window.metaAttributesCache.set('ol-user_id', '624333f147cfd8002622a1d3')
useFetchMock(fetchMock => {
fetchMock.post(/\/api\/project/, {
projects: projectsData,
totalSize: projectsData.length,
})
fetchMock.post(
/\/compile/,
{
status: 'success',
compileGroup: 'standard',
clsiServerId: 'server-1',
outputFiles: [{ path: 'output.pdf', build: '123-321' }],
},
{
delay: 1_000,
}
)
})
return (
<ProjectListProvider>
<ProjectListTable {...args} />
</ProjectListProvider>
)
}
export const Failure = (args: any) => {
window.metaAttributesCache.set('ol-user_id', '624333f147cfd8002622a1d3')
useFetchMock(fetchMock => {
fetchMock.post(/\/api\/project/, {
projects: projectsData,
totalSize: projectsData.length,
})
fetchMock.post(
/\/compile/,
{ status: 'failure', outputFiles: [] },
{ delay: 1_000 }
)
})
return (
<ProjectListProvider>
<ProjectListTable {...args} />
</ProjectListProvider>
)
}
export default {
title: 'Project List / PDF download',
component: ProjectListTable,
decorators: [
(Story: any) => (
<div className="project-list-react">
<Story />
</div>
),
],
}

View File

@@ -0,0 +1,75 @@
import CurrentPlanWidget from '../../js/features/project-list/components/current-plan-widget/current-plan-widget'
export const FreePlan = (args: any) => {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'free',
})
return <CurrentPlanWidget {...args} />
}
export const PaidPlanTrialLastDay = (args: any) => {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'individual',
remainingTrialDays: 1,
plan: {
name: 'Individual',
},
subscription: {
name: 'Example Name',
},
})
return <CurrentPlanWidget {...args} />
}
export const PaidPlanRemainingDays = (args: any) => {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'individual',
remainingTrialDays: 5,
plan: {
name: 'Individual',
},
subscription: {
name: 'Example Name',
},
})
return <CurrentPlanWidget {...args} />
}
export const PaidPlanActive = (args: any) => {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'individual',
plan: {
name: 'Individual',
},
subscription: {
name: 'Example Name',
},
})
return <CurrentPlanWidget {...args} />
}
export const PausedPlan = (args: any) => {
window.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'individual',
plan: {
name: 'Individual',
},
subscription: {
name: 'Example Name',
recurlyStatus: {
state: 'paused',
},
},
})
return <CurrentPlanWidget {...args} />
}
export default {
title: 'Project List / Current Plan Widget',
component: CurrentPlanWidget,
}

View File

@@ -0,0 +1,128 @@
import { merge, cloneDeep } from 'lodash'
import { type FetchMock } from 'fetch-mock'
import { UserEmailData } from '../../../../types/user-email'
import {
Institution,
Notification,
} from '../../../../types/project/dashboard/notification'
import { DeepPartial, DeepReadonly } from '../../../../types/utils'
import { Project } from '../../../../types/project/dashboard/api'
import getMeta from '@/utils/meta'
const MOCK_DELAY = 1000
const fakeInstitutionData = {
email: 'email@example.com',
institutionEmail: 'institution@example.com',
institutionId: 123,
institutionName: 'Abc Institution',
requestedEmail: 'requested@example.com',
} as DeepReadonly<Institution>
export const fakeReconfirmationUsersData = {
affiliation: {
institution: {
ssoEnabled: false,
ssoBeta: false,
name: 'Abc Institution',
},
},
samlProviderId: 'Saml Provider',
email: 'reconfirmation-email@overleaf.com',
default: false,
} as DeepReadonly<UserEmailData>
export function defaultSetupMocks(fetchMock: FetchMock) {
// at least one project is required to show some notifications
const projects = [{}] as Project[]
fetchMock.post(/\/api\/project/, {
status: 200,
body: {
projects,
totalSize: projects.length,
},
})
}
export function setDefaultMeta() {
Object.assign(getMeta('ol-ExposedSettings'), {
emailConfirmationDisabled: false,
samlInitPath: '/fakeSaml',
appName: 'Overleaf',
})
window.metaAttributesCache.set('ol-notificationsInstitution', [])
window.metaAttributesCache.set('ol-userEmails', [])
}
export function errorsMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.post(/\/user\/emails\/*/, 500, { delay: MOCK_DELAY })
fetchMock.post(
/\/project\/[A-Za-z0-9]+\/invite\/token\/[A-Za-z0-9]+\/accept/,
500,
{ delay: MOCK_DELAY }
)
fetchMock.post(/\/user\/emails\/send-reconfirmation/, 500, {
delay: MOCK_DELAY,
})
}
export function setInstitutionMeta(institutionData: Partial<Institution>) {
setDefaultMeta()
window.metaAttributesCache.set('ol-notificationsInstitution', [
merge(cloneDeep(fakeInstitutionData), institutionData),
])
}
export function institutionSetupMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.delete(/\/notifications\/*/, 200, { delay: MOCK_DELAY })
}
export function setCommonMeta(notificationData: DeepPartial<Notification>) {
setDefaultMeta()
window.metaAttributesCache.set('ol-notifications', [notificationData])
}
export function commonSetupMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.post(
/\/project\/[A-Za-z0-9]+\/invite\/token\/[A-Za-z0-9]+\/accept/,
200,
{ delay: MOCK_DELAY }
)
}
export function setReconfirmationMeta() {
setDefaultMeta()
window.metaAttributesCache.set('ol-userEmails', [fakeReconfirmationUsersData])
}
export function reconfirmationSetupMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.post(/\/user\/emails\/resend_confirmation/, 200, {
delay: MOCK_DELAY,
})
}
export function setReconfirmAffiliationMeta() {
setDefaultMeta()
window.metaAttributesCache.set(
'ol-reconfirmedViaSAML',
fakeReconfirmationUsersData.samlProviderId
)
}
export function reconfirmAffiliationSetupMocks(fetchMock: FetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.post(/\/api\/project/, {
status: 200,
body: {
projects: [{}],
totalSize: 0,
},
})
fetchMock.post(/\/user\/emails\/send-reconfirmation/, 200, {
delay: MOCK_DELAY,
})
}

View File

@@ -0,0 +1,10 @@
import INRBanner from '@/features/project-list/components/notifications/ads/inr-banner'
export const Default = () => {
return <INRBanner />
}
export default {
title: 'Project List / INR Banner',
component: INRBanner,
}

View File

@@ -0,0 +1,107 @@
import NewProjectButton from '@/features/project-list/components/new-project-button'
import { ProjectListProvider } from '@/features/project-list/context/project-list-context'
import useFetchMock from '../hooks/use-fetch-mock'
import getMeta from '@/utils/meta'
import { SplitTestProvider } from '@/shared/context/split-test-context'
const templateLinks = [
{
name: 'Journal articles',
url: '/gallery/tagged/academic-journal',
},
{
name: 'Books',
url: '/gallery/tagged/book',
},
{
name: 'Formal letters',
url: '/gallery/tagged/formal-letter',
},
{
name: 'Assignments',
url: '/gallery/tagged/homework',
},
{
name: 'Posters',
url: '/gallery/tagged/poster',
},
{
name: 'Presentations',
url: '/gallery/tagged/presentation',
},
{
name: 'Reports',
url: '/gallery/tagged/report',
},
{
name: 'CVs and résumés',
url: '/gallery/tagged/cv',
},
{
name: 'Theses',
url: '/gallery/tagged/thesis',
},
{
name: 'view_all',
url: '/latex/templates',
},
]
export const Success = () => {
Object.assign(getMeta('ol-ExposedSettings'), {
templateLinks,
})
useFetchMock(fetchMock => {
fetchMock.post(
'express:/project/new',
{
status: 200,
body: {
project_id: '123',
},
},
{ delay: 250 }
)
})
return (
<ProjectListProvider>
<SplitTestProvider>
<NewProjectButton id="new-project-button-story" />
</SplitTestProvider>
</ProjectListProvider>
)
}
export const Error = () => {
Object.assign(getMeta('ol-ExposedSettings'), {
templateLinks,
})
useFetchMock(fetchMock => {
fetchMock.post(
'express:/project/new',
{
status: 400,
body: {
message: 'Something went horribly wrong!',
},
},
{ delay: 250 }
)
})
return (
<ProjectListProvider>
<SplitTestProvider>
<NewProjectButton id="new-project-button-story" />
</SplitTestProvider>
</ProjectListProvider>
)
}
export default {
title: 'Project List / New Project Button',
component: NewProjectButton,
}

View File

@@ -0,0 +1,346 @@
import UserNotifications from '../../js/features/project-list/components/notifications/user-notifications'
import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
import useFetchMock from '../hooks/use-fetch-mock'
import {
commonSetupMocks,
errorsMocks,
fakeReconfirmationUsersData,
institutionSetupMocks,
reconfirmAffiliationSetupMocks,
reconfirmationSetupMocks,
setCommonMeta,
setInstitutionMeta,
setReconfirmAffiliationMeta,
setReconfirmationMeta,
} from './helpers/emails'
import { useMeta } from '../hooks/use-meta'
export const ProjectInvite = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
templateKey: 'notification_project_invite',
messageOpts: {
projectId: '123',
projectName: 'Abc Project',
userName: 'fakeUser',
token: 'abcdef',
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ProjectInviteNetworkError = (args: any) => {
useFetchMock(errorsMocks)
setCommonMeta({
templateKey: 'notification_project_invite',
messageOpts: {
projectId: '123',
projectName: 'Abc Project',
userName: 'fakeUser',
token: 'abcdef',
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const Wfh2020UpgradeOffer = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'wfh_2020_upgrade_offer',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const IPMatchedAffiliationSsoEnabled = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_ip_matched_affiliation',
messageOpts: {
university_name: 'Abc University',
institutionId: '456',
ssoEnabled: true,
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const IPMatchedAffiliationSsoDisabled = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_ip_matched_affiliation',
messageOpts: {
university_name: 'Abc University',
institutionId: '456',
ssoEnabled: false,
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const TpdsFileLimit = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_tpds_file_limit',
messageOpts: {
projectName: 'Abc Project',
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const DropBoxDuplicateProjectNames = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_dropbox_duplicate_project_names',
messageOpts: {
projectName: 'Abc Project',
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const DropBoxUnlinkedDueToLapsedReconfirmation = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_dropbox_unlinked_due_to_lapsed_reconfirmation',
})
useMeta({
'ol-user': { features: {} },
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const NotificationGroupInvitation = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_group_invitation',
messageOpts: {
inviterName: 'John Doe',
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const NotificationGroupInvitationCancelSubscription = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({
_id: 1,
templateKey: 'notification_group_invitation',
messageOpts: {
inviterName: 'John Doe',
},
})
window.metaAttributesCache.set('ol-hasIndividualRecurlySubscription', true)
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const NonSpecificMessage = (args: any) => {
useFetchMock(commonSetupMocks)
setCommonMeta({ _id: 1, html: 'Non specific message' })
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoAvailable = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
_id: 1,
templateKey: 'notification_institution_sso_available',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoLinked = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
_id: 1,
templateKey: 'notification_institution_sso_linked',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoNonCanonical = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
_id: 1,
templateKey: 'notification_institution_sso_non_canonical',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoAlreadyRegistered = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
_id: 1,
templateKey: 'notification_institution_sso_already_registered',
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const InstitutionSsoError = (args: any) => {
useFetchMock(institutionSetupMocks)
setInstitutionMeta({
templateKey: 'notification_institution_sso_error',
error: {
message: 'message',
translatedMessage: 'Translated Message',
tryAgain: true,
},
})
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ResendConfirmationEmail = (args: any) => {
useFetchMock(reconfirmationSetupMocks)
setReconfirmationMeta()
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ResendConfirmationEmailNetworkError = (args: any) => {
useFetchMock(errorsMocks)
setReconfirmationMeta()
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ReconfirmAffiliation = (args: any) => {
useFetchMock(reconfirmAffiliationSetupMocks)
setReconfirmAffiliationMeta()
window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
fakeReconfirmationUsersData,
])
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ReconfirmAffiliationNetworkError = (args: any) => {
useFetchMock(errorsMocks)
setReconfirmAffiliationMeta()
window.metaAttributesCache.set('ol-allInReconfirmNotificationPeriods', [
fakeReconfirmationUsersData,
])
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export const ReconfirmedAffiliationSuccess = (args: any) => {
useFetchMock(reconfirmAffiliationSetupMocks)
setReconfirmAffiliationMeta()
window.metaAttributesCache.set('ol-userEmails', [fakeReconfirmationUsersData])
return (
<ProjectListProvider>
<UserNotifications {...args} />
</ProjectListProvider>
)
}
export default {
title: 'Project List / Notifications',
component: UserNotifications,
}

View File

@@ -0,0 +1,60 @@
import ProjectListTable from '../../js/features/project-list/components/table/project-list-table'
import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
import useFetchMock from '../hooks/use-fetch-mock'
import {
copyableProject,
projectsData,
} from '../../../test/frontend/features/project-list/fixtures/projects-data'
import { useMeta } from '../hooks/use-meta'
import { tags } from '../../../test/frontend/features/project-list/fixtures/tags-data'
import { v4 as uuid } from 'uuid'
const MOCK_DELAY = 500
export const Interactive = (args: any) => {
window.metaAttributesCache.set('ol-user_id', '624333f147cfd8002622a1d3')
useFetchMock(fetchMock => {
fetchMock.post(
/\/api\/project/,
{ projects: projectsData, totalSize: projectsData.length },
{ delay: MOCK_DELAY }
)
fetchMock.post(
'express:/project/:projectId/clone',
() => ({
project_id: uuid(),
name: copyableProject.name,
lastUpdated: new Date().toISOString(),
owner: {
_id: copyableProject.owner?.id,
email: copyableProject.owner?.id,
first_name: copyableProject.owner?.firstName,
last_name: copyableProject.owner?.lastName,
},
}),
{ delay: MOCK_DELAY }
)
})
useMeta({
'ol-tags': tags,
})
return (
<ProjectListProvider>
<ProjectListTable {...args} />
</ProjectListProvider>
)
}
export default {
title: 'Project List / Project Table',
component: ProjectListTable,
decorators: [
(Story: any) => (
<div className="project-list-react">
<Story />
</div>
),
],
}

View File

@@ -0,0 +1,27 @@
import SearchForm from '../../js/features/project-list/components/search-form'
import { ProjectListProvider } from '../../js/features/project-list/context/project-list-context'
import useFetchMock from '../hooks/use-fetch-mock'
import { projectsData } from '../../../test/frontend/features/project-list/fixtures/projects-data'
export const Search = (args: any) => {
useFetchMock(fetchMock => {
fetchMock.post(/\/api\/project/, {
projects: projectsData,
totalSize: projectsData.length,
})
})
return (
<ProjectListProvider>
<SearchForm {...args} />
</ProjectListProvider>
)
}
export default {
title: 'Project List / Project Search',
component: SearchForm,
args: {
inputValue: '',
},
}

View File

@@ -0,0 +1,31 @@
import SurveyWidget from '../../js/features/project-list/components/survey-widget'
export const Survey = (args: any) => {
localStorage.clear()
window.metaAttributesCache.set('ol-survey', {
name: 'my-survey',
preText: 'To help shape the future of Overleaf',
linkText: 'Click here!',
url: 'https://example.com/my-survey',
})
return <SurveyWidget {...args} />
}
export const UndefinedSurvey = (args: any) => {
localStorage.clear()
return <SurveyWidget {...args} />
}
export const EmptySurvey = (args: any) => {
localStorage.clear()
window.metaAttributesCache.set('ol-survey', {})
return <SurveyWidget {...args} />
}
export default {
title: 'Project List / Survey Widget',
component: SurveyWidget,
}

View File

@@ -0,0 +1,42 @@
import SystemMessages from '@/shared/components/system-messages'
import useFetchMock from '../hooks/use-fetch-mock'
import { type FetchMock } from 'fetch-mock'
export const SystemMessage = (args: any) => {
useFetchMock((fetchMock: FetchMock) => {
fetchMock.get(/\/system\/messages/, [
{
_id: 1,
content: `
Closing this message will mark it as hidden.
Remove it from the local storage to make it appear again.
`,
},
{
_id: 'protected',
content: 'A protected message content - cannot be closed',
},
])
})
return <SystemMessages {...args} />
}
export const TranslationMessage = (args: any) => {
useFetchMock((fetchMock: FetchMock) => {
fetchMock.get(/\/system\/messages/, [])
})
window.metaAttributesCache.set('ol-suggestedLanguage', {
url: '/dev/null',
lngName: 'German',
imgUrl: 'https://flagcdn.com/w40/de.png',
})
return <SystemMessages {...args} />
}
export default {
title: 'Project List / System Messages',
component: SystemMessages,
}

View File

@@ -0,0 +1,23 @@
import RadioChip from '../js/shared/components/radio-chip'
type Args = React.ComponentProps<typeof RadioChip>
export const RadioChipDefault = (args: Args) => {
return <RadioChip {...args} />
}
export const RadioChipDisabled = (args: Args) => {
return <RadioChip {...args} disabled />
}
export const RadioChipDisabledSelected = (args: Args) => {
return <RadioChip {...args} checked disabled />
}
export default {
title: 'Shared / Components / RadioChip',
component: RadioChip,
args: {
label: 'Option',
},
}

View File

@@ -0,0 +1,61 @@
import { Select } from '../js/shared/components/select'
const items = [1, 2, 3, 4].map(index => ({
key: index,
value: `Demo item ${index}`,
group: index >= 3 ? 'Large numbers' : undefined,
}))
export const Base = () => {
return (
<Select
items={items}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
defaultText="Choose an item"
/>
)
}
export const WithSubtitles = () => {
return (
<Select
items={items}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
itemToSubtitle={x => x?.group ?? ''}
defaultText="Choose an item"
/>
)
}
export const WithSelectedIcon = () => {
return (
<Select
items={items}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
itemToSubtitle={x => x?.group ?? ''}
defaultText="Choose an item"
selectedIcon
/>
)
}
export const WithDisabledItem = () => {
return (
<Select
items={items}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
itemToDisabled={x => x?.key === 1}
itemToSubtitle={x => x?.group ?? ''}
defaultText="Choose an item"
/>
)
}
export default {
title: 'Shared / Components / Select',
component: Select,
}

View File

@@ -0,0 +1,58 @@
import useFetchMock from '../hooks/use-fetch-mock'
import AccountInfoSection from '../../js/features/settings/components/account-info-section'
import { setDefaultMeta, defaultSetupMocks } from './helpers/account-info'
import { UserProvider } from '../../js/shared/context/user-context'
import getMeta from '@/utils/meta'
export const Success = args => {
setDefaultMeta()
useFetchMock(defaultSetupMocks)
return (
<UserProvider>
<AccountInfoSection {...args} />
</UserProvider>
)
}
export const ReadOnly = args => {
setDefaultMeta()
window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', true)
window.metaAttributesCache.set('ol-shouldAllowEditingDetails', false)
return (
<UserProvider>
<AccountInfoSection {...args} />
</UserProvider>
)
}
export const NoEmailInput = args => {
setDefaultMeta()
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
})
useFetchMock(defaultSetupMocks)
return (
<UserProvider>
<AccountInfoSection {...args} />
</UserProvider>
)
}
export const Error = args => {
setDefaultMeta()
useFetchMock(fetchMock => fetchMock.post(/\/user\/settings/, 500))
return (
<UserProvider>
<AccountInfoSection {...args} />
</UserProvider>
)
}
export default {
title: 'Account Settings / Account Info',
component: AccountInfoSection,
}

View File

@@ -0,0 +1,30 @@
import useFetchMock from './../hooks/use-fetch-mock'
import Input from '../../js/features/settings/components/emails/add-email/input'
export const EmailInput = (args: any) => {
useFetchMock(fetchMock =>
fetchMock.get(/\/institutions\/domains/, [
{
hostname: 'autocomplete.edu',
university: { id: 123, name: 'Auto Complete University' },
},
])
)
return (
<>
<Input {...args} />
<br />
<div>
Use <code>autocomplete.edu</code> as domain to trigger an autocomplete
</div>
</>
)
}
export default {
title: 'Account Settings / Emails and Affiliations',
component: Input,
argTypes: {
onChange: { action: 'change' },
},
}

View File

@@ -0,0 +1,27 @@
import BetaProgramSection from '../../js/features/settings/components/beta-program-section'
import { UserProvider } from '../../js/shared/context/user-context'
export const SectionNotEnrolled = args => {
window.metaAttributesCache.set('ol-user', { betaProgram: false })
return (
<UserProvider>
<BetaProgramSection {...args} />
</UserProvider>
)
}
export const SectionEnrolled = args => {
window.metaAttributesCache.set('ol-user', { betaProgram: true })
return (
<UserProvider>
<BetaProgramSection {...args} />
</UserProvider>
)
}
export default {
title: 'Account Settings / Beta Program',
component: BetaProgramSection,
}

View File

@@ -0,0 +1,43 @@
import EmailsSection from '../../js/features/settings/components/emails-section'
import useFetchMock from './../hooks/use-fetch-mock'
import {
setDefaultMeta,
setReconfirmationMeta,
defaultSetupMocks,
reconfirmationSetupMocks,
errorsMocks,
emailLimitSetupMocks,
} from './helpers/emails'
export const EmailsList = args => {
useFetchMock(defaultSetupMocks)
setDefaultMeta()
return <EmailsSection {...args} />
}
export const EmailLimitList = args => {
useFetchMock(emailLimitSetupMocks)
setDefaultMeta()
return <EmailsSection {...args} />
}
export const ReconfirmationEmailsList = args => {
useFetchMock(reconfirmationSetupMocks)
setReconfirmationMeta()
return <EmailsSection {...args} />
}
export const NetworkErrors = args => {
useFetchMock(errorsMocks)
setDefaultMeta()
return <EmailsSection {...args} />
}
export default {
title: 'Account Settings / Emails and Affiliations',
component: EmailsSection,
}

View File

@@ -0,0 +1,23 @@
import getMeta from '@/utils/meta'
const MOCK_DELAY = 1000
export function defaultSetupMocks(fetchMock) {
fetchMock.post(/\/user\/settings/, 200, {
delay: MOCK_DELAY,
})
}
export function setDefaultMeta() {
window.metaAttributesCache.set('ol-user', {
...window.metaAttributesCache.get('ol-user'),
email: 'sherlock@holmes.co.uk',
first_name: 'Sherlock',
last_name: 'Holmes',
})
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: false,
})
window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', false)
window.metaAttributesCache.set('ol-shouldAllowEditingDetails', true)
}

View File

@@ -0,0 +1,219 @@
import getMeta from '@/utils/meta'
const MOCK_DELAY = 1000
const fakeUsersData = [
{
affiliation: {
institution: {
confirmed: true,
id: 1,
name: 'Overleaf',
},
licence: 'pro_plus',
},
confirmedAt: '2022-03-09T10:59:44.139Z',
email: 'foo@overleaf.com',
default: true,
emailHasInstitutionLicence: true,
},
{
confirmedAt: '2022-03-10T10:59:44.139Z',
email: 'bar@overleaf.com',
default: false,
},
{
affiliation: {
institution: {
confirmed: true,
id: 2,
name: 'Overleaf',
},
licence: 'pro_plus',
department: 'Art & Art History',
role: 'Postdoc',
},
email: 'baz@overleaf.com',
default: false,
},
{
email: 'qux@overleaf.com',
default: false,
},
]
const fakeReconfirmationUsersData = [
{
affiliation: {
institution: {
confirmed: true,
isUniversity: true,
id: 4,
name: 'Reconfirmable Email Highlighted',
},
licence: 'pro_plus',
inReconfirmNotificationPeriod: true,
},
email: 'reconfirmation-highlighted@overleaf.com',
confirmedAt: '2022-03-09T10:59:44.139Z',
default: false,
},
{
affiliation: {
institution: {
confirmed: true,
isUniversity: true,
id: 4,
name: 'Reconfirmable Emails Primary',
},
licence: 'pro_plus',
inReconfirmNotificationPeriod: true,
},
email: 'reconfirmation-nonsso@overleaf.com',
confirmedAt: '2022-03-09T10:59:44.139Z',
default: true,
},
{
affiliation: {
institution: {
confirmed: true,
ssoEnabled: true,
isUniversity: true,
id: 3,
name: 'Reconfirmable SSO',
},
licence: 'pro_plus',
inReconfirmNotificationPeriod: true,
},
email: 'reconfirmation-sso@overleaf.com',
confirmedAt: '2022-03-09T10:59:44.139Z',
samlProviderId: 'reconfirmation-sso-provider-id',
default: false,
},
{
affiliation: {
institution: {
confirmed: true,
isUniversity: true,
ssoEnabled: true,
id: 5,
name: 'Reconfirmed SSO',
},
licence: 'pro_plus',
},
confirmedAt: '2022-03-09T10:59:44.139Z',
email: 'sso@overleaf.com',
samlProviderId: 'sso-reconfirmed-provider-id',
default: false,
},
]
const fakeInstitutions = [
{
id: 9326,
name: 'Unknown',
country_code: 'al',
departments: ['New department'],
},
]
const fakeInstitution = {
id: 123,
name: 'test',
country_code: 'de',
departments: [],
team_id: null,
}
const bazFakeInstitution = {
id: 2,
name: 'Baz',
country_code: 'de',
departments: ['Custom department 1', 'Custom department 2'],
team_id: null,
}
const fakeInstitutionDomain1 = [
{
university: {
id: 1234,
ssoEnabled: true,
name: 'Auto Complete University',
},
hostname: 'autocomplete.edu',
confirmed: true,
},
]
const fakeInstitutionDomain2 = [
{
university: {
id: 5678,
ssoEnabled: false,
name: 'Fake Auto Complete University',
},
hostname: 'fake-autocomplete.edu',
confirmed: true,
},
]
export function defaultSetupMocks(fetchMock) {
fetchMock
.get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY })
.get(/\/institutions\/list\/2/, bazFakeInstitution, { delay: MOCK_DELAY })
.get(/\/institutions\/list\/\d+/, fakeInstitution, { delay: MOCK_DELAY })
.get(/\/institutions\/list\?country_code=.*/, fakeInstitutions, {
delay: MOCK_DELAY,
})
.get(/\/institutions\/domains\?hostname=a/, fakeInstitutionDomain1)
.get(/\/institutions\/domains\?hostname=f/, fakeInstitutionDomain2)
.get(/\/institutions\/domains/, [])
.post(/\/user\/emails\/*/, 200, {
delay: MOCK_DELAY,
})
}
export function reconfirmationSetupMocks(fetchMock) {
defaultSetupMocks(fetchMock)
fetchMock.get(/\/user\/emails/, fakeReconfirmationUsersData, {
delay: MOCK_DELAY,
})
}
export function emailLimitSetupMocks(fetchMock) {
const userData = []
for (let i = 0; i < 10; i++) {
userData.push({ email: `example${i}@overleaf.com` })
}
defaultSetupMocks(fetchMock)
fetchMock.get(/\/user\/emails/, userData, {
delay: MOCK_DELAY,
})
}
export function errorsMocks(fetchMock) {
fetchMock
.get(/\/user\/emails/, fakeUsersData, { delay: MOCK_DELAY })
.post(/\/user\/emails\/*/, 500)
}
export function setDefaultMeta() {
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: true,
hasSamlFeature: true,
samlInitPath: 'saml/init',
})
localStorage.setItem(
'showInstitutionalLeaversSurveyUntil',
(Date.now() - 1000 * 60 * 60).toString()
)
}
export function setReconfirmationMeta() {
setDefaultMeta()
window.metaAttributesCache.set(
'ol-reconfirmationRemoveEmail',
'reconfirmation-highlighted@overleaf.com'
)
window.metaAttributesCache.set(
'ol-reconfirmedViaSAML',
'sso-reconfirmed-provider-id'
)
}

View File

@@ -0,0 +1,17 @@
import getMeta from '@/utils/meta'
const MOCK_DELAY = 1000
export function defaultSetupMocks(fetchMock) {
fetchMock.post(/\/user\/delete/, 200, {
delay: MOCK_DELAY,
})
}
export function setDefaultMeta() {
window.metaAttributesCache.set('ol-usersEmail', 'user@primary.com')
Object.assign(getMeta('ol-ExposedSettings'), {
isOverleaf: true,
})
window.metaAttributesCache.set('ol-hasPassword', true)
}

View File

@@ -0,0 +1,78 @@
const MOCK_DELAY = 1000
export function defaultSetupMocks(fetchMock) {
fetchMock
.post('/user/oauth-unlink', 200, { delay: MOCK_DELAY })
.get(
'express:/user/tpds/queues',
{ tpdsToWeb: 0, webToTpds: 0 },
{ delay: MOCK_DELAY }
)
}
export function setDefaultMeta() {
window.metaAttributesCache.set('ol-user', {
...window.metaAttributesCache.get('ol-user'),
features: { github: true, dropbox: true, mendeley: false, zotero: false },
refProviders: {
mendeley: true,
zotero: true,
},
})
window.metaAttributesCache.set('ol-github', { enabled: false })
window.metaAttributesCache.set('ol-dropbox', { registered: true })
window.metaAttributesCache.set('ol-thirdPartyIds', {
collabratec: 'collabratec-id',
google: 'google-id',
})
window.metaAttributesCache.set('ol-oauthProviders', {
collabratec: {
descriptionKey: 'linked_collabratec_description',
descriptionOptions: { appName: 'Overleaf' },
name: 'IEEE Collabratec®',
hideWhenNotLinked: true,
linkPath: '/collabratec/auth/link',
},
google: {
descriptionKey: 'login_with_service',
descriptionOptions: { service: 'Google' },
name: 'Google',
linkPath: '/auth/google',
},
orcid: {
descriptionKey: 'oauth_orcid_description',
descriptionOptions: {
link: '/blog/434',
appName: 'Overleaf',
},
name: 'ORCID',
linkPath: '/auth/orcid',
},
})
window.metaAttributesCache.set('ol-hideLinkingWidgets', true)
window.metaAttributesCache.delete('ol-ssoErrorMessage')
}
export function setPersonalAccessTokensMeta() {
function generateToken(_id) {
const oneYearFromNow = new Date()
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1)
const tokenHasBeenUsed = Math.random() > 0.5
return {
_id,
accessTokenPartial: 'olp_abc' + _id,
createdAt: new Date(),
accessTokenExpiresAt: oneYearFromNow,
lastUsedAt: tokenHasBeenUsed ? new Date() : undefined,
}
}
const tokens = []
for (let i = 0; i < 6; i++) {
tokens.push(generateToken(i))
}
window.metaAttributesCache.set('ol-personalAccessTokens', tokens)
}

View File

@@ -0,0 +1,35 @@
import getMeta from '@/utils/meta'
const MOCK_DELAY = 1000
export function defaultSetupMocks(fetchMock) {
fetchMock.post(
/\/user\/password\/update/,
{
status: 200,
body: {
message: {
type: 'success',
email: 'tim.alby@overleaf.com',
text: 'Password changed',
},
},
},
{
delay: MOCK_DELAY,
}
)
}
export function setDefaultMeta() {
Object.assign(getMeta('ol-ExposedSettings'), {
isOverleaf: true,
})
window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', false)
window.metaAttributesCache.set('ol-hasPassword', true)
window.metaAttributesCache.set('ol-passwordStrengthOptions', {
length: {
min: 2,
},
})
}

View File

@@ -0,0 +1,71 @@
import useFetchMock from '../hooks/use-fetch-mock'
import LeaveModal from '../../js/features/settings/components/leave/modal'
import LeaveSection from '../../js/features/settings/components/leave-section'
import { setDefaultMeta, defaultSetupMocks } from './helpers/leave'
export const Section = args => {
useFetchMock(defaultSetupMocks)
setDefaultMeta()
return <LeaveSection {...args} />
}
Section.component = LeaveSection
Section.parameters = { controls: { include: [], hideNoControlsWarning: true } }
export const ModalSuccess = args => {
setDefaultMeta()
useFetchMock(defaultSetupMocks)
return <LeaveModal {...args} />
}
export const ModalWithoutPassword = args => {
setDefaultMeta()
window.metaAttributesCache.set('ol-hasPassword', false)
useFetchMock(defaultSetupMocks)
return <LeaveModal {...args} />
}
export const ModalAuthError = args => {
setDefaultMeta()
useFetchMock(fetchMock => {
fetchMock.post(/\/user\/delete/, 403)
})
return <LeaveModal {...args} />
}
export const ModalServerError = args => {
setDefaultMeta()
useFetchMock(fetchMock => {
fetchMock.post(/\/user\/delete/, 500)
})
return <LeaveModal {...args} />
}
export const ModalSubscriptionError = args => {
setDefaultMeta()
useFetchMock(fetchMock => {
fetchMock.post(/\/user\/delete/, {
status: 422,
body: {
error: 'SubscriptionAdminDeletionError',
},
})
})
return <LeaveModal {...args} />
}
export default {
title: 'Account Settings / Leave',
component: LeaveModal,
args: {
isOpen: true,
},
argTypes: {
handleClose: { action: 'handleClose' },
},
}

View File

@@ -0,0 +1,21 @@
import EmailsSection from '../../js/features/settings/components/emails-section'
import { UserEmailsProvider } from '../../js/features/settings/context/user-email-context'
import { LeaversSurveyAlert } from '../../js/features/settings/components/leavers-survey-alert'
import localStorage from '@/infrastructure/local-storage'
export const SurveyAlert = () => {
localStorage.setItem(
'showInstitutionalLeaversSurveyUntil',
Date.now() + 1000 * 60 * 60
)
return (
<UserEmailsProvider>
<LeaversSurveyAlert />
</UserEmailsProvider>
)
}
export default {
title: 'Account Settings / Survey Alerts',
component: EmailsSection,
}

View File

@@ -0,0 +1,96 @@
import useFetchMock from '../hooks/use-fetch-mock'
import LinkingSection from '../../js/features/settings/components/linking-section'
import { setDefaultMeta, defaultSetupMocks } from './helpers/linking'
import { UserProvider } from '../../js/shared/context/user-context'
import { SSOProvider } from '../../js/features/settings/context/sso-context'
import { ScopeDecorator } from '../decorators/scope'
import { useEffect } from 'react'
import { useMeta } from '../hooks/use-meta'
const MOCK_DELAY = 1000
export const Section = args => {
useFetchMock(defaultSetupMocks)
setDefaultMeta()
return (
<UserProvider>
<SSOProvider>
<LinkingSection {...args} />
</SSOProvider>
</UserProvider>
)
}
export const SectionAllUnlinked = args => {
useFetchMock(defaultSetupMocks)
useMeta({
'ol-thirdPartyIds': {},
'ol-user': {
features: { github: true, dropbox: true, mendeley: true, zotero: true },
refProviders: {
mendeley: false,
zotero: false,
},
},
'ol-github': { enabled: false },
'ol-dropbox': { registered: false },
})
useEffect(() => {
setDefaultMeta()
}, [])
return (
<UserProvider>
<SSOProvider>
<LinkingSection {...args} />
</SSOProvider>
</UserProvider>
)
}
export const SectionSSOErrors = args => {
useFetchMock(fetchMock =>
fetchMock.post('/user/oauth-unlink', 500, { delay: MOCK_DELAY })
)
setDefaultMeta()
window.metaAttributesCache.set('ol-hideLinkingWidgets', true)
window.metaAttributesCache.set(
'ol-ssoErrorMessage',
'Account already linked to another Overleaf user'
)
return (
<UserProvider>
<SSOProvider>
<LinkingSection {...args} />
</SSOProvider>
</UserProvider>
)
}
export const SectionProjetSyncSuccess = args => {
useFetchMock(defaultSetupMocks)
setDefaultMeta()
window.metaAttributesCache.set('ol-github', { enabled: true })
window.metaAttributesCache.set(
'ol-projectSyncSuccessMessage',
'Thanks, weve successfully linked your GitHub account to Overleaf. You can now export your Overleaf projects to GitHub, or import projects from your GitHub repositories.'
)
return (
<UserProvider>
<SSOProvider>
<LinkingSection {...args} />
</SSOProvider>
</UserProvider>
)
}
export default {
title: 'Account Settings / Linking',
component: LinkingSection,
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,10 @@
import NewsletterSection from '../../js/features/settings/components/newsletter-section'
export const Section = args => {
return <NewsletterSection {...args} />
}
export default {
title: 'Account Settings / Newsletter',
component: NewsletterSection,
}

View File

@@ -0,0 +1,81 @@
import useFetchMock from '../hooks/use-fetch-mock'
import SettingsPageRoot from '../../js/features/settings/components/root'
import {
setDefaultMeta as setDefaultLeaveMeta,
defaultSetupMocks as defaultSetupLeaveMocks,
} from './helpers/leave'
import {
setDefaultMeta as setDefaultAccountInfoMeta,
defaultSetupMocks as defaultSetupAccountInfoMocks,
} from './helpers/account-info'
import {
setDefaultMeta as setDefaultPasswordMeta,
defaultSetupMocks as defaultSetupPasswordMocks,
} from './helpers/password'
import {
setDefaultMeta as setDefaultEmailsMeta,
defaultSetupMocks as defaultSetupEmailsMocks,
} from './helpers/emails'
import {
setDefaultMeta as setDefaultLinkingMeta,
defaultSetupMocks as defaultSetupLinkingMocks,
setPersonalAccessTokensMeta,
} from './helpers/linking'
import { UserProvider } from '../../js/shared/context/user-context'
import { ScopeDecorator } from '../decorators/scope'
import getMeta from '@/utils/meta'
export const Overleaf = args => {
setDefaultLeaveMeta()
setDefaultAccountInfoMeta()
setDefaultPasswordMeta()
setDefaultEmailsMeta()
setDefaultLinkingMeta()
useFetchMock(fetchMock => {
defaultSetupLeaveMocks(fetchMock)
defaultSetupAccountInfoMocks(fetchMock)
defaultSetupPasswordMocks(fetchMock)
defaultSetupEmailsMocks(fetchMock)
defaultSetupLinkingMocks(fetchMock)
})
return (
<UserProvider>
<SettingsPageRoot {...args} />
</UserProvider>
)
}
export const OverleafWithAccessTokens = args => {
setPersonalAccessTokensMeta()
return Overleaf(args)
}
export const ServerPro = args => {
setDefaultAccountInfoMeta()
setDefaultPasswordMeta()
setPersonalAccessTokensMeta()
useFetchMock(fetchMock => {
defaultSetupAccountInfoMocks(fetchMock)
defaultSetupPasswordMocks(fetchMock)
})
Object.assign(getMeta('ol-ExposedSettings'), {
hasAffiliationsFeature: false,
isOverleaf: false,
})
window.metaAttributesCache.set('ol-hideLinkingWidgets', true)
window.metaAttributesCache.delete('ol-oauthProviders')
return (
<UserProvider>
<SettingsPageRoot {...args} />
</UserProvider>
)
}
export default {
title: 'Account Settings / Full Page',
component: SettingsPageRoot,
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,49 @@
import useFetchMock from '../hooks/use-fetch-mock'
import PasswordSection from '../../js/features/settings/components/password-section'
import { setDefaultMeta, defaultSetupMocks } from './helpers/password'
import getMeta from '@/utils/meta'
export const Success = args => {
setDefaultMeta()
useFetchMock(defaultSetupMocks)
return <PasswordSection {...args} />
}
export const ManagedExternally = args => {
setDefaultMeta()
Object.assign(getMeta('ol-ExposedSettings'), {
isOverleaf: false,
})
window.metaAttributesCache.set('ol-isExternalAuthenticationSystemUsed', true)
useFetchMock(defaultSetupMocks)
return <PasswordSection {...args} />
}
export const NoExistingPassword = args => {
setDefaultMeta()
window.metaAttributesCache.set('ol-hasPassword', false)
useFetchMock(defaultSetupMocks)
return <PasswordSection {...args} />
}
export const Error = args => {
setDefaultMeta()
useFetchMock(fetchMock =>
fetchMock.post(/\/user\/password\/update/, {
status: 400,
body: {
message: 'Your old password is wrong',
},
})
)
return <PasswordSection {...args} />
}
export default {
title: 'Account Settings / Password',
component: PasswordSection,
}

View File

@@ -0,0 +1,10 @@
import SessionsSection from '../../js/features/settings/components/sessions-section'
export const Section = args => {
return <SessionsSection {...args} />
}
export default {
title: 'Account Settings / Sessions',
component: SessionsSection,
}

View File

@@ -0,0 +1,56 @@
import EmailsSection from '../../js/features/settings/components/emails-section'
import { SSOAlert } from '../../js/features/settings/components/emails/sso-alert'
export const Info = () => {
window.metaAttributesCache.set('ol-institutionLinked', {
universityName: 'Overleaf University',
})
return <SSOAlert />
}
export const InfoWithEntitlement = () => {
window.metaAttributesCache.set('ol-institutionLinked', {
universityName: 'Overleaf University',
hasEntitlement: true,
})
return <SSOAlert />
}
export const NonCanonicalEmail = () => {
window.metaAttributesCache.set('ol-institutionLinked', {
universityName: 'Overleaf University',
})
window.metaAttributesCache.set(
'ol-institutionEmailNonCanonical',
'user@example.com'
)
return <SSOAlert />
}
export const Error = () => {
window.metaAttributesCache.set('ol-samlError', {
translatedMessage: 'There was an Error',
})
return <SSOAlert />
}
export const ErrorTranslated = () => {
window.metaAttributesCache.set('ol-samlError', {
translatedMessage: 'Translated Error Message',
message: 'There was an Error',
})
return <SSOAlert />
}
export const ErrorWithTryAgain = () => {
window.metaAttributesCache.set('ol-samlError', {
message: 'There was an Error',
tryAgain: true,
})
return <SSOAlert />
}
export default {
title: 'Account Settings / SSO Alerts',
component: EmailsSection,
}

View File

@@ -0,0 +1,182 @@
import ShareProjectModal from '../js/features/share-project-modal/components/share-project-modal'
import useFetchMock from './hooks/use-fetch-mock'
import { useScope } from './hooks/use-scope'
import { ScopeDecorator } from './decorators/scope'
import { contacts } from './fixtures/contacts'
import { project } from './fixtures/project'
export const LinkSharingOff = args => {
useFetchMock(setupFetchMock)
useScope({
project: {
...args.project,
publicAccesLevel: 'private',
},
})
return <ShareProjectModal {...args} />
}
export const LinkSharingOn = args => {
useFetchMock(setupFetchMock)
useScope({
project: {
...args.project,
publicAccesLevel: 'tokenBased',
},
})
return <ShareProjectModal {...args} />
}
export const LinkSharingLoading = args => {
useFetchMock(setupFetchMock)
useScope({
project: {
...args.project,
publicAccesLevel: 'tokenBased',
},
})
return <ShareProjectModal {...args} />
}
export const NonProjectOwnerLinkSharingOff = args => {
useScope({
project: {
...args.project,
publicAccesLevel: 'private',
},
})
return <ShareProjectModal {...args} />
}
export const NonProjectOwnerLinkSharingOn = args => {
useScope({
project: {
...args.project,
publicAccesLevel: 'tokenBased',
},
})
return <ShareProjectModal {...args} />
}
export const RestrictedTokenMember = args => {
// Override isRestrictedTokenMember to be true
// Currently this is necessary because the context value is set from window,
// however in the future we should change this to set via props
window.metaAttributesCache.set('ol-isRestrictedTokenMember', true)
useScope({
project: {
...args.project,
publicAccesLevel: 'tokenBased',
},
})
return <ShareProjectModal {...args} />
}
export const LegacyLinkSharingReadAndWrite = args => {
useFetchMock(setupFetchMock)
useScope({
project: {
...args.project,
publicAccesLevel: 'readAndWrite',
},
})
return <ShareProjectModal {...args} />
}
export const LegacyLinkSharingReadOnly = args => {
useFetchMock(setupFetchMock)
useScope({
project: {
...args.project,
publicAccesLevel: 'readOnly',
},
})
return <ShareProjectModal {...args} />
}
export const LimitedCollaborators = args => {
useFetchMock(setupFetchMock)
useScope({
project: {
...args.project,
features: {
...args.project.features,
collaborators: 3,
},
},
})
return <ShareProjectModal {...args} />
}
export default {
title: 'Editor / Modals / Share Project',
component: ShareProjectModal,
args: {
show: true,
animation: false,
user: {},
project: {
...project,
owner: {
...project.owner,
_id: 'story-user',
},
},
},
argTypes: {
handleHide: { action: 'hide' },
},
decorators: [ScopeDecorator],
}
function setupFetchMock(fetchMock) {
const delay = 1000
fetchMock
// list contacts
.get('express:/user/contacts', { contacts }, { delay })
// access tokens
.get(
'express:/project/:projectId/tokens',
{ tokens: project.tokens },
{ delay }
)
// change privacy setting
.post('express:/project/:projectId/settings/admin', 200, { delay })
// update project member (e.g. set privilege level)
.put('express:/project/:projectId/users/:userId', 200, { delay })
// remove project member
.delete('express:/project/:projectId/users/:userId', 200, { delay })
// transfer ownership
.post('express:/project/:projectId/transfer-ownership', 200, {
delay,
})
// send invite
.post('express:/project/:projectId/invite', 200, { delay })
// delete invite
.delete('express:/project/:projectId/invite/:inviteId', 204, {
delay,
})
// resend invite
.post('express:/project/:projectId/invite/:inviteId/resend', 200, {
delay,
})
// send analytics event
.post('express:/event/:key', 200)
}

View File

@@ -0,0 +1,409 @@
import SourceEditor from '../../js/features/source-editor/components/source-editor'
import { ScopeDecorator } from '../decorators/scope'
import { useScope } from '../hooks/use-scope'
import { useMeta } from '../hooks/use-meta'
import { FC } from 'react'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import RangesTracker from '@overleaf/ranges-tracker'
const FileTreePathProvider: FC = ({ children }) => (
<FileTreePathContext.Provider
value={{
dirname: () => null,
findEntityByPath: () => null,
pathInFolder: () => null,
previewByPath: (path: string) =>
path === 'frog.jpg'
? {
extension: 'png',
url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAASwAAAEsCAYAAAB5fY51AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpaJVETuIOGSogmBBVMRRq1CECqFWaNXB5NIvaNKQpLg4Cq4FBz8Wqw4uzro6uAqC4AeIq4uToouU+L+k0CLWg+N+vLv3uHsHCNUi06y2cUDTbTMRi4qp9KoYeIWAPnShB6Mys4w5SYqj5fi6h4+vdxGe1frcn6NbzVgM8InEs8wwbeIN4ulN2+C8TxxieVklPiceM+mCxI9cVzx+45xzWeCZITOZmCcOEYu5JlaamOVNjXiKOKxqOuULKY9VzluctWKZ1e/JXxjM6CvLXKc5hBgWsQQJIhSUUUARNiK06qRYSNB+tIV/0PVL5FLIVQAjxwJK0CC7fvA/+N2tlZ2c8JKCUaD9xXE+hoHALlCrOM73sePUTgD/M3ClN/ylKjDzSXqloYWPgN5t4OK6oSl7wOUOMPBkyKbsSn6aQjYLvJ/RN6WB/lugc83rrb6P0wcgSV3Fb4CDQ2AkR9nrLd7d0dzbv2fq/f0ARfNylZJUgMQAAAAGYktHRABuAP8AAGHZRr4AAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnAhELEhgyPeVkAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAyVJREFUeNrt1rEJgDAURVGVNCmS2hS6PziCteIYWjuEbiEfOWeEV1xe35bt6QhjnlYjBJLzbYRABhMAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBbwX6m13QqB5HwbIZBSLyN4WACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBfCFVMtphUBKvYwQSBsPI3hYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIFIFgAggUIFoBgAQgWIFgAggUgWIBgAQgWgGABggUgWACCBQgWgGABCBYgWACCBQgWgGABCBYgWACCBSBYgGABCBaAYAGCBSBYAIIFCBaAYAEIFiBYAIIFIFiAYAEIFoBgAYIF8IUXjtUMuBMh1xAAAAAASUVORK5CYII=',
}
: null,
}}
>
{children}
</FileTreePathContext.Provider>
)
export default {
title: 'Editor / Source Editor',
component: SourceEditor,
decorators: [
(Story: any) =>
ScopeDecorator(Story, {
mockCompileOnLoad: true,
providers: { FileTreePathProvider },
}),
(Story: any) => (
<div style={{ height: '90vh' }}>
<Story />
</div>
),
],
}
const settings = {
fontSize: 12,
fontFamily: 'monaco',
lineHeight: 'normal',
editorTheme: 'textmate',
overallTheme: '',
mode: 'default',
autoComplete: true,
autoPairDelimiters: true,
trackChanges: true,
syntaxValidation: false,
}
const permissions = {
write: true,
}
export const Latex = (args: any, { globals: { theme } }: any) => {
// FIXME: useScope has no effect
useScope({
editor: {
sharejs_doc: mockDoc(content.tex, changes.tex),
open_doc_name: 'example.tex',
},
rootFolder: {
name: 'rootFolder',
id: 'root-folder-id',
type: 'folder',
children: [
{
name: 'example.tex.tex',
id: 'example-doc-id',
type: 'doc',
selected: false,
$$hashKey: 'object:89',
},
{
name: 'frog.jpg',
id: 'frog-image-id',
type: 'file',
linkedFileData: null,
created: '2023-05-04T16:11:04.352Z',
$$hashKey: 'object:108',
},
],
selected: false,
},
settings: {
...settings,
overallTheme: theme === 'default-' ? '' : theme,
},
permissions,
})
useMeta({
'ol-showSymbolPalette': true,
})
return <SourceEditor />
}
export const Markdown = (args: any, { globals: { theme } }: any) => {
useScope({
editor: {
sharejs_doc: mockDoc(content.md, changes.md),
open_doc_name: 'example.md',
},
settings: {
...settings,
overallTheme: theme === 'default-' ? '' : theme,
},
permissions,
})
return <SourceEditor />
}
export const Visual = (args: any, { globals: { theme } }: any) => {
useScope({
editor: {
sharejs_doc: mockDoc(content.tex, changes.tex),
open_doc_name: 'example.tex',
showVisual: true,
},
settings: {
...settings,
overallTheme: theme === 'default-' ? '' : theme,
},
permissions,
})
useMeta({
'ol-showSymbolPalette': true,
'ol-mathJaxPath': 'https://unpkg.com/mathjax@3.2.2/es5/tex-svg-full.js',
'ol-project_id': '63e21c07946dd8c76505f85a',
})
return <SourceEditor />
}
export const Bibtex = (args: any, { globals: { theme } }: any) => {
useScope({
editor: {
sharejs_doc: mockDoc(content.bib, changes.bib),
open_doc_name: 'example.bib',
},
settings: {
...settings,
overallTheme: theme === 'default-' ? '' : theme,
},
permissions,
})
return <SourceEditor />
}
const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength
const mockDoc = (content: string, changes: Array<Record<string, any>> = []) => {
const mockShareJSDoc = {
getText() {
return content
},
on() {
// do nothing
},
insert() {
// do nothing
},
del() {
// do nothing
},
emit: (...args: any[]) => {
console.log(...args)
},
}
return {
doc_id: 'story-doc',
getSnapshot: () => {
return content
},
attachToCM6: (cm6: any) => {
cm6.attachShareJs(mockShareJSDoc, MAX_DOC_LENGTH)
},
detachFromCM6: () => {
// Do nothing
},
on: () => {
// Do nothing
},
off: () => {
// Do nothing
},
setTrackChangesIdSeeds: () => {
// Do nothing
},
setTrackingChanges: () => {
// Do nothing
},
getTrackingChanges: () => {
return true
},
getInflightOp: () => {
return null
},
getPendingOp: () => {
return null
},
ranges: new RangesTracker(changes, []),
hasBufferedOps: () => false,
}
}
const changes: Record<string, Array<Record<string, any>>> = {
tex: [
{
id: '1',
op: {
i: 'Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.',
p: 583,
},
meta: {
user_id: '1',
ts: new Date().toString(),
},
},
],
md: [],
bib: [],
}
const content = {
tex: `\\documentclass{article}
% Language setting
% Replace \`english' with e.g. \`spanish' to change the document language
\\usepackage[english]{babel}
% Set page size and margins
% Replace \`letterpaper' with \`a4paper' for UK/EU standard size
\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry}
% Useful packages
\\usepackage{amsmath}
\\usepackage{graphicx}
\\usepackage[colorlinks=true, allcolors=blue]{hyperref}
\\title{Your Paper}
\\author{You}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract.
\\end{abstract}
\\section{Introduction}
Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.
Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}.
\\section{Some examples to get started}
\\subsection{How to create Sections and Subsections}
Simply use the section and subsection commands, as in this example document! With Overleaf, all the formatting and numbering is handled automatically according to the template you've chosen. If you're using Rich Text mode, you can also create new section and subsections via the buttons in the editor toolbar.
\\subsection{How to include Figures}
First you have to upload the image file from your computer using the upload link in the file-tree menu. Then use the includegraphics command to include it in your document. Use the figure environment and the caption command to add a number and a caption to your figure. See the code for Figure \\ref{fig:frog} in this section for an example.
Note that your figure will automatically be placed in the most appropriate place for it, given the surrounding text and taking into account other figures or tables that may be close by. You can find out more about adding images to your documents in this help article on \\href{https://www.overleaf.com/learn/how-to/Including_images_on_Overleaf}{including images on Overleaf}.
\\begin{figure}
\\centering
\\includegraphics[width=0.25\\linewidth]{frog.jpg}
\\caption{This frog was uploaded via the file-tree menu.}\\label{fig:frog}
\\end{figure}
\\subsection{How to add Tables}
Use the table and tabular environments for basic tables --- see Table~\\ref{tab:widgets}, for example. For more information, please see this help article on \\href{https://www.overleaf.com/learn/latex/tables}{tables}.
\\begin{table}
\\centering
\\begin{tabular}{l|r}
Item & Quantity \\\\\\hline
Widgets & 42 \\\\
Gadgets & 13
\\end{tabular}
\\caption{\\label{tab:widgets}An example table.}
\\end{table}
\\subsection{How to add Comments and Track Changes}
Comments can be added to your project by highlighting some text and clicking \`\`Add comment'' in the top right of the editor pane. To view existing comments, click on the Review menu in the toolbar above. To reply to a comment, click on the Reply button in the lower right corner of the comment. You can close the Review pane by clicking its name on the toolbar when you're done reviewing for the time being.
Track changes are available on all our \\href{https://www.overleaf.com/user/subscription/plans}{premium plans}, and can be toggled on or off using the option at the top of the Review pane. Track changes allow you to keep track of every change made to the document, along with the person making the change.
\\subsection{How to add Lists}
You can make lists with automatic numbering \\dots
\\begin{enumerate}
\\item Like this,
\\item and like this.
\\end{enumerate}
\\dots or bullet points \\dots
\\begin{itemize}
\\item Like this,
\\item and like this.
\\end{itemize}
\\subsection{How to write Mathematics}
\\LaTeX{} is great at typesetting mathematics. Let $X_1, X_2, \\ldots, X_n$ be a sequence of independent and identically distributed random variables with $\\text{E}[X_i] = \\mu$ and $\\text{Var}[X_i] = \\sigma^2 < \\infty$, and let
\\[S_n = \\frac{X_1 + X_2 + \\cdots + X_n}{n}
= \\frac{1}{n}\\sum_{i}^{n} X_i\\]
denote their mean. Then as $n$ approaches infinity, the random variables $\\sqrt{n}(S_n - \\mu)$ converge in distribution to a normal $\\mathcal{N}(0, \\sigma^2)$.
\\subsection{How to change the margins and paper size}
Usually the template you're using will have the page margins and paper size set correctly for that use-case. For example, if you're using a journal article template provided by the journal publisher, that template will be formatted according to their requirements. In these cases, it's best not to alter the margins directly.
If however you're using a more general template, such as this one, and would like to alter the margins, a common way to do so is via the geometry package. You can find the geometry package loaded in the preamble at the top of this example file, and if you'd like to learn more about how to adjust the settings, please visit this help article on \\href{https://www.overleaf.com/learn/latex/page_size_and_margins}{page size and margins}.
\\subsection{How to change the document language and spell check settings}
Overleaf supports many different languages, including multiple different languages within one document.
To configure the document language, simply edit the option provided to the babel package in the preamble at the top of this example project. To learn more about the different options, please visit this help article on \\href{https://www.overleaf.com/learn/latex/International_language_support}{international language support}.
To change the spell check language, simply open the Overleaf menu at the top left of the editor window, scroll down to the spell check setting, and adjust accordingly.
\\subsection{How to add Citations and a References List}
You can simply upload a \\verb|.bib| file containing your BibTeX entries, created with a tool such as JabRef. You can then cite entries from it, like this: \\cite{greenwade93}. Just remember to specify a bibliography style, as well as the filename of the \\verb|.bib|. You can find a \\href{https://www.overleaf.com/help/97-how-to-include-a-bibliography-using-bibtex}{video tutorial here} to learn more about BibTeX.
If you have an \\href{https://www.overleaf.com/user/subscription/plans}{upgraded account}, you can also import your Mendeley or Zotero library directly as a \\verb|.bib| file, via the upload menu in the file-tree.
\\subsection{Good luck!}
We hope you find Overleaf useful, and do take a look at our \\href{https://www.overleaf.com/learn}{help library} for more tutorials and user guides! Please also let us know if you have any feedback using the Contact Us link at the bottom of the Overleaf menu --- or use the contact form at \\url{https://www.overleaf.com/contact}.
\\bibliographystyle{alpha}
\\bibliography{sample}
\\end{document}`,
md: `# Heading
This is **bold**
This is _italic_`,
bib: `@book{texbook,
author = {Donald E. Knuth},
year = {1986},
title = {The {\\TeX} Book},
publisher = {Addison-Wesley Professional}
}
@book{latex:companion,
author = {Frank Mittelbach and Michel Gossens
and Johannes Braams and David Carlisle
and Chris Rowley},
year = {2004},
title = {The {\\LaTeX} Companion},
publisher = {Addison-Wesley Professional},
edition = {2}
}
@book{latex2e,
author = {Leslie Lamport},
year = {1994},
title = {{\\LaTeX}: a Document Preparation System},
publisher = {Addison Wesley},
address = {Massachusetts},
edition = {2}
}
@article{knuth:1984,
title={Literate Programming},
author={Donald E. Knuth},
journal={The Computer Journal},
volume={27},
number={2},
pages={97--111},
year={1984},
publisher={Oxford University Press}
}
@inproceedings{lesk:1977,
title={Computer Typesetting of Technical Journals on {UNIX}},
author={Michael Lesk and Brian Kernighan},
booktitle={Proceedings of American Federation of
Information Processing Societies: 1977
National Computer Conference},
pages={879--888},
year={1977},
address={Dallas, Texas}
}
`,
}

View File

@@ -0,0 +1,137 @@
import SplitTestBadge from '../js/shared/components/split-test-badge'
import { ScopeDecorator } from './decorators/scope'
import { SplitTestContext } from '../js/shared/context/split-test-context'
const splitTestContextValue = {
splitTestVariants: {
'storybook-test': 'active',
},
splitTestInfo: {
'storybook-test': {
phase: 'alpha',
badgeInfo: {
url: '/alpha/participate',
tooltipText: 'This is an alpha feature',
},
},
},
}
export const Alpha = args => {
splitTestContextValue.splitTestVariants = {
'storybook-test': 'active',
}
splitTestContextValue.splitTestInfo = {
'storybook-test': {
phase: 'alpha',
badgeInfo: {
url: '/alpha/participate',
tooltipText: 'This is an alpha feature',
},
},
}
return <SplitTestBadge {...args} />
}
export const AlphaNotDisplayed = args => {
splitTestContextValue.splitTestVariants = {
'storybook-test': 'default',
}
splitTestContextValue.splitTestInfo = {
'storybook-test': {
phase: 'alpha',
badgeInfo: {
url: '/alpha/participate',
tooltipText: 'This is an alpha feature',
},
},
}
return <SplitTestBadge {...args} />
}
export const Beta = args => {
splitTestContextValue.splitTestVariants = {
'storybook-test': 'active',
}
splitTestContextValue.splitTestInfo = {
'storybook-test': {
phase: 'beta',
badgeInfo: {
url: '/beta/participate',
tooltipText: 'This is a beta feature',
},
},
}
return <SplitTestBadge {...args} />
}
export const BetaNotDisplayed = args => {
splitTestContextValue.splitTestVariants = {
'storybook-test': 'default',
}
splitTestContextValue.splitTestInfo = {
'storybook-test': {
phase: 'beta',
badgeInfo: {
url: '/beta/participate',
tooltipText: 'This is a beta feature',
},
},
}
return <SplitTestBadge {...args} />
}
export const Release = args => {
splitTestContextValue.splitTestVariants = {
'storybook-test': 'active',
}
splitTestContextValue.splitTestInfo = {
'storybook-test': {
phase: 'release',
badgeInfo: {
url: '/feedback/form',
tooltipText: 'This is a new feature',
},
},
}
return <SplitTestBadge {...args} />
}
export const ReleaseNotDisplayed = args => {
splitTestContextValue.splitTestVariants = {
'storybook-test': 'default',
}
splitTestContextValue.splitTestInfo = {
'storybook-test': {
phase: 'release',
badgeInfo: {
url: '/feedback/form',
tooltipText: 'This is a new feature',
},
},
}
return <SplitTestBadge {...args} />
}
export default {
title: 'Shared / Components / Split Test Badge',
component: SplitTestBadge,
args: {
splitTestName: 'storybook-test',
displayOnVariants: ['active'],
},
decorators: [
(Story, context) => (
<SplitTestContext.Provider value={splitTestContextValue}>
<Story />
</SplitTestContext.Provider>
),
ScopeDecorator,
],
}

View File

@@ -0,0 +1,33 @@
import StartFreeTrialButton from '../js/shared/components/start-free-trial-button'
import { ScopeDecorator } from './decorators/scope'
export const Default = args => {
return <StartFreeTrialButton {...args} />
}
export const CustomText = args => {
return (
<StartFreeTrialButton {...args}>Some Custom Text!</StartFreeTrialButton>
)
}
export const ButtonStyle = args => {
return (
<StartFreeTrialButton
{...args}
buttonProps={{
variant: 'danger',
size: 'lg',
}}
/>
)
}
export default {
title: 'Shared / Components / Start Free Trial Button',
component: StartFreeTrialButton,
args: {
source: 'storybook',
},
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,44 @@
import GroupInvites from '@/features/subscription/components/group-invites/group-invites'
import type { TeamInvite } from '../../../../types/team-invite'
import { useMeta } from '../../hooks/use-meta'
import { ScopeDecorator } from '../../decorators/scope'
export const GroupInvitesDefault = () => {
const teamInvites: TeamInvite[] = [
{
email: 'email1@exammple.com',
token: 'token123',
inviterName: 'inviter1@example.com',
sentAt: new Date(),
_id: '123abc',
},
{
email: 'email2@exammple.com',
token: 'token456',
inviterName: 'inviter2@example.com',
sentAt: new Date(),
_id: '456bcd',
},
]
useMeta({ 'ol-teamInvites': teamInvites })
return (
<div className="content content-alt team-invite">
<GroupInvites />
</div>
)
}
export default {
title: 'Subscription / Group Invites',
component: GroupInvites,
args: {
show: true,
},
argTypes: {
handleHide: { action: 'close modal' },
onDisableSSO: { action: 'callback' },
},
decorators: [ScopeDecorator],
}

View File

@@ -0,0 +1,55 @@
import OLToggleButton from '@/features/ui/components/ol/ol-toggle-button'
import OLToggleButtonGroup from '@/features/ui/components/ol/ol-toggle-button-group'
export const Base = () => {
return (
<OLToggleButtonGroup
type="radio"
name="figure-width"
defaultValue="0.5"
aria-label="Image width"
>
<OLToggleButton variant="secondary" id="width-25p" value="0.25">
¼ width
</OLToggleButton>
<OLToggleButton variant="secondary" id="width-50p" value="0.5">
½ width
</OLToggleButton>
<OLToggleButton variant="secondary" id="width-75p" value="0.75">
¾ width
</OLToggleButton>
<OLToggleButton variant="secondary" id="width-100p" value="1.0">
Full width
</OLToggleButton>
</OLToggleButtonGroup>
)
}
export const Disabled = () => {
return (
<OLToggleButtonGroup
type="radio"
name="figure-width"
defaultValue="0.5"
aria-label="Image width"
>
<OLToggleButton variant="secondary" id="width-25p" disabled value="0.25">
¼ width
</OLToggleButton>
<OLToggleButton variant="secondary" id="width-50p" disabled value="0.5">
½ width
</OLToggleButton>
<OLToggleButton variant="secondary" id="width-75p" disabled value="0.75">
¾ width
</OLToggleButton>
<OLToggleButton variant="secondary" id="width-100p" disabled value="1.0">
Full width
</OLToggleButton>
</OLToggleButtonGroup>
)
}
export default {
title: 'Shared / Components / Toggle Button Group',
component: OLToggleButtonGroup,
}

View File

@@ -0,0 +1,60 @@
import Badge from '@/features/ui/components/bootstrap-5/badge'
import Icon from '@/shared/components/icon'
import type { Meta, StoryObj } from '@storybook/react'
import classnames from 'classnames'
const meta: Meta<typeof Badge> = {
title: 'Shared / Components / Badge',
component: Badge,
args: {
children: 'Badge',
},
argTypes: {
bg: {
options: ['light', 'info', 'primary', 'warning', 'danger'],
control: { type: 'radio' },
},
prepend: {
table: {
disable: true,
},
},
className: {
table: {
disable: true,
},
},
},
}
export default meta
type Story = StoryObj<typeof Badge>
export const BadgeDefault: Story = {
render: args => {
return (
<Badge
className={classnames({ 'text-dark': args.bg === 'light' })}
{...args}
/>
)
},
}
BadgeDefault.args = {
bg: meta.argTypes!.bg!.options![0],
}
export const BadgePrepend: Story = {
render: args => {
return (
<Badge
className={classnames({ 'text-dark': args.bg === 'light' })}
prepend={<Icon type="star" fw />}
{...args}
/>
)
},
}
BadgePrepend.args = {
bg: meta.argTypes!.bg!.options![0],
}

View File

@@ -0,0 +1,50 @@
import Button from '@/features/ui/components/bootstrap-5/button'
import { Meta } from '@storybook/react'
type Args = React.ComponentProps<typeof Button>
export const NewButton = (args: Args) => {
return <Button {...args} />
}
export const ButtonWithLeadingIcon = (args: Args) => {
return <Button leadingIcon="add" {...args} />
}
export const ButtonWithTrailingIcon = (args: Args) => {
return <Button trailingIcon="add" {...args} />
}
export const ButtonWithIcons = (args: Args) => {
return <Button trailingIcon="add" leadingIcon="add" {...args} />
}
const meta: Meta<typeof Button> = {
title: 'Shared / Components / Button',
component: Button,
args: {
children: 'A Button',
disabled: false,
isLoading: false,
},
argTypes: {
size: {
control: 'radio',
options: ['small', 'default', 'large'],
},
variant: {
control: 'radio',
options: [
'primary',
'secondary',
'ghost',
'danger',
'danger-ghost',
'premium',
'premium-secondary',
],
},
},
}
export default meta

View File

@@ -0,0 +1,236 @@
import {
DropdownMenu,
DropdownItem,
DropdownDivider,
DropdownHeader,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import type { Meta } from '@storybook/react'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
type Args = React.ComponentProps<typeof DropdownMenu>
export const Default = (args: Args) => {
return (
<DropdownMenu show>
<li>
<DropdownItem eventKey="1" href="#/action-1">
Example
</DropdownItem>
</li>
<li>
<DropdownItem eventKey="2" href="#/action-2">
Example
</DropdownItem>
</li>
<DropdownDivider />
<li>
<DropdownItem eventKey="3" disabled={args.disabled} href="#/action-3">
Example
</DropdownItem>
</li>
</DropdownMenu>
)
}
export const Active = (args: Args) => {
return (
<DropdownMenu show>
<li>
<DropdownItem eventKey="1" href="#/action-1">
Example
</DropdownItem>
</li>
<li>
<DropdownItem
eventKey="2"
active
href="#/action-2"
trailingIcon="check"
>
Example
</DropdownItem>
</li>
<DropdownDivider />
<li>
<DropdownItem eventKey="3" disabled={args.disabled} href="#/action-3">
Example
</DropdownItem>
</li>
</DropdownMenu>
)
}
export const MultipleSelection = (args: Args) => {
return (
<DropdownMenu show>
<DropdownHeader>Header</DropdownHeader>
<li>
<DropdownItem
eventKey="1"
href="#/action-1"
leadingIcon={<DropdownItem.EmptyLeadingIcon />}
>
Example
</DropdownItem>
</li>
<li>
<DropdownItem eventKey="2" href="#/action-2" leadingIcon="check">
Example
</DropdownItem>
</li>
<li>
<DropdownItem eventKey="3" href="#/action-3" leadingIcon="check">
Example
</DropdownItem>
</li>
</DropdownMenu>
)
}
export const Danger = (args: Args) => {
return (
<DropdownMenu show>
<li>
<DropdownItem eventKey="1" disabled={args.disabled} href="#/action-1">
Example
</DropdownItem>
</li>
<li>
<DropdownItem eventKey="2" href="#/action-2">
Example
</DropdownItem>
</li>
<DropdownDivider />
<li>
<DropdownItem eventKey="3" href="#/action-3" variant="danger">
Example
</DropdownItem>
</li>
</DropdownMenu>
)
}
export const Description = (args: Args) => {
return (
<DropdownMenu show>
<li>
<DropdownItem
disabled={args.disabled}
eventKey="1"
href="#/action-1"
description="Description of the menu"
>
Example
</DropdownItem>
</li>
<li>
<DropdownItem
active
eventKey="2"
href="#/action-2"
description="Description of the menu"
trailingIcon="check"
>
Example
</DropdownItem>
</li>
</DropdownMenu>
)
}
export const LeadingIcon = (args: Args) => {
return (
<DropdownMenu show>
<OLDropdownMenuItem
disabled={args.disabled}
eventKey="1"
href="#/action-1"
leadingIcon="view_column_2"
>
Editor & PDF
</OLDropdownMenuItem>
<OLDropdownMenuItem
active
eventKey="2"
href="#/action-2"
leadingIcon="terminal"
>
Editor only
</OLDropdownMenuItem>
<OLDropdownMenuItem
eventKey="3"
href="#/action-3"
leadingIcon="picture_as_pdf"
>
PDF only
</OLDropdownMenuItem>
<OLDropdownMenuItem
eventKey="4"
href="#/action-4"
leadingIcon="select_window"
>
PDF in separate tab
</OLDropdownMenuItem>
<OLDropdownMenuItem
eventKey="5"
href="#/action-5"
leadingIcon="align_space_even"
description="Some description"
>
With a description
</OLDropdownMenuItem>
<OLDropdownMenuItem
eventKey="6"
href="#/action-6"
leadingIcon="align_space_even"
className="dropdown-item-material-icon-small"
>
Small icon
</OLDropdownMenuItem>
</DropdownMenu>
)
}
export const TrailingIcon = (args: Args) => {
return (
<DropdownMenu show>
<OLDropdownMenuItem eventKey="1" href="#/action-1" trailingIcon="check">
Tick
</OLDropdownMenuItem>
<OLDropdownMenuItem
eventKey="2"
href="#/action-2"
trailingIcon="check"
description="Some description"
>
With a description
</OLDropdownMenuItem>
<OLDropdownMenuItem
eventKey="3"
href="#/action-3"
leadingIcon="align_space_even"
trailingIcon="check"
description="Some description"
>
With a leading icon
</OLDropdownMenuItem>
</DropdownMenu>
)
}
const meta: Meta<typeof DropdownMenu> = {
title: 'Shared / Components / DropdownMenu',
component: DropdownMenu,
argTypes: {
disabled: {
control: 'boolean',
},
show: {
table: {
disable: true,
},
},
},
}
export default meta

View File

@@ -0,0 +1,62 @@
import { useRef, useLayoutEffect } from 'react'
import { Form } from 'react-bootstrap-5'
import type { Meta, StoryObj } from '@storybook/react'
const meta: Meta<(typeof Form)['Check']> = {
title: 'Shared / Components / Form',
component: Form.Check,
argTypes: {
id: {
table: {
disable: true,
},
},
label: {
table: {
disable: true,
},
},
defaultChecked: {
table: {
disable: true,
},
},
},
}
export default meta
type Story = StoryObj<(typeof Form)['Check']>
export const Checkbox: Story = {
args: {
id: 'id-1',
label: 'Label',
disabled: false,
},
}
export const CheckboxChecked: Story = {
args: {
id: 'id-1',
label: 'Label',
disabled: false,
defaultChecked: true,
},
}
export const CheckboxIndeterminate = (args: Story['args']) => {
const ref = useRef<HTMLInputElement>()
useLayoutEffect(() => {
if (ref.current) {
ref.current.indeterminate = true
}
}, [])
return <Form.Check ref={ref} {...args} />
}
CheckboxIndeterminate.args = {
id: 'id-2',
label: 'Label',
disabled: false,
}

View File

@@ -0,0 +1,300 @@
import { Form } from 'react-bootstrap-5'
import type { Meta, StoryObj } from '@storybook/react'
import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
import MaterialIcon from '@/shared/components/material-icon'
import FormFeedback from '@/features/ui/components/bootstrap-5/form/form-feedback'
const meta: Meta<React.ComponentProps<typeof FormControl>> = {
title: 'Shared / Components / Form / Input',
component: FormControl,
}
export default meta
type Story = StoryObj<React.ComponentProps<typeof FormControl>>
export const Default: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl defaultValue="Large input" size="lg" {...args} />
<FormText>Helper</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl defaultValue="Regular input" {...args} />
<FormText>Helper</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl defaultValue="Small input" size="sm" {...args} />
<FormText>Helper</FormText>
</FormGroup>
</>
)
},
}
Default.args = {
disabled: false,
}
export const Info: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Large input"
size="lg"
{...args}
/>
<FormText type="info">Info</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Regular input"
{...args}
/>
<FormText type="info">Info</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Small input"
size="sm"
{...args}
/>
<FormText type="info">Info</FormText>
</FormGroup>
</>
)
},
}
export const Error: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Large input"
size="lg"
isInvalid
{...args}
/>
<FormFeedback type="invalid">Error</FormFeedback>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Regular input"
isInvalid
{...args}
/>
<FormFeedback type="invalid">Error</FormFeedback>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Small input"
size="sm"
isInvalid
{...args}
/>
<FormFeedback type="invalid">Error</FormFeedback>
</FormGroup>
</>
)
},
}
export const Warning: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Large input"
size="lg"
{...args}
/>
<FormText type="warning">Warning</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Regular input"
{...args}
/>
<FormText type="warning">Warning</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Small input"
size="sm"
{...args}
/>
<FormText type="warning">Warning</FormText>
</FormGroup>
</>
)
},
}
export const Success: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Large input"
size="lg"
{...args}
/>
<FormText type="success">Success</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Regular input"
{...args}
/>
<FormText type="success">Success</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
placeholder="Placeholder"
defaultValue="Small input"
size="sm"
{...args}
/>
<FormText type="success">Success</FormText>
</FormGroup>
</>
)
},
}
export const WithIcons: Story = {
render: args => {
const handleClear = () => {
alert('Clicked clear button')
}
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
type="text"
placeholder="Search"
prepend={<MaterialIcon type="search" />}
append={
<button
type="button"
className="form-control-search-clear-btn"
onClick={handleClear}
>
<MaterialIcon type="clear" />
</button>
}
size="lg"
{...args}
/>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl
type="text"
placeholder="Search"
prepend={<MaterialIcon type="search" />}
append={
<button
type="button"
className="form-control-search-clear-btn"
onClick={handleClear}
>
<MaterialIcon type="clear" />
</button>
}
{...args}
/>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
type="text"
placeholder="Search"
prepend={<MaterialIcon type="search" />}
append={
<button
type="button"
className="form-control-search-clear-btn"
onClick={handleClear}
>
<MaterialIcon type="clear" />
</button>
}
size="sm"
{...args}
/>
</FormGroup>
<br />
<hr />
<FormGroup controlId="id-3">
<Form.Label>Disabled state</Form.Label>
<FormControl
type="text"
placeholder="Search"
prepend={<MaterialIcon type="search" />}
append={
<button
type="button"
className="form-control-search-clear-btn"
onClick={handleClear}
disabled
>
<MaterialIcon type="clear" />
</button>
}
disabled
{...args}
/>
</FormGroup>
</>
)
},
}

View File

@@ -0,0 +1,51 @@
import { Form } from 'react-bootstrap-5'
import type { Meta, StoryObj } from '@storybook/react'
const meta: Meta<(typeof Form)['Check']> = {
title: 'Shared / Components / Form',
component: Form.Check,
argTypes: {
id: {
table: {
disable: true,
},
},
label: {
table: {
disable: true,
},
},
type: {
table: {
disable: true,
},
},
defaultChecked: {
table: {
disable: true,
},
},
},
}
export default meta
type Story = StoryObj<(typeof Form)['Check']>
export const Radio: Story = {
args: {
id: 'id-1',
type: 'radio',
label: 'Label',
disabled: false,
},
}
export const RadioChecked: Story = {
args: {
id: 'id-1',
type: 'radio',
label: 'Label',
disabled: false,
defaultChecked: true,
},
}

View File

@@ -0,0 +1,220 @@
import { Form, FormSelectProps } from 'react-bootstrap-5'
import type { Meta, StoryObj } from '@storybook/react'
import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
const meta: Meta<FormSelectProps> = {
title: 'Shared / Components / Form / Select',
component: Form.Select,
}
export default meta
type Story = StoryObj<FormSelectProps>
export const Default: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<Form.Select size="lg" {...args}>
<option>Large select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText>Helper</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<Form.Select {...args}>
<option>Regular select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText>Helper</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<Form.Select size="sm" {...args}>
<option>Small select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText>Helper</FormText>
</FormGroup>
</>
)
},
}
Default.args = {
disabled: false,
}
export const Info: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<Form.Select size="lg" {...args}>
<option>Large select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="info">Info</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<Form.Select {...args}>
<option>Regular select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="info">Info</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<Form.Select size="sm" {...args}>
<option>Small select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="info">Info</FormText>
</FormGroup>
</>
)
},
}
export const Error: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<Form.Select size="lg" isInvalid {...args}>
<option>Large select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="error">Error</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<Form.Select isInvalid {...args}>
<option>Regular select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="error">Error</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<Form.Select size="sm" isInvalid {...args}>
<option>Small select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="error">Error</FormText>
</FormGroup>
</>
)
},
}
export const Warning: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<Form.Select size="lg" {...args}>
<option>Large select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="warning">Warning</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<Form.Select {...args}>
<option>Regular select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="warning">Warning</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<Form.Select size="sm" {...args}>
<option>Small select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="warning">Warning</FormText>
</FormGroup>
</>
)
},
}
export const Success: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<Form.Select size="lg" {...args}>
<option>Large select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="success">Success</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<Form.Select {...args}>
<option>Regular select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="success">Success</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<Form.Select size="sm" {...args}>
<option>Small select</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
</Form.Select>
<FormText type="success">Success</FormText>
</FormGroup>
</>
)
},
}

View File

@@ -0,0 +1,227 @@
import { Form } from 'react-bootstrap-5'
import type { Meta, StoryObj } from '@storybook/react'
import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
const meta: Meta<React.ComponentProps<typeof FormControl>> = {
title: 'Shared / Components / Form / Textarea',
component: FormControl,
}
export default meta
type Story = StoryObj<React.ComponentProps<typeof FormControl>>
export const Default: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
defaultValue="Large input"
size="lg"
{...args}
/>
<FormText>Helper</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl as="textarea" defaultValue="Regular input" {...args} />
<FormText>Helper</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
defaultValue="Small input"
size="sm"
{...args}
/>
<FormText>Helper</FormText>
</FormGroup>
</>
)
},
}
Default.args = {
disabled: false,
}
export const Info: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Large input"
size="lg"
{...args}
/>
<FormText type="info">Info</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Regular input"
{...args}
/>
<FormText type="info">Info</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Small input"
size="sm"
{...args}
/>
<FormText type="info">Info</FormText>
</FormGroup>
</>
)
},
}
export const Error: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Large input"
size="lg"
isInvalid
{...args}
/>
<FormText type="error">Error</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Regular input"
isInvalid
{...args}
/>
<FormText type="error">Error</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Small input"
size="sm"
isInvalid
{...args}
/>
<FormText type="error">Error</FormText>
</FormGroup>
</>
)
},
}
export const Warning: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Large input"
size="lg"
{...args}
/>
<FormText type="warning">Warning</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Regular input"
{...args}
/>
<FormText type="warning">Warning</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Small input"
size="sm"
{...args}
/>
<FormText type="warning">Warning</FormText>
</FormGroup>
</>
)
},
}
export const Success: Story = {
render: args => {
return (
<>
<FormGroup controlId="id-1">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Large input"
size="lg"
{...args}
/>
<FormText type="success">Success</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-2">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Regular input"
{...args}
/>
<FormText type="success">Success</FormText>
</FormGroup>
<hr />
<FormGroup controlId="id-3">
<Form.Label>Label</Form.Label>
<FormControl
as="textarea"
placeholder="Placeholder"
defaultValue="Small input"
size="sm"
{...args}
/>
<FormText type="success">Success</FormText>
</FormGroup>
</>
)
},
}

View File

@@ -0,0 +1,40 @@
import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
import type { Meta } from '@storybook/react'
import { useTranslation } from 'react-i18next'
type Args = React.ComponentProps<typeof IconButton>
export const Icon = (args: Args) => {
const { t } = useTranslation()
return <IconButton accessibilityLabel={t('add')} disabled {...args} />
}
const meta: Meta<typeof IconButton> = {
title: 'Shared / Components / IconButton',
component: IconButton,
args: {
disabled: false,
icon: 'add',
isLoading: false,
},
argTypes: {
size: {
control: 'radio',
options: ['small', 'default', 'large'],
},
variant: {
control: 'radio',
options: [
'primary',
'secondary',
'ghost',
'danger',
'danger-ghost',
'premium',
],
},
},
}
export default meta

View File

@@ -0,0 +1,29 @@
import { Container, Row, Col } from 'react-bootstrap-5'
import { Meta } from '@storybook/react'
type Args = React.ComponentProps<typeof Row>
export const ColumnRowCell = (args: Args) => {
return (
<Container style={{ border: '3px solid green' }}>
<Row {...args} style={{ border: '1px solid black' }}>
<Col sm={6} style={{ border: '1px solid red' }}>
<div style={{ backgroundColor: '#ddd' }}>Col 1</div>
</Col>
<Col sm={6} style={{ border: '1px solid red' }}>
<div style={{ backgroundColor: '#ddd' }}>Col 2</div>
</Col>
<Col sm={{ span: 10, offset: 2 }} style={{ border: '1px solid red' }}>
<div style={{ backgroundColor: '#ddd' }}>Col 3</div>
</Col>
</Row>
</Container>
)
}
const meta: Meta<typeof Row> = {
title: 'Shared / Components / Column-Row-Cell',
component: Row,
}
export default meta

View File

@@ -0,0 +1,61 @@
import { Fragment } from 'react'
import type { Meta } from '@storybook/react'
import { useTranslation } from 'react-i18next'
import {
Dropdown,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import Button from '@/features/ui/components/bootstrap-5/button'
import { ButtonGroup } from 'react-bootstrap-5'
type Args = React.ComponentProps<typeof Dropdown>
export const Sizes = (args: Args) => {
const { t } = useTranslation()
const sizes = {
Large: 'lg',
Regular: undefined,
Small: 'sm',
} as const
const variants = ['primary', 'secondary', 'danger'] as const
return Object.entries(sizes).map(([label, size]) => (
<Fragment key={`${label}-${size}`}>
<h4>{label}</h4>
<div style={{ display: 'inline-flex', gap: '10px' }}>
{variants.map(variant => (
<Dropdown key={variant} as={ButtonGroup}>
<Button variant={variant} size={size}>
Split Button
</Button>
<DropdownToggle
split
variant={variant}
id={`split-btn-${variant}-${size}`}
size={size}
aria-label={t('expand')}
/>
<DropdownMenu>
<DropdownHeader>Header</DropdownHeader>
<DropdownItem as="button">Action 1</DropdownItem>
<DropdownItem as="button">Action 2</DropdownItem>
<DropdownItem as="button">Action 3</DropdownItem>
</DropdownMenu>
</Dropdown>
))}
</div>
</Fragment>
))
}
const meta: Meta<typeof Dropdown> = {
title: 'Shared/Components/SplitButton',
component: Dropdown,
args: {
align: { sm: 'start' },
},
}
export default meta

Some files were not shown because too many files have changed in this diff Show More