feat(editor): three-tab WYSIWYG editor — graph / text / run
Full rewrite of the editor surface, layered on top of the
FlowGraph foundation. One in-memory flow drives three tabs
that the operator can flip between freely:
- Graph: a drag-and-drop canvas. Nodes are step cards with
port dots on their left (one per `with:` field) and a
combined output port on the right. Pinned inputs and
outputs pseudo-nodes sit at the left and right edges so
every flow has a visually obvious source and sink. Pan +
zoom via InteractiveViewer; drag a node by its body to
reposition it (positions persisted to a sidecar JSON file
under ~/.fai/data/flows/.layout/<name>.json — kept OUT of
the YAML so `fai run` stays byte-stable).
- Text: the existing YAML CodeField with expands:true so
line 1 anchors at the top edge. YAML-aware syntax
highlighting picks up the theme's primary / secondary /
tertiary palette for keys / strings / numbers.
- Run: an inputs form (text fields + file-pick), a Start
button that calls the host's FlowRunDriver, a live step
list driven by the driver's event stream (matches the
`fai run` CLI rendering — ◻ pending, · running, ✔ done +
duration, ✗ failed, ⏸ awaiting approval), and the typed
outputs once the run resolves.
Source of truth = the YAML text. Graph edits emit fresh YAML
into the shared CodeController; text edits re-parse the
graph on a 350 ms debounce. Layout sidecar persists drag
positions only.
New public API (lib/fai_studio_flow_editor.dart):
FlowEditorPage(
initialFlowName: ...,
locale: ...,
runDriver: FlowRunDriver?, // NEW — host bridge
availableCapabilities: List<String>, // NEW — for the
// capability picker
// dialog when adding
// a step
)
The host (Studio) implements FlowRunDriver to bridge the
hub's gRPC SDK into the editor's event vocabulary. The
StepStarted/Completed/Failed/AwaitingApproval events are
shared verbatim with the CLI's run_progress renderer so
both surfaces speak the same visual language.
Files in this commit:
- lib/src/editor_controller.dart — shared state +
debounced reparse loop
- lib/src/run_driver.dart — host bridge
interface + event types
- lib/src/widgets/flow_canvas.dart — pan / zoom / drag /
port-to-port connection drawing
- lib/src/widgets/flow_node.dart — node card primitive
(module / approval / inputs / outputs variants)
- lib/src/widgets/edge_painter.dart — single CustomPainter
for every edge + draft drag line, cubic bezier with
arrow-head caps
- lib/src/widgets/properties_panel.dart — right-side editor
when a step is selected (rename id, change capability, add
/ remove / rename with-fields, delete step)
- lib/src/widgets/capability_picker.dart — searchable list
dialog used by Add-step
- lib/src/widgets/run_tab.dart — inputs form +
live step progress + outputs renderer
- lib/src/flow_editor_page.dart — host scaffolding,
toolbar, file list, three-tab body, keyboard shortcuts
- lib/src/l10n.dart — EN + DE strings for
every new label
- lib/fai_studio_flow_editor.dart — exports the new
public types (FlowRunDriver, FlowRunEvent variants,
FlowOutputValue variants)
flutter analyze: 0 issues. flutter test: 7/7 green.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
dbd9a0004f
commit
870cbc29f7
12 changed files with 2769 additions and 408 deletions
564
lib/src/widgets/run_tab.dart
Normal file
564
lib/src/widgets/run_tab.dart
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
// RunTab — the third tab. Shows:
|
||||
//
|
||||
// 1. A form for the flow's declared inputs (one field per
|
||||
// `inputs:` entry). Text inputs use a TextField; bytes
|
||||
// inputs use a "Choose file…" button + filename badge.
|
||||
//
|
||||
// 2. A Start button that calls the host's FlowRunDriver
|
||||
// with the collected inputs.
|
||||
//
|
||||
// 3. A live step list driven by the driver's event stream,
|
||||
// identical visually to the CLI's `fai run` block:
|
||||
// ◻ pending, · running, ✔ done + duration, ✗ failed,
|
||||
// ⏸ awaiting approval.
|
||||
//
|
||||
// 4. The flow's outputs once the run resolves.
|
||||
//
|
||||
// The tab requires the file to be saved on disk — the hub's
|
||||
// runSavedFlow reads from `~/.fai/data/flows/<name>.yaml`,
|
||||
// not from the in-memory buffer. The dirty banner reminds
|
||||
// the operator to save before running so they don't run a
|
||||
// stale version by surprise.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../editor_controller.dart';
|
||||
import '../l10n.dart';
|
||||
import '../model/flow_graph.dart';
|
||||
import '../run_driver.dart';
|
||||
import '../tokens.dart';
|
||||
|
||||
class RunTab extends StatefulWidget {
|
||||
final FlowEditorController controller;
|
||||
final FlowEditorStrings strings;
|
||||
final FlowRunDriver? driver;
|
||||
const RunTab({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.strings,
|
||||
this.driver,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RunTab> createState() => _RunTabState();
|
||||
}
|
||||
|
||||
class _RunTabState extends State<RunTab> {
|
||||
final Map<String, TextEditingController> _textInputs = {};
|
||||
final Map<String, _FilePick> _fileInputs = {};
|
||||
|
||||
StreamSubscription<FlowRunEvent>? _eventSub;
|
||||
bool _running = false;
|
||||
Map<String, FlowOutputValue>? _outputs;
|
||||
Object? _error;
|
||||
// Insertion-ordered: shows steps in the order the hub
|
||||
// actually started them, matching the CLI rendering.
|
||||
final Map<String, _StepState> _liveSteps = {};
|
||||
String? _runFlowName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
_syncInputs();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
_eventSub?.cancel();
|
||||
for (final c in _textInputs.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onControllerChanged() {
|
||||
if (!mounted) return;
|
||||
_syncInputs();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _syncInputs() {
|
||||
final graph = widget.controller.graph;
|
||||
final keep = <String>{};
|
||||
for (final entry in graph.inputs.entries) {
|
||||
keep.add(entry.key);
|
||||
if (entry.value.type == 'bytes' || entry.value.type == 'file') {
|
||||
// bytes input — keep its file pick if already chosen.
|
||||
_fileInputs.putIfAbsent(entry.key, () => const _FilePick.empty());
|
||||
} else {
|
||||
_textInputs.putIfAbsent(
|
||||
entry.key,
|
||||
() => TextEditingController(text: entry.value.defaultValue ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Drop controllers for inputs that no longer exist.
|
||||
_textInputs.removeWhere((k, c) {
|
||||
if (keep.contains(k)) return false;
|
||||
c.dispose();
|
||||
return true;
|
||||
});
|
||||
_fileInputs.removeWhere((k, _) => !keep.contains(k));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final controller = widget.controller;
|
||||
final strings = widget.strings;
|
||||
if (controller.activeName == null) {
|
||||
return _empty(theme, strings.runNoFlow);
|
||||
}
|
||||
final graph = controller.graph;
|
||||
return Container(
|
||||
color: theme.colorScheme.surface,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(FaiSpace.lg),
|
||||
children: [
|
||||
_titleRow(theme),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
if (controller.isDirty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||
margin: const EdgeInsets.only(bottom: FaiSpace.md),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
strings.runUnsavedBanner,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_section(theme, strings.runInputs),
|
||||
if (graph.inputs.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||||
child: Text(
|
||||
'—',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final entry in graph.inputs.entries)
|
||||
_inputField(theme, entry.key, entry.value),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
FilledButton.icon(
|
||||
onPressed: _running || widget.driver == null ? null : _start,
|
||||
icon: _running
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.play_arrow, size: 18),
|
||||
label: Text(strings.runStart),
|
||||
),
|
||||
if (_liveSteps.isNotEmpty || _running) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_section(
|
||||
theme,
|
||||
_running ? strings.runTitleRunning : strings.runTitleDone,
|
||||
),
|
||||
_stepList(theme),
|
||||
],
|
||||
if (_outputs != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_section(theme, strings.runOutputs),
|
||||
for (final entry in _outputs!.entries)
|
||||
_outputRow(theme, entry.key, entry.value),
|
||||
],
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_section(theme, strings.runTitleFailed),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
_error.toString(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _titleRow(ThemeData theme) {
|
||||
final title = _running
|
||||
? widget.strings.runTitleRunning
|
||||
: _error != null
|
||||
? widget.strings.runTitleFailed
|
||||
: _outputs != null
|
||||
? widget.strings.runTitleDone
|
||||
: widget.strings.runTitleIdle;
|
||||
return Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(ThemeData theme, String label) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _empty(ThemeData theme, String hint) {
|
||||
return Container(
|
||||
color: theme.colorScheme.surface,
|
||||
padding: const EdgeInsets.all(FaiSpace.lg),
|
||||
child: Center(
|
||||
child: Text(
|
||||
hint,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _inputField(ThemeData theme, String name, FlowInput input) {
|
||||
final type = input.type;
|
||||
if (type == 'bytes' || type == 'file') {
|
||||
final pick = _fileInputs[name] ?? const _FilePick.empty();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(
|
||||
'$name ($type)',
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _pickFile(name),
|
||||
icon: const Icon(Icons.attach_file, size: 16),
|
||||
label: Text(
|
||||
pick.fileName ?? widget.strings.runChooseFile,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(
|
||||
'$name ($type)',
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textInputs[name],
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
hintText: input.hint,
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepList(ThemeData theme) {
|
||||
final entries = _liveSteps.entries.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final entry in entries) _stepRow(theme, entry.key, entry.value),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepRow(ThemeData theme, String id, _StepState state) {
|
||||
final IconData glyph;
|
||||
final Color color;
|
||||
String suffix = '';
|
||||
switch (state.kind) {
|
||||
case _StepKind.running:
|
||||
glyph = Icons.refresh;
|
||||
color = theme.colorScheme.primary;
|
||||
case _StepKind.done:
|
||||
glyph = Icons.check;
|
||||
color = Colors.green.shade600;
|
||||
suffix = ' ${(state.durationMs ?? 0) / 1000.0}s';
|
||||
case _StepKind.error:
|
||||
glyph = Icons.close;
|
||||
color = theme.colorScheme.error;
|
||||
if (state.error != null && state.error!.isNotEmpty) {
|
||||
suffix = ' — ${state.error}';
|
||||
}
|
||||
case _StepKind.awaiting:
|
||||
glyph = Icons.pause_circle_outline;
|
||||
color = theme.colorScheme.tertiary;
|
||||
suffix = ' awaiting approval';
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(glyph, size: 16, color: color),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'$id$suffix',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: color,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _outputRow(ThemeData theme, String name, FlowOutputValue value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name.toUpperCase(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
_outputBody(theme, value),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _outputBody(ThemeData theme, FlowOutputValue value) {
|
||||
if (value is FlowOutputText) {
|
||||
return SelectableText(
|
||||
value.value,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
);
|
||||
}
|
||||
if (value is FlowOutputJson) {
|
||||
return SelectableText(
|
||||
value.value.toString(),
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
);
|
||||
}
|
||||
if (value is FlowOutputBytes) {
|
||||
return Text(
|
||||
'${value.value.length} bytes · ${value.mimeType}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Future<void> _pickFile(String name) async {
|
||||
// For 0.2.0 the editor relies on the host to inject a
|
||||
// file-picker via callback; minimal version uses
|
||||
// dart:io directly for desktop. Studio uses file_picker;
|
||||
// we fall back to a manual path entry dialog if no host
|
||||
// picker is available.
|
||||
final controller = TextEditingController();
|
||||
final ctx = context;
|
||||
final result = await showDialog<String>(
|
||||
context: ctx,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text(widget.strings.runChooseFile),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '/path/to/file',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, null),
|
||||
child: Text(widget.strings.newDialogCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (result == null || result.isEmpty || !mounted) return;
|
||||
try {
|
||||
final file = File(result);
|
||||
if (!file.existsSync()) return;
|
||||
final bytes = await file.readAsBytes();
|
||||
setState(() {
|
||||
_fileInputs[name] = _FilePick(
|
||||
fileName: file.uri.pathSegments.last,
|
||||
bytes: Uint8List.fromList(bytes),
|
||||
);
|
||||
});
|
||||
} catch (_) {
|
||||
// swallow — file unreadable, leave the pick empty
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
final driver = widget.driver;
|
||||
if (driver == null) return;
|
||||
final flowName = widget.controller.activeName;
|
||||
if (flowName == null) return;
|
||||
setState(() {
|
||||
_running = true;
|
||||
_outputs = null;
|
||||
_error = null;
|
||||
_liveSteps.clear();
|
||||
_runFlowName = flowName;
|
||||
});
|
||||
widget.controller.running = true;
|
||||
// Subscribe BEFORE submitting so the first event isn't
|
||||
// lost to the broadcast's dead-letter.
|
||||
_eventSub?.cancel();
|
||||
_eventSub = driver.events().listen(_onEvent);
|
||||
final textInputs = <String, String>{
|
||||
for (final entry in _textInputs.entries) entry.key: entry.value.text,
|
||||
};
|
||||
final fileInputs = <String, Uint8List>{};
|
||||
final fileMimes = <String, String>{};
|
||||
for (final entry in _fileInputs.entries) {
|
||||
final bytes = entry.value.bytes;
|
||||
if (bytes == null) continue;
|
||||
fileInputs[entry.key] = bytes;
|
||||
fileMimes[entry.key] = _mimeFor(entry.value.fileName ?? '');
|
||||
}
|
||||
try {
|
||||
final outputs = await driver.runFlow(
|
||||
flowName: flowName,
|
||||
textInputs: textInputs,
|
||||
fileInputs: fileInputs,
|
||||
fileMimes: fileMimes,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_running = false;
|
||||
_outputs = outputs;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_running = false;
|
||||
_error = e;
|
||||
});
|
||||
} finally {
|
||||
widget.controller.running = false;
|
||||
_eventSub?.cancel();
|
||||
_eventSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onEvent(FlowRunEvent event) {
|
||||
final flowName = _runFlowName;
|
||||
if (flowName == null) return;
|
||||
if (event.flowName != flowName) return;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
switch (event) {
|
||||
case StepStarted():
|
||||
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.running);
|
||||
case StepCompleted():
|
||||
_liveSteps[event.stepId] = _StepState(
|
||||
kind: _StepKind.done,
|
||||
durationMs: event.durationMs,
|
||||
);
|
||||
case StepFailed():
|
||||
_liveSteps[event.stepId] = _StepState(
|
||||
kind: _StepKind.error,
|
||||
error: event.error,
|
||||
);
|
||||
case StepAwaitingApproval():
|
||||
_liveSteps[event.stepId] = const _StepState(kind: _StepKind.awaiting);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _mimeFor(String name) {
|
||||
final lower = name.toLowerCase();
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
if (lower.endsWith('.docx')) {
|
||||
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
}
|
||||
if (lower.endsWith('.txt')) return 'text/plain';
|
||||
if (lower.endsWith('.json')) return 'application/json';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
class _FilePick {
|
||||
final String? fileName;
|
||||
final Uint8List? bytes;
|
||||
const _FilePick({this.fileName, this.bytes});
|
||||
const _FilePick.empty() : fileName = null, bytes = null;
|
||||
}
|
||||
|
||||
enum _StepKind { running, done, error, awaiting }
|
||||
|
||||
class _StepState {
|
||||
final _StepKind kind;
|
||||
final int? durationMs;
|
||||
final String? error;
|
||||
const _StepState({required this.kind, this.durationMs, this.error});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue