From 89a4c476eee6503d40e62b0d4ff5d7e66c4dd629 Mon Sep 17 00:00:00 2001 From: John Ryan Date: Tue, 6 Apr 2021 10:35:17 -0700 Subject: [PATCH 1/8] Codelab UI - initial setup (#1807) * initial setup * setup YAML parsing * format * load codelab files * move logic into CodelabUi class, start stubbing out code * Set up right panels * Add CodeMirror editor * Add CodelabState * fix HTML * Add step buttons * remove print * Load code and update when the step changes * Add run button and console panel * add flutter_codelab * Ignore /example/codelabs directory in analysis and builders * Add tabs to console panel * Get flutter compilation working * add TODO * exclude codelabs in dart_source_cleanup build step * remove whitespace --- analysis_options.yaml | 2 +- build.yaml | 16 + lib/codelab.dart | 361 ++++++++++++++++++ lib/codelabs/codelabs.dart | 5 + lib/codelabs/src/codelab.dart | 10 + lib/codelabs/src/fetcher.dart | 5 + lib/codelabs/src/github.dart | 23 ++ lib/codelabs/src/meta.dart | 62 +++ lib/codelabs/src/meta.g.dart | 91 +++++ lib/codelabs/src/step.dart | 8 + lib/codelabs/src/web_server.dart | 51 +++ lib/editing/codemirror_options.dart | 18 + lib/embed.dart | 20 +- lib/playground.dart | 19 +- lib/scss/shared.scss | 13 + lib/util/query_params.dart | 4 + pubspec.lock | 20 +- pubspec.yaml | 3 + web/codelabs.html | 223 +++++++++++ web/example/codelabs/dart/meta.yaml | 9 + .../codelabs/dart/step_01/instructions.md | 17 + .../codelabs/dart/step_01/snippet.dart | 3 + .../codelabs/dart/step_01/solution.dart | 3 + .../codelabs/dart/step_02/instructions.md | 3 + .../codelabs/dart/step_02/snippet.dart | 5 + web/example/codelabs/flutter/meta.yaml | 9 + .../codelabs/flutter/step_01/instructions.md | 17 + .../codelabs/flutter/step_01/snippet.dart | 62 +++ .../codelabs/flutter/step_01/solution.dart | 68 ++++ .../codelabs/flutter/step_02/instructions.md | 3 + .../codelabs/flutter/step_02/snippet.dart | 5 + web/scripts/codelabs.dart | 8 + web/styles/codelabs.scss | 234 ++++++++++++ web/styles/styles.scss | 14 - 34 files changed, 1359 insertions(+), 55 deletions(-) create mode 100644 lib/codelab.dart create mode 100644 lib/codelabs/codelabs.dart create mode 100644 lib/codelabs/src/codelab.dart create mode 100644 lib/codelabs/src/fetcher.dart create mode 100644 lib/codelabs/src/github.dart create mode 100644 lib/codelabs/src/meta.dart create mode 100644 lib/codelabs/src/meta.g.dart create mode 100644 lib/codelabs/src/step.dart create mode 100644 lib/codelabs/src/web_server.dart create mode 100644 lib/editing/codemirror_options.dart create mode 100644 web/codelabs.html create mode 100644 web/example/codelabs/dart/meta.yaml create mode 100644 web/example/codelabs/dart/step_01/instructions.md create mode 100644 web/example/codelabs/dart/step_01/snippet.dart create mode 100644 web/example/codelabs/dart/step_01/solution.dart create mode 100644 web/example/codelabs/dart/step_02/instructions.md create mode 100644 web/example/codelabs/dart/step_02/snippet.dart create mode 100644 web/example/codelabs/flutter/meta.yaml create mode 100644 web/example/codelabs/flutter/step_01/instructions.md create mode 100644 web/example/codelabs/flutter/step_01/snippet.dart create mode 100644 web/example/codelabs/flutter/step_01/solution.dart create mode 100644 web/example/codelabs/flutter/step_02/instructions.md create mode 100644 web/example/codelabs/flutter/step_02/snippet.dart create mode 100644 web/scripts/codelabs.dart create mode 100644 web/styles/codelabs.scss diff --git a/analysis_options.yaml b/analysis_options.yaml index 834f27b8c..ceb88a554 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -10,7 +10,7 @@ analyzer: - 'lib/bower/**' - 'lib/src/protos/**' - 'third_party/pkg/**' - + - 'web/example/codelabs/**' linter: rules: - await_only_futures diff --git a/build.yaml b/build.yaml index 9ce521afd..65f302e1b 100644 --- a/build.yaml +++ b/build.yaml @@ -1,8 +1,24 @@ targets: $default: builders: + # Don't clean up codelab examples, they need to be served. + build_web_compilers:dart_source_cleanup: + generate_for: + exclude: + - web/example/codelabs/**.dart + build_web_compilers|entrypoint: + # Don't compile codelab examples. + generate_for: + exclude: + - web/example/codelabs/**.dart options: dart2js_args: - --minify - --fast-startup + + json_serializable: + # Ignore codelab samples. + generate_for: + exclude: + - web/example/codelabs/**.dart diff --git a/lib/codelab.dart b/lib/codelab.dart new file mode 100644 index 000000000..4330ed133 --- /dev/null +++ b/lib/codelab.dart @@ -0,0 +1,361 @@ +import 'dart:async'; +import 'dart:html' hide Console; + +import 'package:dart_pad/util/query_params.dart'; +import 'package:markdown/markdown.dart' as markdown; +import 'package:mdc_web/mdc_web.dart'; +import 'package:split/split.dart'; + +import 'codelabs/codelabs.dart'; +import 'core/dependencies.dart'; +import 'core/modules.dart'; +import 'dart_pad.dart'; +import 'editing/codemirror_options.dart'; +import 'editing/editor.dart'; +import 'editing/editor_codemirror.dart'; +import 'elements/button.dart'; +import 'elements/console.dart'; +import 'elements/counter.dart'; +import 'elements/elements.dart'; +import 'elements/material_tab_controller.dart'; +import 'modules/codemirror_module.dart'; +import 'modules/dart_pad_module.dart'; +import 'modules/dartservices_module.dart'; +import 'services/common.dart'; +import 'services/dartservices.dart'; +import 'services/execution.dart'; +import 'services/execution_iframe.dart'; +import 'src/ga.dart'; + +CodelabUi _codelabUi; + +CodelabUi get codelabUi => _codelabUi; + +IFrameElement get _frame => querySelector('#frame') as IFrameElement; + +void init() { + _codelabUi = CodelabUi(); +} + +class CodelabUi { + CodelabState _codelabState; + Splitter splitter; + Splitter rightSplitter; + Editor editor; + DElement stepLabel; + DElement previousStepButton; + DElement nextStepButton; + Console _console; + MDCButton runButton; + MaterialTabController consolePanelTabController; + Counter unreadConsoleCounter; + + CodelabUi() { + _init(); + } + + DivElement get _editorHost => querySelector('#editor-host') as DivElement; + + DivElement get _consoleElement => + querySelector('#output-panel-content') as DivElement; + + DivElement get _documentationElement => + querySelector('#doc-panel') as DivElement; + + IFrameElement get _frame => querySelector('#frame') as IFrameElement; + + Future _init() async { + await _loadCodelab(); + _initHeader(); + _updateInstructions(); + await _initModules(); + _initCodelabUi(); + _initEditor(); + _initSplitters(); + _initStepButtons(); + _initStepListener(); + _initConsoles(); + _initButtons(); + _updateCode(); + _initTabs(); + } + + Future _initModules() async { + var modules = ModuleManager(); + + modules.register(DartPadModule()); + modules.register(DartServicesModule()); + modules.register(CodeMirrorModule()); + + await modules.start(); + } + + void _initEditor() { + // Set up CodeMirror + editor = (editorFactory as CodeMirrorFactory) + .createFromElement(_editorHost, options: codeMirrorOptions) + ..theme = 'darkpad' + ..mode = 'dart' + ..showLineNumbers = true; + } + + void _initCodelabUi() { + // Set up the iframe. + deps[ExecutionService] = ExecutionServiceIFrame(_frame); + executionService.onStdout.listen(_showOutput); + executionService.onStderr.listen((m) => _showOutput(m, error: true)); + // Set up Google Analytics. + deps[Analytics] = Analytics(); + + // Use null safety for codelabs + (deps[DartservicesApi] as DartservicesApi).rootUrl = nullSafetyServerUrl; + } + + Future _loadCodelab() async { + var fetcher = await _getFetcher(); + _codelabState = CodelabState(await fetcher.getCodelab()); + } + + void _initSplitters() { + var stepsPanel = querySelector('#steps-panel'); + var rightPanel = querySelector('#right-panel'); + var editorPanel = querySelector('#editor-panel'); + var outputPanel = querySelector('#output-panel'); + splitter = flexSplit( + [stepsPanel, rightPanel], + horizontal: true, + gutterSize: 6, + sizes: const [50, 50], + minSize: [100, 100], + ); + rightSplitter = flexSplit( + [editorPanel, outputPanel], + horizontal: false, + gutterSize: 6, + sizes: const [50, 50], + minSize: [100, 100], + ); + + // Resize Codemirror when the size of the panel changes. This keeps the + // virtual scrollbar in sync with the size of the panel. + ResizeObserver((entries, observer) { + editor.resize(); + }).observe(editorPanel); + } + + void _initHeader() { + querySelector('#codelab-name').text = _codelabState.codelab.name; + } + + void _initStepButtons() { + stepLabel = DElement(querySelector('#steps-label')); + previousStepButton = DElement(querySelector('#previous-step-btn')) + ..onClick.listen((event) { + _codelabState.currentStepIndex--; + }); + nextStepButton = DElement(querySelector('#next-step-btn')) + ..onClick.listen((event) { + _codelabState.currentStepIndex++; + }); + _updateStepButtons(); + } + + void _initStepListener() { + _codelabState.onStepChanged.listen((event) { + _updateInstructions(); + _updateStepButtons(); + _updateCode(); + }); + } + + void _initConsoles() { + _console = Console(DElement(_consoleElement)); + unreadConsoleCounter = + Counter(querySelector('#unread-console-counter') as SpanElement); + } + + void _initButtons() { + runButton = MDCButton(querySelector('#run-button') as ButtonElement) + ..onClick.listen((_) { + _handleRun(); + }); + } + + void _updateCode() { + editor.document.updateValue(_codelabState.currentStep.snippet); + } + + void _initTabs() { + var consoleTabBar = querySelector('#web-tab-bar'); + consolePanelTabController = MaterialTabController(MDCTabBar(consoleTabBar)); + for (var name in ['ui-output', 'console', 'documentation']) { + consolePanelTabController.registerTab( + TabElement(querySelector('#$name-tab'), name: name, onSelect: () { + _changeConsoleTab(name); + })); + } + // Set the current tab to UI Output + _changeConsoleTab('ui-output'); + } + + void _changeConsoleTab(String name) { + if (name == 'ui-output') { + _frame.hidden = false; + _consoleElement.hidden = true; + _documentationElement.hidden = true; + } else if (name == 'console') { + _frame.hidden = true; + _consoleElement.hidden = false; + _documentationElement.hidden = true; + } else if (name == 'documentation') { + _frame.hidden = true; + _consoleElement.hidden = true; + _documentationElement.hidden = false; + } + } + + void _updateInstructions() { + var div = querySelector('#markdown-content'); + div.children.clear(); + div.innerHtml = + markdown.markdownToHtml(_codelabState.currentStep.instructions); + } + + void _updateStepButtons() { + stepLabel.text = 'Step ${_codelabState.currentStepIndex + 1}'; + previousStepButton.toggleAttr('disabled', !_codelabState.hasPreviousStep); + nextStepButton.toggleAttr('disabled', !_codelabState.hasNextStep); + } + + Future _getFetcher() async { + var webServer = queryParams.webServer; + if (webServer != null && webServer.isNotEmpty) { + var uri = Uri.parse(webServer); + return WebServerCodelabFetcher(uri); + } + var ghOwner = queryParams.githubOwner; + var ghRepo = queryParams.githubRepo; + var ghRef = queryParams.githubRef; + var ghPath = queryParams.githubPath; + if (ghOwner != null && + ghOwner.isNotEmpty && + ghRepo != null && + ghRepo.isNotEmpty && + ghRef != null && + ghRef.isNotEmpty && + ghPath != null && + ghPath.isNotEmpty) { + return GithubCodelabFetcher( + owner: ghOwner, + repo: ghRepo, + ref: ghRef, + path: ghPath, + ); + } + throw ('Invalid parameters provided. Use either "webserver" or ' + '"gh_owner", "gh_repo", "gh_ref", and "gh_path"'); + } + + void _handleRun() async { + ga.sendEvent('main', 'run'); + runButton.disabled = true; + + var compilationTimer = Stopwatch()..start(); + + final compileRequest = CompileRequest()..source = editor.document.value; + + try { + if (_codelabState.codelab.type == CodelabType.flutter) { + final response = await dartServices + .compileDDC(compileRequest) + .timeout(longServiceCallTimeout); + + ga.sendTiming( + 'action-perf', + 'compilation-e2e', + compilationTimer.elapsedMilliseconds, + ); + + _clearOutput(); + + await executionService.execute( + '', + '', + response.result, + modulesBaseUrl: response.modulesBaseUrl, + ); + } else { + final response = await dartServices + .compile(compileRequest) + .timeout(longServiceCallTimeout); + + ga.sendTiming( + 'action-perf', + 'compilation-e2e', + compilationTimer.elapsedMilliseconds, + ); + + _clearOutput(); + + await executionService.execute( + '', + '', + response.result, + ); + } + } catch (e) { + ga.sendException('${e.runtimeType}'); + final message = e is ApiRequestError ? e.message : '$e'; + _showSnackbar('Error compiling to JavaScript'); + _clearOutput(); + _showOutput('Error compiling to JavaScript:\n$message', error: true); + } finally { + runButton.disabled = false; + } + } + + void _clearOutput() { + _console.clear(); + unreadConsoleCounter.clear(); + } + + void _showOutput(String message, {bool error = false}) { + _console.showOutput(message, error: error); + if (consolePanelTabController.selectedTab.name != 'console') { + unreadConsoleCounter.increment(); + } + } + + void _showSnackbar(String message) { + var div = querySelector('.mdc-snackbar'); + var snackbar = MDCSnackbar(div)..labelText = message; + snackbar.open(); + } +} + +class CodelabState { + final StreamController _controller = StreamController.broadcast(); + int _currentStepIndex = 0; + + final Codelab codelab; + + CodelabState(this.codelab); + + Stream get onStepChanged => _controller.stream; + + Step get currentStep => codelab.steps[_currentStepIndex]; + + int get currentStepIndex => _currentStepIndex; + + set currentStepIndex(int stepIndex) { + if (stepIndex < 0 || stepIndex >= codelab.steps.length) { + throw ('Invalid step index: $stepIndex'); + } + _currentStepIndex = stepIndex; + _controller.add(codelab.steps[stepIndex]); + } + + bool get hasNextStep => _currentStepIndex < codelab.steps.length - 1; + + bool get hasPreviousStep => _currentStepIndex > 0; +} diff --git a/lib/codelabs/codelabs.dart b/lib/codelabs/codelabs.dart new file mode 100644 index 000000000..fbd04d098 --- /dev/null +++ b/lib/codelabs/codelabs.dart @@ -0,0 +1,5 @@ +export 'src/codelab.dart'; +export 'src/fetcher.dart'; +export 'src/github.dart'; +export 'src/step.dart'; +export 'src/web_server.dart'; diff --git a/lib/codelabs/src/codelab.dart b/lib/codelabs/src/codelab.dart new file mode 100644 index 000000000..a0bac48fc --- /dev/null +++ b/lib/codelabs/src/codelab.dart @@ -0,0 +1,10 @@ +import 'step.dart'; + +enum CodelabType { dart, flutter } + +class Codelab { + final String name; + final CodelabType type; + final List steps; + Codelab(this.name, this.type, this.steps); +} diff --git a/lib/codelabs/src/fetcher.dart b/lib/codelabs/src/fetcher.dart new file mode 100644 index 000000000..e67ea51ee --- /dev/null +++ b/lib/codelabs/src/fetcher.dart @@ -0,0 +1,5 @@ +import 'codelab.dart'; + +abstract class CodelabFetcher { + Future getCodelab(); +} diff --git a/lib/codelabs/src/github.dart b/lib/codelabs/src/github.dart new file mode 100644 index 000000000..868baa12c --- /dev/null +++ b/lib/codelabs/src/github.dart @@ -0,0 +1,23 @@ +import 'package:meta/meta.dart'; + +import 'codelab.dart'; +import 'fetcher.dart'; + +class GithubCodelabFetcher implements CodelabFetcher { + final String owner; + final String repo; + final String ref; + final String path; + + GithubCodelabFetcher({ + @required this.owner, + @required this.repo, + @required this.ref, + @required this.path, + }); + + @override + Future getCodelab() async { + throw UnsupportedError('Github codelabs are not supported yet.'); + } +} diff --git a/lib/codelabs/src/meta.dart b/lib/codelabs/src/meta.dart new file mode 100644 index 000000000..a32bec814 --- /dev/null +++ b/lib/codelabs/src/meta.dart @@ -0,0 +1,62 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:meta/meta.dart'; + +import 'codelab.dart'; + +part 'meta.g.dart'; + +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class Meta { + @JsonKey(required: true) + final String name; + + @JsonKey( + required: false, + defaultValue: CodelabType.dart, + ) + final CodelabType type; + + @JsonKey(required: true) + final List steps; + + Meta(this.name, this.steps, {this.type}); + + factory Meta.fromJson(Map json) => _$MetaFromJson(json); + + Map toJson() => _$MetaToJson(this); + + @override + String toString() => ' name: $name steps: $steps'; +} + +@JsonSerializable( + anyMap: true, + checked: true, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class StepConfiguration { + final String name; + final String directory; + final bool hasSolution; + + StepConfiguration({ + @required this.name, + @required this.directory, + this.hasSolution = false, + }); + + factory StepConfiguration.fromJson(Map json) => + _$StepConfigurationFromJson(json); + + Map toJson() => _$StepConfigurationToJson(this); + + @override + String toString() => + ' name: $name has_solution: $hasSolution'; +} diff --git a/lib/codelabs/src/meta.g.dart b/lib/codelabs/src/meta.g.dart new file mode 100644 index 000000000..bb2b43653 --- /dev/null +++ b/lib/codelabs/src/meta.g.dart @@ -0,0 +1,91 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'meta.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Meta _$MetaFromJson(Map json) { + return $checkedNew('Meta', json, () { + $checkKeys(json, + allowedKeys: const ['name', 'type', 'steps'], + requiredKeys: const ['name', 'steps']); + final val = Meta( + $checkedConvert(json, 'name', (v) => v as String), + $checkedConvert( + json, + 'steps', + (v) => (v as List) + ?.map((e) => + e == null ? null : StepConfiguration.fromJson(e as Map)) + ?.toList()), + type: $checkedConvert(json, 'type', + (v) => _$enumDecodeNullable(_$CodelabTypeEnumMap, v)) ?? + CodelabType.dart, + ); + return val; + }); +} + +Map _$MetaToJson(Meta instance) => { + 'name': instance.name, + 'type': _$CodelabTypeEnumMap[instance.type], + 'steps': instance.steps, + }; + +T _$enumDecode( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + throw ArgumentError('A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}'); + } + + final value = enumValues.entries + .singleWhere((e) => e.value == source, orElse: () => null) + ?.key; + + if (value == null && unknownValue == null) { + throw ArgumentError('`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}'); + } + return value ?? unknownValue; +} + +T _$enumDecodeNullable( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + return null; + } + return _$enumDecode(enumValues, source, unknownValue: unknownValue); +} + +const _$CodelabTypeEnumMap = { + CodelabType.dart: 'dart', + CodelabType.flutter: 'flutter', +}; + +StepConfiguration _$StepConfigurationFromJson(Map json) { + return $checkedNew('StepConfiguration', json, () { + $checkKeys(json, allowedKeys: const ['name', 'directory', 'has_solution']); + final val = StepConfiguration( + name: $checkedConvert(json, 'name', (v) => v as String), + directory: $checkedConvert(json, 'directory', (v) => v as String), + hasSolution: $checkedConvert(json, 'has_solution', (v) => v as bool), + ); + return val; + }, fieldKeyMap: const {'hasSolution': 'has_solution'}); +} + +Map _$StepConfigurationToJson(StepConfiguration instance) => + { + 'name': instance.name, + 'directory': instance.directory, + 'has_solution': instance.hasSolution, + }; diff --git a/lib/codelabs/src/step.dart b/lib/codelabs/src/step.dart new file mode 100644 index 000000000..db6eeb688 --- /dev/null +++ b/lib/codelabs/src/step.dart @@ -0,0 +1,8 @@ +class Step { + final String name; + final String instructions; + final String snippet; + final String solution; + + Step(this.name, this.instructions, this.snippet, {this.solution}); +} diff --git a/lib/codelabs/src/web_server.dart b/lib/codelabs/src/web_server.dart new file mode 100644 index 000000000..840ebbb50 --- /dev/null +++ b/lib/codelabs/src/web_server.dart @@ -0,0 +1,51 @@ +import 'package:checked_yaml/checked_yaml.dart'; +import 'package:http/http.dart' as http; + +import 'codelab.dart'; +import 'fetcher.dart'; +import 'meta.dart'; +import 'step.dart'; + +class WebServerCodelabFetcher implements CodelabFetcher { + final Uri uri; + + WebServerCodelabFetcher(this.uri); + + @override + Future getCodelab() async { + var metadata = await _fetchMeta(); + var steps = await _fetchSteps(metadata); + + return Codelab('Example codelab', metadata.type, steps); + } + + Future _fetchMeta() async { + var contents = await _loadFileContents(['meta.yaml']); + return checkedYamlDecode(contents, (Map m) => Meta.fromJson(m)); + } + + Future> _fetchSteps(Meta metadata) async { + var steps = []; + for (var stepConfig in metadata.steps) { + steps.add(await _fetchStep(stepConfig)); + } + return steps; + } + + Future _fetchStep(StepConfiguration config) async { + var directory = config.directory; + var instructions = await _loadFileContents([directory, 'instructions.md']); + var snippet = await _loadFileContents([directory, 'snippet.dart']); + var solution = config.hasSolution + ? await _loadFileContents([directory, 'solution.dart']) + : null; + return Step(config.name, instructions, snippet, solution: solution); + } + + Future _loadFileContents(List relativePath) async { + var fileUri = + uri.replace(pathSegments: [...uri.pathSegments, ...relativePath]); + var response = await http.get(fileUri); + return response.body; + } +} diff --git a/lib/editing/codemirror_options.dart b/lib/editing/codemirror_options.dart new file mode 100644 index 000000000..a11414201 --- /dev/null +++ b/lib/editing/codemirror_options.dart @@ -0,0 +1,18 @@ +const codeMirrorOptions = { + 'continueComments': {'continueLineComment': false}, + 'autofocus': false, + 'autoCloseBrackets': true, + 'matchBrackets': true, + 'tabSize': 2, + 'lineWrapping': true, + 'indentUnit': 2, + 'cursorHeight': 0.85, + 'viewportMargin': 100, + 'extraKeys': { + 'Cmd-/': 'toggleComment', + 'Ctrl-/': 'toggleComment', + 'Tab': 'insertSoftTab' + }, + 'hintOptions': {'completeSingle': false}, + 'scrollbarStyle': 'simple', +}; diff --git a/lib/embed.dart b/lib/embed.dart index 570a2530b..106e55b47 100644 --- a/lib/embed.dart +++ b/lib/embed.dart @@ -16,6 +16,7 @@ import 'completion.dart'; import 'core/dependencies.dart'; import 'core/modules.dart'; import 'dart_pad.dart'; +import 'editing/codemirror_options.dart'; import 'editing/editor.dart'; import 'editing/editor_codemirror.dart'; import 'elements/analysis_results_controller.dart'; @@ -40,25 +41,6 @@ Embed get embed => _embed; Embed _embed; -const codeMirrorOptions = { - 'continueComments': {'continueLineComment': false}, - 'autofocus': false, - 'autoCloseBrackets': true, - 'matchBrackets': true, - 'tabSize': 2, - 'lineWrapping': true, - 'indentUnit': 2, - 'cursorHeight': 0.85, - 'viewportMargin': 100, - 'extraKeys': { - 'Cmd-/': 'toggleComment', - 'Ctrl-/': 'toggleComment', - 'Tab': 'insertSoftTab' - }, - 'hintOptions': {'completeSingle': false}, - 'scrollbarStyle': 'simple', -}; - void init(EmbedOptions options) { _embed = Embed(options); } diff --git a/lib/playground.dart b/lib/playground.dart index 31293dd54..0a7e5ff7b 100644 --- a/lib/playground.dart +++ b/lib/playground.dart @@ -22,6 +22,7 @@ import 'core/keys.dart'; import 'core/modules.dart'; import 'dart_pad.dart'; import 'documentation.dart'; +import 'editing/codemirror_options.dart'; import 'editing/editor.dart'; import 'elements/analysis_results_controller.dart'; import 'elements/bind.dart'; @@ -49,24 +50,6 @@ import 'util/detect_flutter.dart'; import 'util/keymap.dart'; import 'util/query_params.dart' show queryParams; -const codeMirrorOptions = { - 'continueComments': {'continueLineComment': false}, - 'autofocus': false, - 'autoCloseBrackets': true, - 'matchBrackets': true, - 'tabSize': 2, - 'lineWrapping': true, - 'indentUnit': 2, - 'cursorHeight': 0.85, - 'viewportMargin': 100, - 'extraKeys': { - 'Cmd-/': 'toggleComment', - 'Ctrl-/': 'toggleComment', - 'Tab': 'insertSoftTab' - }, - 'hintOptions': {'completeSingle': false}, - 'scrollbarStyle': 'simple', -}; Playground get playground => _playground; diff --git a/lib/scss/shared.scss b/lib/scss/shared.scss index f1b66cda7..49e80a9cd 100644 --- a/lib/scss/shared.scss +++ b/lib/scss/shared.scss @@ -187,6 +187,19 @@ a { background-color: $scrollbar-color; } +// Unread console counter +.Counter { + margin-left: 4px; + display: inline-block; + padding: 2px 5px; + font-size: 12px; + font-weight: 600; + line-height: 1; + color: $dark-teal; + background-color: fade_out($dark-teal, 0.5); + border-radius: 20px; +} + // Misc .view-label { font-family: $editor-font; diff --git a/lib/util/query_params.dart b/lib/util/query_params.dart index c908aa2d5..7aa7fa611 100644 --- a/lib/util/query_params.dart +++ b/lib/util/query_params.dart @@ -104,6 +104,10 @@ class _QueryParams { return _queryParam('gh_ref'); } + String /*?*/ get webServer { + return _queryParam('webserver'); + } + int /*?*/ get initialSplit { final split = _queryParam('split'); diff --git a/pubspec.lock b/pubspec.lock index 3fced7b33..63aa444f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -135,7 +135,7 @@ packages: source: hosted version: "1.2.0" checked_yaml: - dependency: transitive + dependency: "direct main" description: name: checked_yaml url: "https://pub.dartlang.org" @@ -317,12 +317,19 @@ packages: source: hosted version: "0.6.3" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation url: "https://pub.dartlang.org" source: hosted - version: "4.0.1" + version: "3.1.1" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "3.5.1" logging: dependency: "direct main" description: @@ -505,6 +512,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.4+1" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.10+3" source_map_stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 49543305c..debc930ee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ dependencies: codemirror: ^0.6.0-0 fluttering_phrases: ^0.3.1 http: '>=0.12.2 <0.13.0' + json_annotation: ^3.1.0 logging: '>=0.11.4 <0.12.0' markdown: ^3.0.0 meta: ^1.2.4 @@ -20,6 +21,7 @@ dependencies: sass_builder: ^2.1.0 mdc_web: ^0.5.0-pre yaml: ^2.2.0 + checked_yaml: ^1.0.0 js: ^0.6.0 dev_dependencies: @@ -29,6 +31,7 @@ dev_dependencies: collection: ^1.14.10 git: ^2.0.0 grinder: ^0.8.0 + json_serializable: ^3.5.0 pedantic: ^1.9.0 shelf: ^0.7.5 shelf_static: ^0.2.8 diff --git a/web/codelabs.html b/web/codelabs.html new file mode 100644 index 000000000..e478996d3 --- /dev/null +++ b/web/codelabs.html @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + DartPad Codelabs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + DartPad +
+
(No name)
+
+ +
+
+
+
+
+ +
Step 1
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+ + + +
+
+
+

+
+
+ + +
+
+
+
+
+ + diff --git a/web/example/codelabs/dart/meta.yaml b/web/example/codelabs/dart/meta.yaml new file mode 100644 index 000000000..cc68f8898 --- /dev/null +++ b/web/example/codelabs/dart/meta.yaml @@ -0,0 +1,9 @@ +name: Example codelab +type: dart # Optional +steps: + - name: Step 1 + directory: step_01 + has_solution: true + - name: Step 2 + directory: step_02 + has_solution: false diff --git a/web/example/codelabs/dart/step_01/instructions.md b/web/example/codelabs/dart/step_01/instructions.md new file mode 100644 index 000000000..30822d44d --- /dev/null +++ b/web/example/codelabs/dart/step_01/instructions.md @@ -0,0 +1,17 @@ +# Step 1 + +Change the code to do something: + +1. Example +2. List +3. Of +4. Steps + +## Hint +Try using a function: + +```dart +void myFunction() { + +} +``` diff --git a/web/example/codelabs/dart/step_01/snippet.dart b/web/example/codelabs/dart/step_01/snippet.dart new file mode 100644 index 000000000..1e8fc6ee2 --- /dev/null +++ b/web/example/codelabs/dart/step_01/snippet.dart @@ -0,0 +1,3 @@ +void main() { + print('Codelab'); +} diff --git a/web/example/codelabs/dart/step_01/solution.dart b/web/example/codelabs/dart/step_01/solution.dart new file mode 100644 index 000000000..8942817ca --- /dev/null +++ b/web/example/codelabs/dart/step_01/solution.dart @@ -0,0 +1,3 @@ +void main() { + print('Solution'); +} \ No newline at end of file diff --git a/web/example/codelabs/dart/step_02/instructions.md b/web/example/codelabs/dart/step_02/instructions.md new file mode 100644 index 000000000..54d4aef1f --- /dev/null +++ b/web/example/codelabs/dart/step_02/instructions.md @@ -0,0 +1,3 @@ +# Step 2 + +You're done! Have a coffee ☕️. diff --git a/web/example/codelabs/dart/step_02/snippet.dart b/web/example/codelabs/dart/step_02/snippet.dart new file mode 100644 index 000000000..1034c8860 --- /dev/null +++ b/web/example/codelabs/dart/step_02/snippet.dart @@ -0,0 +1,5 @@ +void main() { + for (var i = 0; i < 10; i++) { + print('Step 2'); + } +} diff --git a/web/example/codelabs/flutter/meta.yaml b/web/example/codelabs/flutter/meta.yaml new file mode 100644 index 000000000..f3d15fb04 --- /dev/null +++ b/web/example/codelabs/flutter/meta.yaml @@ -0,0 +1,9 @@ +name: Example codelab +type: flutter # Optional +steps: + - name: Step 1 + directory: step_01 + has_solution: true + - name: Step 2 + directory: step_02 + has_solution: false diff --git a/web/example/codelabs/flutter/step_01/instructions.md b/web/example/codelabs/flutter/step_01/instructions.md new file mode 100644 index 000000000..62fb0d07a --- /dev/null +++ b/web/example/codelabs/flutter/step_01/instructions.md @@ -0,0 +1,17 @@ +# Step 1 + +Flutter Codelab + +```dart +// Put this code in the build() method +children: [ + // Use this code + Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headline4, + ), +], +``` diff --git a/web/example/codelabs/flutter/step_01/snippet.dart b/web/example/codelabs/flutter/step_01/snippet.dart new file mode 100644 index 000000000..b5dddf4ce --- /dev/null +++ b/web/example/codelabs/flutter/step_01/snippet.dart @@ -0,0 +1,62 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + debugShowCheckedModeBanner: false, + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + MyHomePage({Key? key, required this.title}) : super(key: key); + + final String title; + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Add Code Here + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: Icon(Icons.add), + ), + ); + } +} diff --git a/web/example/codelabs/flutter/step_01/solution.dart b/web/example/codelabs/flutter/step_01/solution.dart new file mode 100644 index 000000000..680ebe56b --- /dev/null +++ b/web/example/codelabs/flutter/step_01/solution.dart @@ -0,0 +1,68 @@ +// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/material.dart'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + debugShowCheckedModeBanner: false, + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + MyHomePage({Key? key, required this.title}) : super(key: key); + + final String title; + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headline4, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: Icon(Icons.add), + ), + ); + } +} diff --git a/web/example/codelabs/flutter/step_02/instructions.md b/web/example/codelabs/flutter/step_02/instructions.md new file mode 100644 index 000000000..54d4aef1f --- /dev/null +++ b/web/example/codelabs/flutter/step_02/instructions.md @@ -0,0 +1,3 @@ +# Step 2 + +You're done! Have a coffee ☕️. diff --git a/web/example/codelabs/flutter/step_02/snippet.dart b/web/example/codelabs/flutter/step_02/snippet.dart new file mode 100644 index 000000000..1034c8860 --- /dev/null +++ b/web/example/codelabs/flutter/step_02/snippet.dart @@ -0,0 +1,5 @@ +void main() { + for (var i = 0; i < 10; i++) { + print('Step 2'); + } +} diff --git a/web/scripts/codelabs.dart b/web/scripts/codelabs.dart new file mode 100644 index 000000000..e581756dd --- /dev/null +++ b/web/scripts/codelabs.dart @@ -0,0 +1,8 @@ +import 'package:dart_pad/codelab.dart'; +import 'package:logging/logging.dart'; + +void main() { + init(); + + Logger.root.onRecord.listen(print); +} diff --git a/web/styles/codelabs.scss b/web/styles/codelabs.scss new file mode 100644 index 000000000..5aa4467a2 --- /dev/null +++ b/web/styles/codelabs.scss @@ -0,0 +1,234 @@ +@import 'package:dart_pad/scss/colors'; +@import 'package:dart_pad/scss/variables'; +@import 'package:dart_pad/scss/shared'; + +@import 'layout'; + +// Material Design Web theme colors. Must be imported before importing +// material-components-web.scss. +$mdc-theme-primary: #168AFD; +$mdc-theme-secondary: #676767; +$mdc-theme-background: $playground-background-color; +$mdc-theme-surface: $playground-background-color; +$mdc-theme-error: $dark-red; + +// Layout constants +$doc-console-padding: 8px 24px 8px 24px; + +@import 'package:mdc_web/material-components-web'; + +header { + background-color: $playground-header-background-color; + height: 48px; + padding-left: 24px; + @include layout-center; + z-index: 4; + user-select: none; + + .header-title { + @include layout; + @include layout-center; + font-family: $google-sans; + font-weight: 400; + font-size: 16pt; + margin-right: 8px; + + img.logo { + height: 24px; + width: 24px; + margin-right: 8px; + } + } + + #codelab-name { + @include layout-flex; + @include layout-horizontal; + @include layout-center-justified; + font-size: 14pt; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: 1em; + margin-right: 1em; + user-select: none; + } +} + +body { + background-color: $playground-background-color; + color: $playground-text-color; + font-family: $normal-font; + font-size: 14px; + overflow: hidden; + @include layout-vertical; + @include layout-fit; +} + +// Main section +section.main-section { + @include layout-flex; + @include layout; + @include layout-relative; + + .panels { + @include layout; + @include layout-fit; + } +} + +// Panels +#right-panel { + @include layout-vertical(); +} + +#editor-panel, #output-panel { + @include layout-flex; +} + +// Steps panel +#steps-panel { + @include layout-vertical(); + @include layout-center(); +} + +#markdown-content { + @include layout-flex(); + width: 100%; +} + +#step-button-container { + @include layout-horizontal(); + @include layout-center(); +} + +#steps-label { + padding: 12px; +} + +#next-step-btn, #previous-step-btn { + @include mdc-icon-button-size(32px, 32px); +} + +// Editor panel +#editor-panel { + @include layout-vertical; + @include layout-relative; +} +#editor-host { + @include layout-vertical; + @include layout-flex; + margin: 8px 0 0 0; + padding: 0 8px; + + .CodeMirror { + @include layout-flex; + font-family: $editor-font; + font-size: $playground-editor-font-size; + } +} + +.button-group { + @include layout-horizontal; + @include layout-center; + position: absolute; + top: 0; + right: 0; + margin: 8px 24px 0 0; + z-index: 5; +} + +// Console panel +#output-panel { + @include layout-vertical(); +} + +#output-panel-content { + @include layout-flex(); + overflow-y: scroll; +} + +.console { + @include layout-flex; + font-family: $editor-font; + font-size: 14px; + line-height: 20px; + min-height: 50px; + overflow-y: auto; + white-space: pre-wrap; + padding: $doc-console-padding; + + .normal { + color: $dark-editor-text; + } + + .error-output { + color: $dark-pink; + } +} + +// iframe element + +iframe { + @include layout-flex; + border: none; +} + +// Links +a { + color: $playground-link-color; + fill: $playground-text-color; + + &:visited { + color: $playground-link-color; + fill: $playground-link-color; + } + + &:hover { + color: $playground-text-color; + fill: $playground-text-color; + } +} + +// Footer +body>footer { + background-color: $playground-footer-background-color; + @include layout-horizontal; + @include layout-center; + padding: 8px 24px; + .flex { + @include layout-flex; + } + + .footer-item { + margin-right: 14px; + } + + * { + margin-right: 4px; + } + + #dartpad-version { + margin-left: 12px; + } +} + +// Splitter +.gutter { + background-color: $dark-gutter-background-color; + background-repeat: no-repeat; + background-position: 50%; + margin-left: 0; + margin-right: 0; +} + +.gutter.gutter-horizontal { + background-repeat: repeat-y; + cursor: col-resize; + height: 100%; +} + +.gutter.gutter-vertical { + background-repeat: repeat-x; + cursor: row-resize; + width: 100%; +} + diff --git a/web/styles/styles.scss b/web/styles/styles.scss index a0c13487a..7705357de 100644 --- a/web/styles/styles.scss +++ b/web/styles/styles.scss @@ -491,20 +491,6 @@ a { } } -// Unread console counter -.Counter { - margin-left: 4px; - display: inline-block; - padding: 2px 5px; - font-size: 12px; - font-weight: 600; - line-height: 1; - color: $dark-teal; - background-color: fade_out($dark-teal, 0.5); - border-radius: 20px; -} - - // Make the dialog show up even without everything initialized (for localStorage check) .mdc-dialog { z-index: 1000; From 8737b55526a0d7062d0c525e24cb54a91a13a0a3 Mon Sep 17 00:00:00 2001 From: John Ryan Date: Tue, 6 Apr 2021 10:46:26 -0700 Subject: [PATCH 2/8] Add Github codelab fetcher (#1811) * Add GithubCodelabFetcher * make gh_ref and gh_path optional query parameters --- lib/codelab.dart | 6 +---- lib/codelabs/src/exceptions.dart | 13 ++++++++++ lib/codelabs/src/fetcher.dart | 11 ++++++++ lib/codelabs/src/fetcher_impl.dart | 40 ++++++++++++++++++++++++++++++ lib/codelabs/src/github.dart | 40 ++++++++++++++++++++++++------ lib/codelabs/src/web_server.dart | 40 +++--------------------------- lib/sharing/gists.dart | 15 +++-------- lib/util/github.dart | 13 ++++++++++ 8 files changed, 117 insertions(+), 61 deletions(-) create mode 100644 lib/codelabs/src/exceptions.dart create mode 100644 lib/codelabs/src/fetcher_impl.dart create mode 100644 lib/util/github.dart diff --git a/lib/codelab.dart b/lib/codelab.dart index 4330ed133..e4c52c5fa 100644 --- a/lib/codelab.dart +++ b/lib/codelab.dart @@ -240,11 +240,7 @@ class CodelabUi { if (ghOwner != null && ghOwner.isNotEmpty && ghRepo != null && - ghRepo.isNotEmpty && - ghRef != null && - ghRef.isNotEmpty && - ghPath != null && - ghPath.isNotEmpty) { + ghRepo.isNotEmpty) { return GithubCodelabFetcher( owner: ghOwner, repo: ghRepo, diff --git a/lib/codelabs/src/exceptions.dart b/lib/codelabs/src/exceptions.dart new file mode 100644 index 000000000..8bde94741 --- /dev/null +++ b/lib/codelabs/src/exceptions.dart @@ -0,0 +1,13 @@ +enum CodelabFetchExceptionType { + unknown, + contentNotFound, + rateLimitExceeded, + invalidMetadata, +} + +class CodelabFetchException implements Exception { + final CodelabFetchExceptionType failureType; + final String message; + + const CodelabFetchException(this.failureType, [this.message]); +} \ No newline at end of file diff --git a/lib/codelabs/src/fetcher.dart b/lib/codelabs/src/fetcher.dart index e67ea51ee..318775d7d 100644 --- a/lib/codelabs/src/fetcher.dart +++ b/lib/codelabs/src/fetcher.dart @@ -1,5 +1,16 @@ +import 'package:dart_pad/codelabs/codelabs.dart'; +import 'package:meta/meta.dart'; + import 'codelab.dart'; +import 'github.dart'; abstract class CodelabFetcher { Future getCodelab(); + factory CodelabFetcher.github({ + @required String owner, + @required String repo, + String ref, + String path, + }) => GithubCodelabFetcher(owner: owner, repo: repo, path: path, ref: ref); + factory CodelabFetcher.webserver(Uri uri) => WebServerCodelabFetcher(uri); } diff --git a/lib/codelabs/src/fetcher_impl.dart b/lib/codelabs/src/fetcher_impl.dart new file mode 100644 index 000000000..974f0c146 --- /dev/null +++ b/lib/codelabs/src/fetcher_impl.dart @@ -0,0 +1,40 @@ +import 'package:checked_yaml/checked_yaml.dart'; + +import 'codelab.dart'; +import 'fetcher.dart'; +import 'meta.dart'; +import 'step.dart'; + +abstract class CodelabFetcherImpl implements CodelabFetcher { + Future loadFileContents(List relativePath); + + @override + Future getCodelab() async { + var metadata = await fetchMeta(); + var steps = await fetchSteps(metadata); + return Codelab('Example codelab', metadata.type, steps); + } + + Future fetchMeta() async { + var contents = await loadFileContents(['meta.yaml']); + return checkedYamlDecode(contents, (Map m) => Meta.fromJson(m)); + } + + Future> fetchSteps(Meta metadata) async { + var steps = []; + for (var stepConfig in metadata.steps) { + steps.add(await fetchStep(stepConfig)); + } + return steps; + } + + Future fetchStep(StepConfiguration config) async { + var directory = config.directory; + var instructions = await loadFileContents([directory, 'instructions.md']); + var snippet = await loadFileContents([directory, 'snippet.dart']); + var solution = config.hasSolution + ? await loadFileContents([directory, 'solution.dart']) + : null; + return Step(config.name, instructions, snippet, solution: solution); + } +} diff --git a/lib/codelabs/src/github.dart b/lib/codelabs/src/github.dart index 868baa12c..bcf26a531 100644 --- a/lib/codelabs/src/github.dart +++ b/lib/codelabs/src/github.dart @@ -1,9 +1,13 @@ +import 'package:dart_pad/util/github.dart'; +import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; -import 'codelab.dart'; -import 'fetcher.dart'; +import 'exceptions.dart'; +import 'fetcher_impl.dart'; + +class GithubCodelabFetcher extends CodelabFetcherImpl { + static const String _apiHostname = 'api.github.com'; -class GithubCodelabFetcher implements CodelabFetcher { final String owner; final String repo; final String ref; @@ -12,12 +16,34 @@ class GithubCodelabFetcher implements CodelabFetcher { GithubCodelabFetcher({ @required this.owner, @required this.repo, - @required this.ref, - @required this.path, + this.ref, + this.path, }); @override - Future getCodelab() async { - throw UnsupportedError('Github codelabs are not supported yet.'); + Future loadFileContents(List relativePath) async { + var url = _buildFileUrl(relativePath); + var res = await http.get(url); + + var statusCode = res.statusCode; + if (statusCode == 404) { + throw CodelabFetchException(CodelabFetchExceptionType.contentNotFound); + } else if (statusCode == 403) { + throw CodelabFetchException(CodelabFetchExceptionType.rateLimitExceeded); + } else if (statusCode != 200) { + throw CodelabFetchException(CodelabFetchExceptionType.unknown); + } + + return extractGitHubResponseBody(res.body); + } + + Uri _buildFileUrl(List pathSegments) { + var filePath = [if (path != null) path, ...pathSegments]; + return Uri( + scheme: 'https', + host: _apiHostname, + pathSegments: ['repos', owner, repo, 'contents', ...filePath], + queryParameters: {if (ref != null) 'ref': ref}, + ); } } diff --git a/lib/codelabs/src/web_server.dart b/lib/codelabs/src/web_server.dart index 840ebbb50..8a86f1f59 100644 --- a/lib/codelabs/src/web_server.dart +++ b/lib/codelabs/src/web_server.dart @@ -1,48 +1,14 @@ -import 'package:checked_yaml/checked_yaml.dart'; import 'package:http/http.dart' as http; -import 'codelab.dart'; -import 'fetcher.dart'; -import 'meta.dart'; -import 'step.dart'; +import 'fetcher_impl.dart'; -class WebServerCodelabFetcher implements CodelabFetcher { +class WebServerCodelabFetcher extends CodelabFetcherImpl { final Uri uri; WebServerCodelabFetcher(this.uri); @override - Future getCodelab() async { - var metadata = await _fetchMeta(); - var steps = await _fetchSteps(metadata); - - return Codelab('Example codelab', metadata.type, steps); - } - - Future _fetchMeta() async { - var contents = await _loadFileContents(['meta.yaml']); - return checkedYamlDecode(contents, (Map m) => Meta.fromJson(m)); - } - - Future> _fetchSteps(Meta metadata) async { - var steps = []; - for (var stepConfig in metadata.steps) { - steps.add(await _fetchStep(stepConfig)); - } - return steps; - } - - Future _fetchStep(StepConfiguration config) async { - var directory = config.directory; - var instructions = await _loadFileContents([directory, 'instructions.md']); - var snippet = await _loadFileContents([directory, 'snippet.dart']); - var solution = config.hasSolution - ? await _loadFileContents([directory, 'solution.dart']) - : null; - return Step(config.name, instructions, snippet, solution: solution); - } - - Future _loadFileContents(List relativePath) async { + Future loadFileContents(List relativePath) async { var fileUri = uri.replace(pathSegments: [...uri.pathSegments, ...relativePath]); var response = await http.get(fileUri); diff --git a/lib/sharing/gists.dart b/lib/sharing/gists.dart index 4bfc2ed0b..3d2e2deef 100644 --- a/lib/sharing/gists.dart +++ b/lib/sharing/gists.dart @@ -14,6 +14,7 @@ import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:yaml/yaml.dart' as yaml; import '../util/detect_flutter.dart' as detect_flutter; +import '../util/github.dart'; final String _dartpadLink = '[dartpad.dev](https://dartpad.dev)'; @@ -266,16 +267,6 @@ $styleRef$dartRef return gist; } - String _extractContents(String githubResponse) { - // GitHub's API returns file contents as the "contents" field in a JSON - // object. The field's value is in base64 encoding, but with line ending - // characters ('\n') included. - final contentJson = json.decode(githubResponse); - final encodedContentStr = - contentJson['content'].toString().replaceAll('\n', ''); - return utf8.decode(base64.decode(encodedContentStr)); - } - Uri _buildContentsUrl(String owner, String repo, String path, [String /*?*/ ref]) { return Uri.https( @@ -304,7 +295,7 @@ $styleRef$dartRef throw GistLoaderException(GistLoaderFailureType.unknown); } - final metadataContent = _extractContents(metadataResponse.body); + final metadataContent = extractGitHubResponseBody(metadataResponse.body); ExerciseMetadata metadata; @@ -340,7 +331,7 @@ $styleRef$dartRef throw GistLoaderException(GistLoaderFailureType.unknown); } - return _extractContents(contentResponse.body); + return extractGitHubResponseBody(contentResponse.body); }); // This will rethrow the first exception created above, if one is thrown. diff --git a/lib/util/github.dart b/lib/util/github.dart new file mode 100644 index 000000000..95ffe2a04 --- /dev/null +++ b/lib/util/github.dart @@ -0,0 +1,13 @@ +import 'dart:convert'; + +/// Returns the contents of the file returned from +/// api.github.com/repos///contents/ +String extractGitHubResponseBody(String githubResponse) { + // GitHub's API returns file contents as the "contents" field in a JSON + // object. The field's value is in base64 encoding, but with line ending + // characters ('\n') included. + final contentJson = json.decode(githubResponse); + final encodedContentStr = + contentJson['content'].toString().replaceAll('\n', ''); + return utf8.decode(base64.decode(encodedContentStr)); +} \ No newline at end of file From dd512663098531cc75ef5b0d14796353067a6fec Mon Sep 17 00:00:00 2001 From: John Ryan Date: Wed, 14 Apr 2021 17:52:59 -0700 Subject: [PATCH 3/8] Add show solution button to Codelab UI (#1819) * add codelabs.html to allowed files in main.py * Add show solution button --- lib/codelab.dart | 37 ++++++++++++++++++++++++++++++++++--- web/codelabs.html | 25 ++++++++++++++++--------- web/main.py | 1 + web/styles/codelabs.scss | 13 +++++++++++++ 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/lib/codelab.dart b/lib/codelab.dart index e4c52c5fa..9b53b9708 100644 --- a/lib/codelab.dart +++ b/lib/codelab.dart @@ -16,6 +16,7 @@ import 'editing/editor_codemirror.dart'; import 'elements/button.dart'; import 'elements/console.dart'; import 'elements/counter.dart'; +import 'elements/dialog.dart'; import 'elements/elements.dart'; import 'elements/material_tab_controller.dart'; import 'modules/codemirror_module.dart'; @@ -47,8 +48,10 @@ class CodelabUi { DElement nextStepButton; Console _console; MDCButton runButton; + MDCButton showSolutionButton; MaterialTabController consolePanelTabController; Counter unreadConsoleCounter; + Dialog dialog; CodelabUi() { _init(); @@ -65,6 +68,7 @@ class CodelabUi { IFrameElement get _frame => querySelector('#frame') as IFrameElement; Future _init() async { + _initDialogs(); await _loadCodelab(); _initHeader(); _updateInstructions(); @@ -90,6 +94,10 @@ class CodelabUi { await modules.start(); } + void _initDialogs() { + dialog = Dialog(); + } + void _initEditor() { // Set up CodeMirror editor = (editorFactory as CodeMirrorFactory) @@ -165,6 +173,7 @@ class CodelabUi { _updateInstructions(); _updateStepButtons(); _updateCode(); + _updateSolutionButton(); }); } @@ -176,9 +185,20 @@ class CodelabUi { void _initButtons() { runButton = MDCButton(querySelector('#run-button') as ButtonElement) - ..onClick.listen((_) { - _handleRun(); - }); + ..onClick.listen((_) => _handleRun()); + + showSolutionButton = + MDCButton(querySelector('#show-solution-btn') as ButtonElement) + ..onClick.listen((_) => _handleShowSolution()); + } + + void _updateSolutionButton() { + if (_codelabState.currentStep.solution == null) { + showSolutionButton.element.style.visibility = 'hidden'; + } else { + showSolutionButton.element.style.visibility = null; + } + showSolutionButton.disabled = false; } void _updateCode() { @@ -327,6 +347,17 @@ class CodelabUi { var snackbar = MDCSnackbar(div)..labelText = message; snackbar.open(); } + + Future _handleShowSolution() async { + var result = await dialog.showOkCancel( + 'Show solution', + 'Are you sure you want to show the solution? Your changes for this ' + 'step will be lost.'); + if (result == DialogResult.ok) { + editor.document.updateValue(_codelabState.currentStep.solution); + showSolutionButton.disabled = true; + } + } } class CodelabState { diff --git a/web/codelabs.html b/web/codelabs.html index e478996d3..812f3989d 100644 --- a/web/codelabs.html +++ b/web/codelabs.html @@ -75,15 +75,22 @@
-
- -
Step 1
- +
Step 1
+ +
+ +
diff --git a/web/main.py b/web/main.py index ff84fafcd..9afb151dc 100644 --- a/web/main.py +++ b/web/main.py @@ -13,6 +13,7 @@ # Files that the server is allowed to serve. Additional static files are # served via directives in app.yaml. VALID_FILES = [ + 'codelabs.html', 'dark_mode.js', 'dart-192.png', 'embed-dart.html', diff --git a/web/styles/codelabs.scss b/web/styles/codelabs.scss index 5aa4467a2..c894e7c71 100644 --- a/web/styles/codelabs.scss +++ b/web/styles/codelabs.scss @@ -85,6 +85,17 @@ section.main-section { } // Steps panel +#steps-row { + @include layout-horizontal(); + @include layout-center(); + @include layout-relative(); + width: 100%; +} + +#show-solution-btn { + margin: 8px; +} + #steps-panel { @include layout-vertical(); @include layout-center(); @@ -98,6 +109,8 @@ section.main-section { #step-button-container { @include layout-horizontal(); @include layout-center(); + @include layout-flex(); + @include layout-center-justified(); } #steps-label { From 4a90fa37f5759288ff533072e5e2877b081cabda Mon Sep 17 00:00:00 2001 From: John Ryan Date: Fri, 16 Apr 2021 13:53:18 -0700 Subject: [PATCH 4/8] Run 'dart format --fix .' --- lib/codelabs/src/exceptions.dart | 2 +- lib/codelabs/src/fetcher.dart | 3 ++- lib/playground.dart | 1 - lib/util/github.dart | 4 ++-- web/example/codelabs/dart/step_01/solution.dart | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/codelabs/src/exceptions.dart b/lib/codelabs/src/exceptions.dart index 8bde94741..a8fe57f47 100644 --- a/lib/codelabs/src/exceptions.dart +++ b/lib/codelabs/src/exceptions.dart @@ -10,4 +10,4 @@ class CodelabFetchException implements Exception { final String message; const CodelabFetchException(this.failureType, [this.message]); -} \ No newline at end of file +} diff --git a/lib/codelabs/src/fetcher.dart b/lib/codelabs/src/fetcher.dart index 318775d7d..9644b04dd 100644 --- a/lib/codelabs/src/fetcher.dart +++ b/lib/codelabs/src/fetcher.dart @@ -11,6 +11,7 @@ abstract class CodelabFetcher { @required String repo, String ref, String path, - }) => GithubCodelabFetcher(owner: owner, repo: repo, path: path, ref: ref); + }) => + GithubCodelabFetcher(owner: owner, repo: repo, path: path, ref: ref); factory CodelabFetcher.webserver(Uri uri) => WebServerCodelabFetcher(uri); } diff --git a/lib/playground.dart b/lib/playground.dart index 0a7e5ff7b..6976f988b 100644 --- a/lib/playground.dart +++ b/lib/playground.dart @@ -50,7 +50,6 @@ import 'util/detect_flutter.dart'; import 'util/keymap.dart'; import 'util/query_params.dart' show queryParams; - Playground get playground => _playground; Playground _playground; diff --git a/lib/util/github.dart b/lib/util/github.dart index 95ffe2a04..5bff0a3df 100644 --- a/lib/util/github.dart +++ b/lib/util/github.dart @@ -8,6 +8,6 @@ String extractGitHubResponseBody(String githubResponse) { // characters ('\n') included. final contentJson = json.decode(githubResponse); final encodedContentStr = - contentJson['content'].toString().replaceAll('\n', ''); + contentJson['content'].toString().replaceAll('\n', ''); return utf8.decode(base64.decode(encodedContentStr)); -} \ No newline at end of file +} diff --git a/web/example/codelabs/dart/step_01/solution.dart b/web/example/codelabs/dart/step_01/solution.dart index 8942817ca..521ae8519 100644 --- a/web/example/codelabs/dart/step_01/solution.dart +++ b/web/example/codelabs/dart/step_01/solution.dart @@ -1,3 +1,3 @@ void main() { print('Solution'); -} \ No newline at end of file +} From b7a9f5b3f5685af747a4693e9d570697ba1873c4 Mon Sep 17 00:00:00 2001 From: John Ryan Date: Fri, 16 Apr 2021 16:00:36 -0700 Subject: [PATCH 5/8] remove unused top-level variable --- lib/codelab.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/codelab.dart b/lib/codelab.dart index 9b53b9708..10be3668a 100644 --- a/lib/codelab.dart +++ b/lib/codelab.dart @@ -32,8 +32,6 @@ CodelabUi _codelabUi; CodelabUi get codelabUi => _codelabUi; -IFrameElement get _frame => querySelector('#frame') as IFrameElement; - void init() { _codelabUi = CodelabUi(); } From 3353d1981427ddc2352a2d1b556d698111cbb8e1 Mon Sep 17 00:00:00 2001 From: John Ryan Date: Fri, 16 Apr 2021 17:26:19 -0700 Subject: [PATCH 6/8] remove lib/codelabs/src/meta.g.dart --- lib/codelabs/src/meta.g.dart | 91 ------------------------------------ 1 file changed, 91 deletions(-) delete mode 100644 lib/codelabs/src/meta.g.dart diff --git a/lib/codelabs/src/meta.g.dart b/lib/codelabs/src/meta.g.dart deleted file mode 100644 index bb2b43653..000000000 --- a/lib/codelabs/src/meta.g.dart +++ /dev/null @@ -1,91 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'meta.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Meta _$MetaFromJson(Map json) { - return $checkedNew('Meta', json, () { - $checkKeys(json, - allowedKeys: const ['name', 'type', 'steps'], - requiredKeys: const ['name', 'steps']); - final val = Meta( - $checkedConvert(json, 'name', (v) => v as String), - $checkedConvert( - json, - 'steps', - (v) => (v as List) - ?.map((e) => - e == null ? null : StepConfiguration.fromJson(e as Map)) - ?.toList()), - type: $checkedConvert(json, 'type', - (v) => _$enumDecodeNullable(_$CodelabTypeEnumMap, v)) ?? - CodelabType.dart, - ); - return val; - }); -} - -Map _$MetaToJson(Meta instance) => { - 'name': instance.name, - 'type': _$CodelabTypeEnumMap[instance.type], - 'steps': instance.steps, - }; - -T _$enumDecode( - Map enumValues, - dynamic source, { - T unknownValue, -}) { - if (source == null) { - throw ArgumentError('A value must be provided. Supported values: ' - '${enumValues.values.join(', ')}'); - } - - final value = enumValues.entries - .singleWhere((e) => e.value == source, orElse: () => null) - ?.key; - - if (value == null && unknownValue == null) { - throw ArgumentError('`$source` is not one of the supported values: ' - '${enumValues.values.join(', ')}'); - } - return value ?? unknownValue; -} - -T _$enumDecodeNullable( - Map enumValues, - dynamic source, { - T unknownValue, -}) { - if (source == null) { - return null; - } - return _$enumDecode(enumValues, source, unknownValue: unknownValue); -} - -const _$CodelabTypeEnumMap = { - CodelabType.dart: 'dart', - CodelabType.flutter: 'flutter', -}; - -StepConfiguration _$StepConfigurationFromJson(Map json) { - return $checkedNew('StepConfiguration', json, () { - $checkKeys(json, allowedKeys: const ['name', 'directory', 'has_solution']); - final val = StepConfiguration( - name: $checkedConvert(json, 'name', (v) => v as String), - directory: $checkedConvert(json, 'directory', (v) => v as String), - hasSolution: $checkedConvert(json, 'has_solution', (v) => v as bool), - ); - return val; - }, fieldKeyMap: const {'hasSolution': 'has_solution'}); -} - -Map _$StepConfigurationToJson(StepConfiguration instance) => - { - 'name': instance.name, - 'directory': instance.directory, - 'has_solution': instance.hasSolution, - }; From f711cbaf1093604d216851e124c38c1d7b2a14ce Mon Sep 17 00:00:00 2001 From: John Ryan Date: Mon, 19 Apr 2021 09:48:23 -0700 Subject: [PATCH 7/8] Revert "remove lib/codelabs/src/meta.g.dart" This reverts commit 3353d1981427ddc2352a2d1b556d698111cbb8e1. --- lib/codelabs/src/meta.g.dart | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 lib/codelabs/src/meta.g.dart diff --git a/lib/codelabs/src/meta.g.dart b/lib/codelabs/src/meta.g.dart new file mode 100644 index 000000000..bb2b43653 --- /dev/null +++ b/lib/codelabs/src/meta.g.dart @@ -0,0 +1,91 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'meta.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Meta _$MetaFromJson(Map json) { + return $checkedNew('Meta', json, () { + $checkKeys(json, + allowedKeys: const ['name', 'type', 'steps'], + requiredKeys: const ['name', 'steps']); + final val = Meta( + $checkedConvert(json, 'name', (v) => v as String), + $checkedConvert( + json, + 'steps', + (v) => (v as List) + ?.map((e) => + e == null ? null : StepConfiguration.fromJson(e as Map)) + ?.toList()), + type: $checkedConvert(json, 'type', + (v) => _$enumDecodeNullable(_$CodelabTypeEnumMap, v)) ?? + CodelabType.dart, + ); + return val; + }); +} + +Map _$MetaToJson(Meta instance) => { + 'name': instance.name, + 'type': _$CodelabTypeEnumMap[instance.type], + 'steps': instance.steps, + }; + +T _$enumDecode( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + throw ArgumentError('A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}'); + } + + final value = enumValues.entries + .singleWhere((e) => e.value == source, orElse: () => null) + ?.key; + + if (value == null && unknownValue == null) { + throw ArgumentError('`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}'); + } + return value ?? unknownValue; +} + +T _$enumDecodeNullable( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + return null; + } + return _$enumDecode(enumValues, source, unknownValue: unknownValue); +} + +const _$CodelabTypeEnumMap = { + CodelabType.dart: 'dart', + CodelabType.flutter: 'flutter', +}; + +StepConfiguration _$StepConfigurationFromJson(Map json) { + return $checkedNew('StepConfiguration', json, () { + $checkKeys(json, allowedKeys: const ['name', 'directory', 'has_solution']); + final val = StepConfiguration( + name: $checkedConvert(json, 'name', (v) => v as String), + directory: $checkedConvert(json, 'directory', (v) => v as String), + hasSolution: $checkedConvert(json, 'has_solution', (v) => v as bool), + ); + return val; + }, fieldKeyMap: const {'hasSolution': 'has_solution'}); +} + +Map _$StepConfigurationToJson(StepConfiguration instance) => + { + 'name': instance.name, + 'directory': instance.directory, + 'has_solution': instance.hasSolution, + }; From 05a06b477729de7da7d0ba9e026bf0a4e05e1f59 Mon Sep 17 00:00:00 2001 From: John Ryan Date: Mon, 19 Apr 2021 09:50:52 -0700 Subject: [PATCH 8/8] add --delete-conflicting-outputs to grind build task --- tool/grind.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tool/grind.dart b/tool/grind.dart index 57171268d..4badd3777 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -113,7 +113,8 @@ serveCustomBackend() async { @Task('Build the `web/index.html` entrypoint') build() { - PubApp.local('build_runner').run(['build', '-r', '-o', 'web:build']); + PubApp.local('build_runner') + .run(['build', '-r', '-o', 'web:build', '--delete-conflicting-outputs']); var mainFile = _buildDir.join('scripts/playground.dart.js'); log('$mainFile compiled to ${_printSize(mainFile)}');