unit View.TasksHTML;

interface

uses
  System.SysUtils, System.Classes,
  JS, Web, WEBLib.Graphics, WEBLib.Controls, WEBLib.Forms, WEBLib.Dialogs,
  WEBLib.ExtCtrls, uNameManager,
  XData.Web.Client, XData.Web.Dataset,
  Utils, Data.DB, XData.Web.JsonDataset, Vcl.Controls, Vcl.StdCtrls,
  WEBLib.StdCtrls;

type
  TFTasksHTML = class(TWebForm)
    xdwcTasks: TXDataWebClient;
    xdwdsTasks: TXDataWebDataSet;
    xdwdsTaskstaskId: TStringField;
    xdwdsTasksapplication: TStringField;
    xdwdsTasksversion: TStringField;
    xdwdsTaskstaskDate: TStringField;
    xdwdsTasksreportedBy: TStringField;
    xdwdsTasksassignedTo: TStringField;
    xdwdsTasksstatus: TStringField;
    xdwdsTasksstatusDate: TStringField;
    xdwdsTasksformSection: TStringField;
    xdwdsTasksissue: TStringField;
    xdwdsTasksnotes: TStringField;
    btnReload: TWebButton;
    btnAddRow: TWebButton;
    xdwdsTasksitemNum: TIntegerField;
    xdwdsTaskstaskItemId: TIntegerField;
    btnDeleteRow: TWebButton;
    [async] procedure btnAddRowClick(Sender: TObject);
    procedure btnReloadClick(Sender: TObject);
    procedure WebFormCreate(Sender: TObject);
    [async] procedure btnDeleteRowClick(Sender: TObject);
  private
    FTaskId: string;
    FReportedByOptions: TJSArray;
    FAssignedToOptions: TJSArray;
    FStatusOptions: TJSArray;
    FPendingFocusTaskItemId: integer;
    FPendingFocusField: string;
    FSelectedTaskItemId: Integer;
    FSelectedTaskId: Integer;
    FPendingScrollTop: Integer;
    FPendingScrollLeft: Integer;
    FPendingFocusItemNum: Integer;
    FPendingFocusTaskField: string;
    FNameManager: TNameManager;
    [async] procedure LoadTasks(const ATaskId: string);
    procedure RenderTable;
    procedure BindTableEditors;
    procedure EditorInput(Event: TJSEvent);
    procedure SelectChange(Event: TJSEvent);
    procedure EnableColumnResize;
    procedure EnableAutoGrowTextAreas;
    procedure GotoRowIndex(AIndex: Integer);
    function HtmlEncode(const s: string): string;
    procedure SetTotalRowsLabel(ARowCount: Integer);
    procedure SetTaskLabel(const ATitle: string);
    [async] procedure SaveRow(AIndex: Integer);
    procedure EditorBlur(Event: TJSEvent);
    [async] function AddTaskRow: Boolean;
    [async] function DeleteTaskRow: Boolean;
    function ExtractOptionNames(const SourceArray: TJSArray): TJSArray;
    function GetOptionsForField(const AFieldName: string): TJSArray;
    procedure FocusTrigger(const ATriggerId: string);
    procedure DropdownItemClick(Event: TJSEvent);
    procedure DropdownEditClick(Event: TJSEvent);
    function ExtractCodeDescs(const SourceArray: TJSArray): TJSArray;
    [async] procedure MoveTaskRow(AIndex: Integer; const newItemNum: Integer);
    procedure ApplyPendingFocus;
    procedure RowClick(Event: TJSEvent);
    procedure ApplySelectedRowState;
    procedure ApplyPendingDeleteFocus;
    procedure EditorKeyDown(Event: TJSEvent);
    procedure CaptureTableScroll;
    procedure RestoreTableScroll;
  public

  end;

var
  FTasksHTML: TFTasksHTML;

implementation

uses
  ConnectionModule;

{$R *.dfm}

procedure TFTasksHTML.WebFormCreate(Sender: TObject);
begin
  console.log('TFTasksHTML.WebFormCreate fired');
  FTaskId := Application.Parameters.Values['task_id'];
  FReportedByOptions := TJSArray.new;
  FAssignedToOptions := TJSArray.new;
  FStatusOptions := TJSArray.new;
  FSelectedTaskItemId := 0;
  FSelectedTaskId := 0;

  FNameManager := TNameManager.Create(
    function(const AFieldName: string): TJSArray
    begin
      Result := GetOptionsForField(AFieldName);
    end,
    procedure
    begin
      RenderTable;
    end,
    procedure(const ATriggerId: string)
    begin
      FocusTrigger(ATriggerId);
    end
  );

  FNameManager.BindControls;

  if FTaskId = '' then
  begin
    Utils.ShowErrorModal('Missing task_id. Please reopen from emt3.');
    Exit;
  end;

  btnAddRow.Enabled := False;
  btnDeleteRow.Enabled := False;

  if not DMConnection.ApiConnection.Connected then
  begin
    DMConnection.ApiConnection.Open(
      procedure
      begin
        LoadTasks(FTaskId);
      end
    );
  end
  else
    LoadTasks(FTaskId);
end;


function TFTasksHTML.HtmlEncode(const s: string): string;
begin
  Result := s;
  Result := StringReplace(Result, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [rfReplaceAll]);
  Result := StringReplace(Result, '"', '&quot;', [rfReplaceAll]);
  Result := StringReplace(Result, '''', '&#39;', [rfReplaceAll]);
end;


procedure TFTasksHTML.GotoRowIndex(AIndex: Integer);
var
  i: Integer;
begin
  if (AIndex < 0) or (not xdwdsTasks.Active) then
    Exit;

  xdwdsTasks.First;
  i := 0;
  while (i < AIndex) and (not xdwdsTasks.Eof) do
  begin
    xdwdsTasks.Next;
    Inc(i);
  end;
end;


procedure TFTasksHTML.EditorInput(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName: string;
  newVal: string;
begin
  if not xdwdsTasks.Active then
    Exit;

  el := TJSHTMLElement(Event.target);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  idx := StrToIntDef(idxStr, -1);
  if (idx < 0) or (fieldName = '') then
    Exit;

  GotoRowIndex(idx);
  if xdwdsTasks.Eof then
    Exit;

  newVal := string(TJSObject(el)['value']);

  console.log('EditorInput: idx=' + IntToStr(idx) + ' field=' + fieldName + ' val=' + newVal);

  xdwdsTasks.Edit;
  if SameText(fieldName, 'itemNum') then
    xdwdsTasks.FieldByName(fieldName).AsInteger := StrToIntDef(newVal, 0)
  else
    xdwdsTasks.FieldByName(fieldName).AsString := newVal;
  xdwdsTasks.Post;

  el.setAttribute('data-unsaved-data', '1');
end;


procedure TFTasksHTML.SelectChange(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName: string;
  sel: TJSHTMLSelectElement;
begin
  if not xdwdsTasks.Active then
    Exit;

  el := TJSHTMLElement(Event.target);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  idx := StrToIntDef(idxStr, -1);
  if (idx < 0) or (fieldName = '') then
    Exit;

  GotoRowIndex(idx);
  if xdwdsTasks.Eof then
    Exit;

  sel := TJSHTMLSelectElement(el);

  console.log('SelectChange: idx=' + IntToStr(idx) + ' field=' + fieldName + ' val=' + string(sel.value));

  xdwdsTasks.Edit;
  xdwdsTasks.FieldByName(fieldName).AsString := string(sel.value);
  xdwdsTasks.Post;

  el.setAttribute('data-unsaved-data', '1');
  SaveRow(idx);
end;


procedure TFTasksHTML.BindTableEditors;
var
  nodes: TJSNodeList;
  i: Integer;
  el: TJSHTMLElement;
begin
  console.log('BindTableEditors: wiring handlers...');

  nodes := document.querySelectorAll('.task-editor');
  console.log('BindTableEditors: task-editor count=' + IntToStr(nodes.length));
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('input', TJSEventHandler(@EditorInput));
    el.addEventListener('blur', TJSEventHandler(@EditorBlur));
    el.addEventListener('keydown', TJSEventHandler(@EditorKeyDown));
  end;

  nodes := document.querySelectorAll('.task-select');
  console.log('BindTableEditors: task-select count=' + IntToStr(nodes.length));
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('change', TJSEventHandler(@SelectChange));
  end;

  nodes := document.querySelectorAll('.task-dd-item');
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('click', TJSEventHandler(@DropdownItemClick));
  end;

  nodes := document.querySelectorAll('.task-dd-edit-btn');
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('click', TJSEventHandler(@DropdownEditClick));
  end;

  nodes := document.querySelectorAll('.task-row-selectable');
  for i := 0 to nodes.length - 1 do
  begin
    el := TJSHTMLElement(nodes.item(i));
    el.addEventListener('click', TJSEventHandler(@RowClick));
  end;

  ApplySelectedRowState;
end;


procedure TFTasksHTML.EditorKeyDown(Event: TJSEvent);
var
  el: TJSHTMLElement;
  fieldName: string;
  newItemNum: Integer;
  idx: Integer;
begin
  el := TJSHTMLElement(Event.target);
  fieldName := string(el.getAttribute('data-field'));

  if not SameText(fieldName, 'itemNum') then
    Exit;

  if TJSKeyboardEvent(Event).key <> 'Enter' then
    Exit;

  Event.preventDefault;

  idx := StrToIntDef(string(el.getAttribute('data-idx')), -1);
  if idx < 0 then
    Exit;

  newItemNum := StrToIntDef(string(TJSObject(el)['value']), 0);

  el.removeAttribute('data-unsaved-data');

  MoveTaskRow(idx, newItemNum);
end;


[async] procedure TFTasksHTML.btnAddRowClick(Sender: TObject);
begin
  Utils.ShowSpinner('spinner');
  try
    if await(AddTaskRow) then
    begin
      CaptureTableScroll;
      LoadTasks(FTaskId);
    end;
  finally
    Utils.HideSpinner('spinner');
  end;
end;

[async] procedure TFTasksHTML.btnDeleteRowClick(Sender: TObject);
var
  deletedItemNum: Integer;
begin
  if FSelectedTaskItemId <= 0 then
    Exit;

  deletedItemNum := 0;

  if xdwdsTasks.Active then
  begin
    xdwdsTasks.First;
    while not xdwdsTasks.Eof do
    begin
      if xdwdsTaskstaskItemId.AsInteger = FSelectedTaskItemId then
      begin
        deletedItemNum := xdwdsTasksitemNum.AsInteger;
        Break;
      end;
      xdwdsTasks.Next;
    end;
  end;

  Utils.ShowSpinner('spinner');
  try
    if await(DeleteTaskRow) then
    begin
      FSelectedTaskItemId := 0;
      FSelectedTaskId := 0;
      btnDeleteRow.Enabled := False;

      FPendingFocusItemNum := deletedItemNum;
      FPendingFocusTaskField := 'application';

      CaptureTableScroll;
      LoadTasks(FTaskId);
    end;
  finally
    Utils.HideSpinner('spinner');
  end;
end;

procedure TFTasksHTML.RowClick(Event: TJSEvent);
var
  rowEl: TJSHTMLElement;
  taskItemIdStr: string;
  taskIdStr: string;
begin
  rowEl := TJSHTMLElement(Event.currentTarget);
  if not Assigned(rowEl) then
    Exit;

  taskItemIdStr := string(rowEl.getAttribute('data-task-item-id'));
  taskIdStr := string(rowEl.getAttribute('data-task-id'));

  FSelectedTaskItemId := StrToIntDef(taskItemIdStr, 0);
  FSelectedTaskId := StrToIntDef(taskIdStr, 0);

  btnDeleteRow.Enabled := FSelectedTaskItemId > 0;
  ApplySelectedRowState;
end;

procedure TFTasksHTML.ApplySelectedRowState;
begin
  asm
    const selectedTaskItemId = this.FSelectedTaskItemId;
    document.querySelectorAll('.task-row-selectable').forEach(function(row){
      const rowTaskItemId = parseInt(row.getAttribute('data-task-item-id') || '0', 10);
      if ((selectedTaskItemId > 0) && (rowTaskItemId === selectedTaskItemId))
        row.classList.add('table-active');
      else
        row.classList.remove('table-active');
    });
  end;
end;


[async] function TFTasksHTML.AddTaskRow: Boolean;
var
  response: TXDataClientResponse;
  insertAfterItemNum: Integer;
  newItemNum: Integer;
  maxItemNum: Integer;
begin
  Result := False;

  if FTaskId = '' then
  begin
    Utils.ShowErrorModal('Missing task_id. Please reopen from emt3.');
    Exit;
  end;

  insertAfterItemNum := 0;
  maxItemNum := 0;

  if xdwdsTasks.Active then
  begin
    xdwdsTasks.First;
    while not xdwdsTasks.Eof do
    begin
      if xdwdsTasksitemNum.AsInteger > maxItemNum then
        maxItemNum := xdwdsTasksitemNum.AsInteger;

      if xdwdsTaskstaskItemId.AsInteger = FSelectedTaskItemId then
        insertAfterItemNum := xdwdsTasksitemNum.AsInteger;

      xdwdsTasks.Next;
    end;
  end;

  if insertAfterItemNum > 0 then
    newItemNum := insertAfterItemNum + 1
  else
    newItemNum := maxItemNum + 1;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'IApiService.AddTaskRow', [FTaskId, insertAfterItemNum]
    ));
    console.log('AddTaskRow response=' + string(TJSJSON.stringify(response.Result)));

    FPendingFocusItemNum := newItemNum;
    FPendingFocusTaskField := 'application';

    Result := True;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('AddTaskRow ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
    end;
  end;
end;


procedure TFTasksHTML.btnReloadClick(Sender: TObject);
begin
  if FTaskId = '' then
  begin
    Utils.ShowErrorModal('Missing Task Id. Update url params or resend from emT3.');
    Exit;
  end;

  LoadTasks(FTaskId);
end;


procedure TFTasksHTML.EnableAutoGrowTextAreas;
begin
  asm
    (function(){
      const host = document.getElementById('tasks_table_host');
      if(!host) return;
      host.querySelectorAll('textarea.cell-textarea').forEach(ta => {
        const fit = () => { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; };
        fit();
        ta.addEventListener('input', fit);
      });
    })();
  end;
end;


procedure TFTasksHTML.SetTotalRowsLabel(ARowCount: Integer);
var
  el: TJSHTMLElement;
begin
  el := TJSHTMLElement(document.getElementById('lbl_total_rows'));
  if Assigned(el) then
    el.innerText := 'Total Rows: ' + IntToStr(ARowCount);
end;


procedure TFTasksHTML.SetTaskLabel(const ATitle: string);
var
  el: TJSHTMLElement;
begin
  el := TJSHTMLElement(document.getElementById('lbl_project_name'));
  if Assigned(el) then
    el.innerText := ATitle;
end;


[async] procedure TFTasksHTML.LoadTasks(const ATaskId: string);
var
  response: TXDataClientResponse;
  resultObj, taskObj: TJSObject;
  itemsArray: TJSArray;
  titleText: string;
  rowCount: Integer;
begin
  console.log('IApiService.GetTaskItems called with task_id: ' + ATaskId);
  console.log('Load Tasks Fired');
  Utils.ShowSpinner('spinner');
  try
    try
      response := await(xdwcTasks.RawInvokeAsync(
        'IApiService.GetTaskItems', [ATaskId]
      ));
    except
      on E: EXDataClientRequestException do
      begin
        Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
        Exit;
      end;
    end;

    if not Assigned(response.Result) then
      Exit;

    resultObj := TJSObject(response.Result);
    taskObj := TJSObject(resultObj['task']);

    if Assigned(taskObj) then
      titleText := string(taskObj['title'])
    else
      titleText := 'Task - ' + ATaskId;

    SetTaskLabel(titleText);

    rowCount := StrToIntDef(string(resultObj['count']), 0);
    SetTotalRowsLabel(rowCount);

    FReportedByOptions := ExtractOptionNames(TJSArray(resultObj['reportedByOptions']));
    FAssignedToOptions := ExtractOptionNames(TJSArray(resultObj['assignedToOptions']));
    FStatusOptions := ExtractCodeDescs(TJSArray(resultObj['statusOptions']));

    itemsArray := TJSArray(resultObj['items']);
    if not Assigned(itemsArray) then
      itemsArray := TJSArray.new;

    xdwdsTasks.Close;
    xdwdsTasks.SetJsonData(itemsArray);
    xdwdsTasks.Open;

    btnAddRow.Enabled := True;
    btnDeleteRow.Enabled := FSelectedTaskItemId > 0;

    RenderTable;
  finally
    Utils.HideSpinner('spinner');
  end;
end;


procedure TFTasksHTML.RenderTable;
var
  host: TJSHTMLElement;
  html: string;
  rowIdx: Integer;

  function Th(const s: string): string;
  begin
    Result := '<th scope="col">' + s + '</th>';
  end;

  function ThBlank: string;
  begin
    Result := '<th scope="col">&nbsp;</th>';
  end;

  function TdNowrap(const s: string): string;
  begin
    Result := '<td class="align-top nowrap-cell">' + s + '</td>';
  end;

  function TdWrap(const s: string): string;
  begin
    Result := '<td class="align-top wrap-cell">' + s + '</td>';
  end;

  function TextInput(const FieldName, Value: string; const AIdx: Integer; const MinWidth: Integer = 0): string;
  var
    w: string;
  begin
    w := '';
    if MinWidth > 0 then
      w := ' style="min-width: ' + IntToStr(MinWidth) + 'px;"';

    Result :=
      '<input class="form-control form-control-sm cell-input task-editor w-100" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '" ' +
        'value="' + HtmlEncode(Value) + '"' + w + '>';
  end;

  function TextArea(const FieldName, Value: string; const AIdx: Integer): string;
  begin
    Result :=
      '<textarea class="form-control form-control-sm cell-textarea task-editor w-100" ' +
        'style="height:31px; min-height:31px; overflow:hidden; resize:none;" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '" ' +
        'rows="1">' + HtmlEncode(Value) + '</textarea>';
  end;

  function ItemNumInput(const Value: Integer; const AIdx: Integer): string;
  begin
    Result :=
      '<input type="number" min="1" class="form-control form-control-sm task-editor text-center px-1" ' +
        'style="width: 30px;" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="itemNum" ' +
        'value="' + IntToStr(Value) + '">';
  end;

  function DateInput(const FieldName, Value: string; const AIdx: Integer; const MinWidth: Integer = 0): string;
  var
    w: string;
  begin
    w := '';
    if MinWidth > 0 then
      w := ' style="min-width: ' + IntToStr(MinWidth) + 'px;"';

    Result :=
      '<input type="date" class="form-control form-control-sm cell-input task-editor w-100" ' +
        'data-idx="' + IntToStr(AIdx) + '" data-field="' + FieldName + '" ' +
        'value="' + HtmlEncode(Value) + '"' + w + '>';
  end;

  function SelectList(const FieldName, Current: string; const AIdx: Integer; const Items: TJSArray): string;
    var
      i: Integer;
      itemText: string;
      triggerId: string;
    begin
      triggerId := 'task_dd_' + FieldName + '_' + IntToStr(AIdx);

      Result :=
        '<div class="dropdown w-100">' +
          '<button id="' + triggerId + '" class="btn btn-sm btn-light border w-100 d-flex justify-content-between align-items-center text-start task-dd-toggle" ' +
            'type="button" data-bs-toggle="dropdown" aria-expanded="false">' +
            '<span class="task-dd-label text-truncate">' + HtmlEncode(Current) + '</span>' +
            '<span class="dropdown-toggle dropdown-toggle-split border-0 ms-2"></span>' +
          '</button>' +
          '<div class="dropdown-menu w-100 p-0 overflow-hidden">';

      Result := Result +
        '<button type="button" class="dropdown-item task-dd-item" ' +
          'data-idx="' + IntToStr(AIdx) + '" ' +
          'data-field="' + FieldName + '" ' +
          'data-value=""></button>';

      if Assigned(Items) then
        for i := 0 to Items.length - 1 do
        begin
          itemText := string(Items[i]);
          Result := Result +
            '<button type="button" class="dropdown-item task-dd-item" ' +
              'data-idx="' + IntToStr(AIdx) + '" ' +
              'data-field="' + FieldName + '" ' +
              'data-value="' + HtmlEncode(itemText) + '" ' +
              'data-trigger-id="' + triggerId + '">' + HtmlEncode(itemText) + '</button>';
        end;

      Result := Result +
            '<div class="dropdown-divider my-1"></div>' +
            '<div class="px-2 py-1 text-end">' +
              '<button type="button" class="btn btn-link btn-sm p-0 text-body task-dd-edit-btn" ' +
                'data-idx="' + IntToStr(AIdx) + '" ' +
                'data-field="' + FieldName + '" ' +
                'data-trigger-id="' + triggerId + '">' +
                '<i class="fas fa-pencil-alt"></i>' +
              '</button>' +
            '</div>' +
          '</div>' +
        '</div>';
    end;

  function StatusSelect(const Current: string; const AIdx: Integer): string;
  var
    i: Integer;
    statusText: string;
    sel: string;
  begin
    Result :=
      '<select class="form-select form-select-sm task-select" data-idx="' + IntToStr(AIdx) + '" data-field="status">';

    Result := Result + '<option value=""></option>'; //Note: This adds the blank option on top

    if Assigned(FStatusOptions) then
      for i := 0 to FStatusOptions.length - 1 do
      begin
        statusText := string(FStatusOptions[i]);
        sel := '';
        if SameText(Current, statusText) then
          sel := ' selected';

        Result := Result + '<option value="' + HtmlEncode(statusText) + '"' + sel + '>' + HtmlEncode(statusText) + '</option>';
      end;

    Result := Result + '</select>';
  end;

begin
  host := TJSHTMLElement(document.getElementById('tasks_table_host'));
  if not Assigned(host) then
    Exit;

  html :=
    '<div class="tasks-vscroll">' +
      '<div class="tasks-hscroll">' +
        '<table class="table table-sm table-bordered align-middle mb-0" style="min-width: 2000px;">' +
          '<colgroup>' +
            '<col style="width:40px">' +   // Item Num
            '<col style="width:200px">' +  // App
            '<col style="width:90px">' +   // Version
            '<col style="width:120px">' +  // Date
            '<col style="width:120px">' +  // Reported
            '<col style="width:120px">' +  // Assigned
            '<col style="width:195px">' +  // Status
            '<col style="width:140px">' +  // Status Date
            '<col style="width:160px">' +  // Form
            '<col style="width:520px">' +  // Issue
            '<col style="width:520px">' +  // Notes
          '</colgroup>' +
          '<thead><tr>' +
            ThBlank +
            Th('App') +
            Th('Version') +
            Th('Date') +
            Th('Reported') +
            Th('Assigned') +
            Th('Status') +
            Th('Status Date') +
            Th('Form') +
            Th('Issue') +
            Th('Notes') +
          '</tr></thead><tbody>';

  rowIdx := 0;
  xdwdsTasks.First;
  while not xdwdsTasks.Eof do
  begin
    html := html +
      '<tr class="task-row-selectable" data-task-item-id="' + IntToStr(xdwdsTaskstaskItemId.AsInteger) + '" data-task-id="' + xdwdsTaskstaskId.AsString + '" data-item-num="' + IntToStr(xdwdsTasksitemNum.AsInteger) + '">' +
        TdNowrap(ItemNumInput(xdwdsTasksitemNum.AsInteger, rowIdx)) +
        TdNowrap(TextInput('application', xdwdsTasksapplication.AsString, rowIdx, 180)) +
        TdNowrap(TextInput('version', xdwdsTasksversion.AsString, rowIdx, 80)) +
        TdNowrap(DateInput('taskDate', xdwdsTaskstaskDate.AsString, rowIdx, 110)) +
        TdNowrap(SelectList('reportedBy', xdwdsTasksreportedBy.AsString, rowIdx, FReportedByOptions)) +
        TdNowrap(SelectList('assignedTo', xdwdsTasksassignedTo.AsString, rowIdx, FAssignedToOptions)) +
        TdNowrap(StatusSelect(xdwdsTasksstatus.AsString, rowIdx)) +
        TdNowrap(DateInput('statusDate', xdwdsTasksstatusDate.AsString, rowIdx, 110)) +
        TdNowrap(TextInput('formSection', xdwdsTasksformSection.AsString, rowIdx, 160)) +
        TdWrap(TextArea('issue', xdwdsTasksissue.AsString, rowIdx)) +
        TdWrap(TextArea('notes', xdwdsTasksnotes.AsString, rowIdx)) +
      '</tr>';

    xdwdsTasks.Next;
    Inc(rowIdx);
  end;

  html := html + '</tbody></table></div></div>';
  host.innerHTML := html;

  BindTableEditors;
  EnableAutoGrowTextAreas;
  EnableColumnResize;
  RestoreTableScroll;
  ApplyPendingFocus;
  ApplyPendingDeleteFocus;
end;


procedure TFTasksHTML.EnableColumnResize;
begin
  asm
    (function(){
      const host = document.getElementById('tasks_table_host');
      if(!host) return;
      const table = host.querySelector('table');
      if(!table) return;
      const ths = table.querySelectorAll('thead th');
      ths.forEach(th => {
        th.classList.add('th-resize');
        if(th.querySelector('.th-resize-handle')) return;

        const handle = document.createElement('div');
        handle.className = 'th-resize-handle';
        th.appendChild(handle);

        handle.addEventListener('mousedown', function(e){
          e.preventDefault();
          const startX = e.clientX;
          const startW = th.getBoundingClientRect().width;

          function onMove(ev){
            const w = Math.max(40, startW + (ev.clientX - startX));
            th.style.width = w + 'px';
          }
          function onUp(){
            document.removeEventListener('mousemove', onMove);
            document.removeEventListener('mouseup', onUp);
          }
          document.addEventListener('mousemove', onMove);
          document.addEventListener('mouseup', onUp);
        });
      });
    })();
  end;
end;


procedure TFTasksHTML.EditorBlur(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName: string;
  newItemNum: Integer;
begin
  el := TJSHTMLElement(Event.target);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));

  if string(el.getAttribute('data-unsaved-data')) <> '1' then
  begin
    console.log('EditorBlur: skip (not unsaved) idx=' + idxStr + ' field=' + fieldName);
    Exit;
  end;

  el.removeAttribute('data-unsaved-data');

  idx := StrToIntDef(idxStr, -1);
  if idx < 0 then
    Exit;

  if SameText(fieldName, 'itemNum') then
  begin
    newItemNum := StrToIntDef(string(TJSObject(el)['value']), 0);
    console.log('EditorBlur: MOVE idx=' + IntToStr(idx) + ' newItemNum=' + IntToStr(newItemNum));
    MoveTaskRow(idx, newItemNum);
    Exit;
  end;

  console.log('EditorBlur: SAVE idx=' + IntToStr(idx) + ' field=' + fieldName);
  SaveRow(idx);
end;


[async] procedure TFTasksHTML.MoveTaskRow(AIndex: Integer; const newItemNum: Integer);
var
  response: TXDataClientResponse;
  movedTaskItemId: Integer;
begin
  if not xdwdsTasks.Active then
    Exit;

  GotoRowIndex(AIndex);
  if xdwdsTasks.Eof then
    Exit;

  movedTaskItemId := xdwdsTaskstaskItemId.AsInteger;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'IApiService.MoveTaskRow',
      [
        StrToIntDef(xdwdsTaskstaskId.AsString, 0),
        movedTaskItemId,
        newItemNum
      ]
    ));
    console.log('MoveTaskRow: response=' + string(TJSJSON.stringify(response.Result)));

    FPendingFocusTaskItemId := movedTaskItemId;
    FPendingFocusField := 'application';

    CaptureTableScroll;
    LoadTasks(FTaskId);
  except
    on E: EXDataClientRequestException do
    begin
      console.log('MoveTaskRow ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
      LoadTasks(FTaskId);
    end;
  end;
end;


[async] procedure TFTasksHTML.SaveRow(AIndex: Integer);
const
  // Note: Use this to manipulate saving to the server or not for testing
  ENABLE_SERVER_SAVE = True;
var
  response: TXDataClientResponse;
  payload: TJSObject;
  payloadJson: string;
begin
  if not xdwdsTasks.Active then
    Exit;

  GotoRowIndex(AIndex);
  if xdwdsTasks.Eof then
    Exit;

  payload := TJSObject.new;

  payload['taskItemId'] := xdwdsTaskstaskItemId.AsInteger;
  payload['taskId'] := xdwdsTaskstaskId.AsString;

  payload['application'] := xdwdsTasksapplication.AsString;
  payload['version'] := xdwdsTasksversion.AsString;
  payload['taskDate'] := xdwdsTaskstaskDate.AsString;
  payload['reportedBy'] := xdwdsTasksreportedBy.AsString;
  payload['assignedTo'] := xdwdsTasksassignedTo.AsString;
  payload['status'] := xdwdsTasksstatus.AsString;
  payload['statusDate'] := xdwdsTasksstatusDate.AsString;
  payload['formSection'] := xdwdsTasksformSection.AsString;
  payload['issue'] := xdwdsTasksissue.AsString;
  payload['notes'] := xdwdsTasksnotes.AsString;

  payloadJson := string(TJSJSON.stringify(payload));
  console.log('SaveRow: idx=' + IntToStr(AIndex) + ' payload=' + payloadJson);

  if not ENABLE_SERVER_SAVE then
    Exit;

  try
    response := await(xdwcTasks.RawInvokeAsync('IApiService.SaveTaskRow', [payload]));
    console.log('SaveRow: response=' + string(TJSJSON.stringify(response.Result)));
  except
    on E: EXDataClientRequestException do
    begin
      console.log('SaveRow ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
    end;
  end;
end;


[async] function TFTasksHTML.DeleteTaskRow: Boolean;
var
  response: TXDataClientResponse;
begin
  Result := False;

  if (FSelectedTaskId <= 0) or (FSelectedTaskItemId <= 0) then
    Exit;

  try
    response := await(xdwcTasks.RawInvokeAsync(
      'IApiService.DeleteTaskRow',
      [FSelectedTaskId, FSelectedTaskItemId]
    ));
    console.log('DeleteTaskRow response=' + string(TJSJSON.stringify(response.Result)));
    Result := True;
  except
    on E: EXDataClientRequestException do
    begin
      console.log('DeleteTaskRow ERROR: ' + E.ErrorResult.ErrorMessage);
      Utils.ShowErrorModal(E.ErrorResult.ErrorMessage);
    end;
  end;
end;

function TFTasksHTML.ExtractOptionNames(const SourceArray: TJSArray): TJSArray;
var
  i: Integer;
  optionObj: TJSObject;
begin
  Result := TJSArray.new;

  if not Assigned(SourceArray) then
    Exit;

  for i := 0 to SourceArray.length - 1 do
  begin
    optionObj := TJSObject(SourceArray[i]);
    if Assigned(optionObj) then
      Result.push(string(optionObj['name']));
  end;
end;


function TFTasksHTML.ExtractCodeDescs(const SourceArray: TJSArray): TJSArray;
var
  i: Integer;
  optionObj: TJSObject;
begin
  Result := TJSArray.new;

  if not Assigned(SourceArray) then
    Exit;

  for i := 0 to SourceArray.length - 1 do
  begin
    optionObj := TJSObject(SourceArray[i]);
    if Assigned(optionObj) then
      Result.push(string(optionObj['codeDesc']));
  end;
end;


function TFTasksHTML.GetOptionsForField(const AFieldName: string): TJSArray;
begin
  if SameText(AFieldName, 'reportedBy') then
    Result := FReportedByOptions
  else if SameText(AFieldName, 'assignedTo') then
    Result := FAssignedToOptions
  else
    Result := nil;
end;


procedure TFTasksHTML.FocusTrigger(const ATriggerId: string);
var
  el: TJSHTMLElement;
begin
  if ATriggerId = '' then
    Exit;

  el := TJSHTMLElement(document.getElementById(ATriggerId));
  if Assigned(el) then
    el.focus;
end;


procedure TFTasksHTML.DropdownItemClick(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName, newVal, triggerId: string;
begin
  if not xdwdsTasks.Active then
    Exit;

  Event.preventDefault;

  el := TJSHTMLElement(Event.currentTarget);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  newVal := string(el.getAttribute('data-value'));
  triggerId := string(el.getAttribute('data-trigger-id'));

  idx := StrToIntDef(idxStr, -1);
  if (idx < 0) or (fieldName = '') then
    Exit;

  GotoRowIndex(idx);
  if xdwdsTasks.Eof then
    Exit;

  xdwdsTasks.Edit;
  xdwdsTasks.FieldByName(fieldName).AsString := newVal;
  xdwdsTasks.Post;

  if triggerId <> '' then
  begin
    asm
      var btn = document.getElementById(triggerId);
      if (btn) {
        var labelEl = btn.querySelector('.task-dd-label');
        if (labelEl) {
          labelEl.textContent = newVal;
        }
      }
    end;
  end;

  SaveRow(idx);
end;


procedure TFTasksHTML.DropdownEditClick(Event: TJSEvent);
var
  el: TJSHTMLElement;
  idx: Integer;
  idxStr, fieldName, triggerId: string;
begin
  Event.preventDefault;
  Event.stopPropagation;

  el := TJSHTMLElement(Event.currentTarget);

  idxStr := string(el.getAttribute('data-idx'));
  fieldName := string(el.getAttribute('data-field'));
  triggerId := string(el.getAttribute('data-trigger-id'));

  idx := StrToIntDef(idxStr, -1);
  if (idx < 0) or (fieldName = '') then
    Exit;

  FNameManager.OpenManager(fieldName, idx, triggerId);
end;


procedure TFTasksHTML.ApplyPendingFocus;
var
  el: TJSHTMLElement;
  selector: string;
begin
  if (FPendingFocusTaskItemId <= 0) or (FPendingFocusField = '') then
    Exit;

  selector :=
    '[data-task-item-id="' + IntToStr(FPendingFocusTaskItemId) + '"] ' +
    '[data-field="' + FPendingFocusField + '"]';

  el := TJSHTMLElement(document.querySelector(selector));
  if Assigned(el) then
  begin
    asm
      el.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' });
      el.focus();
    end;
  end;

  FPendingFocusTaskItemId := 0;
  FPendingFocusField := '';
end;


procedure TFTasksHTML.ApplyPendingDeleteFocus;
var
  el: TJSHTMLElement;
  rowEl: TJSHTMLElement;
  selector: string;
  taskItemIdStr: string;
  taskIdStr: string;
begin
  if (FPendingFocusItemNum <= 0) or (FPendingFocusTaskField = '') then
    Exit;

  selector :=
    'tr[data-item-num="' + IntToStr(FPendingFocusItemNum) + '"] ' +
    '[data-field="' + FPendingFocusTaskField + '"]';

  el := TJSHTMLElement(document.querySelector(selector));
  if Assigned(el) then
  begin
    rowEl := TJSHTMLElement(el.closest('tr'));

    if Assigned(rowEl) then
    begin
      taskItemIdStr := string(rowEl.getAttribute('data-task-item-id'));
      taskIdStr := string(rowEl.getAttribute('data-task-id'));

      FSelectedTaskItemId := StrToIntDef(taskItemIdStr, 0);
      FSelectedTaskId := StrToIntDef(taskIdStr, 0);
      btnDeleteRow.Enabled := FSelectedTaskItemId > 0;
      ApplySelectedRowState;
    end;

    asm
      el.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'nearest' });
      el.focus();
    end;
  end;

  FPendingFocusItemNum := 0;
  FPendingFocusTaskField := '';
end;

procedure TFTasksHTML.CaptureTableScroll;
begin
  asm
    const vscroll = document.querySelector('.tasks-vscroll');
    const hscroll = document.querySelector('.tasks-hscroll');

    this.FPendingScrollTop = vscroll ? vscroll.scrollTop : 0;
    this.FPendingScrollLeft = hscroll ? hscroll.scrollLeft : 0;
  end;
end;

procedure TFTasksHTML.RestoreTableScroll;
begin
  asm
    const vscroll = document.querySelector('.tasks-vscroll');
    const hscroll = document.querySelector('.tasks-hscroll');

    if (vscroll) vscroll.scrollTop = this.FPendingScrollTop || 0;
    if (hscroll) hscroll.scrollLeft = this.FPendingScrollLeft || 0;
  end;
end;




end.

