Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add free-drawing page #7266

Merged
merged 19 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/controllers/api/bootcamp/drawings_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class API::Bootcamp::DrawingsController < API::Bootcamp::BaseController
before_action :use_drawing

def update
@drawing.update(code: params[:code]) if params[:code].present?
@drawing.update(title: params[:title]) if params[:title].present?

render json: {}, status: :ok
end

private
def use_drawing
@drawing = current_user.bootcamp_drawings.find_by!(uuid: params[:uuid])
end
end
3 changes: 2 additions & 1 deletion app/css/bootcamp/components/site-header.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
}
}

body.controller-exercises.action-edit {
body.namespace-bootcamp.controller-exercises.action-edit,
body.namespace-bootcamp.controller-drawings.action-edit {
.c-site-header {
display: none;
}
Expand Down
7 changes: 4 additions & 3 deletions app/helpers/react_components/bootcamp/drawing_page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@ def to_s
super(id, data)
end

def id = "bootcamp-solve-exercise-page"
def id = "bootcamp-drawing-page"

def data
{
drawing: {
uuid: drawing.uuid
uuid: drawing.uuid,
title: drawing.title
},
code: {
code: drawing.code,
stored_at: drawing.updated_at
},
links: {
update_code: Exercism::Routes.api_bootcamp_drawing_url(drawing),
drawings_index: Exercism::Routes.bootcamp_drawings_url(only_path: true)
drawings_index: Exercism::Routes.bootcamp_project_path(:drawing)
}
}
end
Expand Down
114 changes: 114 additions & 0 deletions app/javascript/components/bootcamp/DrawingPage/DrawingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React, { useMemo, useState } from 'react'
import { Header, StudentCodeGetter } from './Header/Header'
import {
Resizer,
useResizablePanels,
} from '../SolveExercisePage/hooks/useResize'
import { CodeMirror } from '../SolveExercisePage/CodeMirror/CodeMirror'
import ErrorBoundary from '../common/ErrorBoundary/ErrorBoundary'
import { useDrawingEditorHandler } from './useDrawingEditorHandler'
import { useLocalStorage } from '@uidotdev/usehooks'
import Scrubber from './Scrubber/Scrubber'
import { debounce } from 'lodash'
import { useSetupDrawingPage } from './useSetupDrawingPage'

export default function DrawingPage({
drawing,
code,
links,
}: DrawingPageProps) {
const [savingStateLabel, setSavingStateLabel] = useState<string>('')

const {
primarySize: LHSWidth,
secondarySize: RHSWidth,
handleMouseDown,
} = useResizablePanels({
initialSize: 800,
direction: 'horizontal',
localStorageId: 'drawing-page-lhs',
})

const {
handleRunCode,
handleEditorDidMount,
getStudentCode,
editorViewRef,
viewContainerRef,
animationTimeline,
frames,
} = useDrawingEditorHandler({ code, links, drawing })

const [editorLocalStorageValue, setEditorLocalStorageValue] = useLocalStorage(
'bootcamp-editor-value-' + drawing.uuid,
{ code: code.code, storedAt: code.storedAt }
)

useSetupDrawingPage({
code,
editorLocalStorageValue,
setEditorLocalStorageValue,
})

const patchCodeOnDebounce = useMemo(() => {
return debounce(() => {
setSavingStateLabel('Saving...')
patchDrawingCode(links, getStudentCode).then(() =>
setSavingStateLabel('Saved')
)
}, 5000)
}, [setEditorLocalStorageValue])

return (
<div id="bootcamp-solve-exercise-page">
<Header
links={links}
savingStateLabel={savingStateLabel}
drawing={drawing}
/>
<div className="page-body">
<div style={{ width: LHSWidth }} className="page-body-lhs">
<ErrorBoundary>
<CodeMirror
style={{ height: `100%` }}
ref={editorViewRef}
editorDidMount={handleEditorDidMount}
handleRunCode={handleRunCode}
setEditorLocalStorageValue={setEditorLocalStorageValue}
onEditorChangeCallback={patchCodeOnDebounce}
/>
<Scrubber animationTimeline={animationTimeline} frames={frames} />
</ErrorBoundary>
</div>
<Resizer direction="vertical" handleMouseDown={handleMouseDown} />
{/* RHS */}
<div className="page-body-rhs" style={{ width: RHSWidth }}>
<div ref={viewContainerRef} id="view-container" />
</div>
</div>
</div>
)
}

async function patchDrawingCode(
links: DrawingPageProps['links'],
getStudentCode: StudentCodeGetter
) {
const studentCode = getStudentCode()

const response = await fetch(links.updateCode, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: studentCode,
}),
})

if (!response.ok) {
throw new Error('Failed to save code')
}

return response.json()
}
109 changes: 109 additions & 0 deletions app/javascript/components/bootcamp/DrawingPage/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { useCallback, useState } from 'react'
import { wrapWithErrorBoundary } from '@/components/bootcamp/common/ErrorBoundary/wrapWithErrorBoundary'
import { assembleClassNames } from '@/utils/assemble-classnames'

import { GraphicalIcon } from '@/components/common/GraphicalIcon'

export type StudentCodeGetter = () => string | undefined

const DEFAULT_SAVE_BUTTON_LABEL = 'Save'
function _Header({
links,
savingStateLabel,
drawing,
}: { savingStateLabel: string } & Pick<DrawingPageProps, 'links' | 'drawing'>) {
const [titleInputValue, setTitleInputValue] = useState(drawing.title)
const [editMode, setEditMode] = useState(false)
const [titleSavingStateLabel, setTitleSavingStateLabel] = useState<string>(
DEFAULT_SAVE_BUTTON_LABEL
)

const handleSaveTitle = useCallback(() => {
setTitleSavingStateLabel('Saving...')
patchDrawingTitle(links, titleInputValue)
.then(() => {
setTitleSavingStateLabel(DEFAULT_SAVE_BUTTON_LABEL)
setEditMode(false)
})
.catch(() => setTitleSavingStateLabel('Try again'))
}, [links, titleInputValue])

return (
<div className="page-header">
<div className="ident">
<GraphicalIcon icon="logo" category="bootcamp" />
<div>
<strong className="font-semibold">Exercism</strong> Bootcamp
</div>
</div>
<div className="ml-auto flex items-center gap-12">
{savingStateLabel && (
<span className="text-xs text-gray-500 font-semibold mr-4">
{savingStateLabel}
</span>
)}
<div className="flex items-center gap-12">
{editMode ? (
<>
<button onClick={handleSaveTitle} className="btn-primary btn-xxs">
{titleSavingStateLabel}
</button>
<button
className="btn-secondary btn-xxs"
onClick={() => setEditMode(false)}
>
Cancel
</button>
<input
value={titleInputValue}
onChange={(e) => {
setTitleInputValue(e.target.value)
setTitleSavingStateLabel(DEFAULT_SAVE_BUTTON_LABEL)
}}
type="text"
style={{ all: 'unset', borderBottom: '1px solid' }}
/>
</>
) : (
<>
<button onClick={() => setEditMode(true)}>
<GraphicalIcon icon="edit" height={15} width={15} />
</button>
<span>{titleInputValue}</span>
</>
)}
</div>

<a
href={links.drawingsIndex}
className={assembleClassNames('btn-secondary btn-xxs')}
>
Back to drawings
</a>
</div>
</div>
)
}

export const Header = wrapWithErrorBoundary(_Header)

async function patchDrawingTitle(
links: DrawingPageProps['links'],
title: string
) {
const response = await fetch(links.updateCode, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title,
}),
})

if (!response.ok) {
throw new Error('Failed to save code')
}

return response.json()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react'
import { useCallback } from 'react'
import useEditorStore from '../../SolveExercisePage/store/editorStore'
import useTestStore from '../../SolveExercisePage/store/testStore'

export function InformationWidgetToggleButton({
disabled,
}: {
disabled: boolean
}) {
const {
toggleShouldShowInformationWidget,
shouldShowInformationWidget,
setHighlightedLine,
} = useEditorStore()
const { inspectedTestResult } = useTestStore()
const handleToggleShouldShowInformationWidget = useCallback(() => {
toggleShouldShowInformationWidget()

if (!inspectedTestResult) return

// if there is only one frame..
if (inspectedTestResult.frames.length === 1) {
// ...and we are about to show information widget
if (!shouldShowInformationWidget) {
// highlight relevant line
setHighlightedLine(inspectedTestResult.frames[0].line)
} else {
// if toggling's next step is off, remove highlight
setHighlightedLine(0)
}
}
}, [shouldShowInformationWidget, inspectedTestResult])
return (
<label className="switch">
<input
disabled={disabled}
type="checkbox"
onChange={handleToggleShouldShowInformationWidget}
checked={shouldShowInformationWidget}
/>
<span className="slider round"></span>
</label>
)
}
Loading
Loading