'데이터 필드 배선' → '값 가져오기' — 개발자 말을 걷어낸다

사용자가 "어떻게 사용하는지 모르겠다, 개발자가 아닌 사람도 쓸 수 있어야 한다" 고 했다.
화면을 열어 보니 낱말만 문제가 아니었다.

## 낱말

- 창 제목 '데이터 필드 배선' → <b>'값 가져오기'</b>. 전산실에서 '배선'은 랜선·전원이고,
  '배선 지우기' 는 데이터소스나 쿼리가 지워지는 것처럼 읽힌다(실제로는 이 컨트롤 속성만 빈다).
- 인스펙터 행 라벨 '데이터 필드' → '값 가져오기'. 빈 상태 '(배선 없음)' → <b>'(가져오지 않음)'</b> —
  대화상자의 지우기 버튼과 <b>글자까지 같게</b> 두어, 누르면 무엇이 되는지 미리 읽히게 했다.
- '필드' → '가져올 컬럼'. 앱은 이미 결과 창에서 '컬럼 N개', 배선 실패 문구에서
  '결과에 X 컬럼이 없습니다' 로 컬럼을 쓴다 — 낱말을 하나 더 만들지 않는다.
- '형태 Select/Rows' 를 화면에서 지웠다. .NET DataTable API 이름이라
  <b>SQL 을 아는 사람이 오히려 더 확실히 오해한다</b>(여기의 Select 는 SQL SELECT 가 아니다).
- '필터' → '행 조건'. WHERE 연상을 끊는다 — 서버로 가지 않고 이미 받아 온 표를 거르는 식이라
  NVL·TO_CHAR·|| 가 통하지 않는다. 되는 예와 안 되는 예를 나란히 적었다.

## 화면이 "무엇을 먼저 하나"의 답을 '지우기'로 주고 있었다

네 칸(형태·필터·행 번호·필드)이 같은 굵기로 나란히 있어 넷 다 채워야 하는 것처럼 보였다.
정작 검증이 요구하는 <b>필수는 둘</b>(데이터소스·컬럼)이고, 그 둘이 화면에서 가장 눈에 안 띄는
자리에 있었다. 게다가 선언 순서 때문에 첫 Tab 정지점이 '배선 지우기' 였다.

①②③ 으로 다시 세웠다 — ① 가져올 데이터소스 ② 가져올 컬럼, ③ '여러 행 중에서 고르기' 는 접는다.
접는 근거는 실측이다: 조건을 쓴 배선이 <b>0건</b>, 행 번호가 0 인 것이 98%.
기본값이 아닐 때만 접힌 머리에 칩('3번째 행' · '조건 있음')을 띄워 숨지 않게 했다.
초기 포커스도 다음 할 일로 보낸다.

## 첫 화면이 굳이 어려운 쪽으로 열리고 있었다

새 배선이 레거시 편집기와 같은 Select 형으로 시작해서, 처음 여는 사람이
<b>평생 안 쓸 개념(조건)</b>을 먼저 봤다. 새 배선을 Rows 형으로 바꿨다.

<b>동작은 같다.</b> 조건이 비면 Select("")(0) 과 Rows(0) 이 같은 행을 가리키고,
해석기 자신이 그 등가를 코드로 인정한다 — DataTableFieldPicker 는 조건이 비어 있으면
필터 경로를 아예 타지 않는다. 기존 값의 형태는 그대로 유지한다(원문을 안 건드리는 것이
왕복 무손실의 조건이다).

## 사용자를 창 밖으로 내보내던 문구

"필드 후보는 쿼리를 실행해야 알 수 있어 목록으로 제시하지 않습니다" —
이 창은 <b>모달이라 밖이 잠겨 있다</b>. 취소 → 데이터소스 찾기 → 쿼리 편집기 → 검증 실행 →
컬럼명 외우기 → 다시 열기. 레거시 편집기는 이 목록을 이미 보여 줬으므로
그 문구는 퇴행을 설명하는 문장이었다.

이제 데이터소스를 고르면 그 조회 결과의 컬럼 이름을 보여 준다. 실행은 이 창이 하지 않는다 —
호출부가 MDataTableRunner 를 함수로 감싸 넘기므로 접속·치환·캐시·실패 처리가 한 군데에 남는다.
창은 데이터소스별로 <b>한 번만</b> 묻는다(Refresh 가 키 입력마다 도는데 그때마다 조회하면 타이핑이 끊긴다).
못 읽으면 <b>왜</b> 못 읽는지 말하고 직접 입력 칸을 남긴다.

## 원문은 없애지 않고 접었다

'저장될 값'에 MDataTable1.Select("")(0).Item("") 이 큰 자리로 보이던 것을
사람 말 한 줄로 바꿨다 — "서식을 열면 — MDataTable2 조회 결과의 첫 행에서 ALGYON 값을
가져와 이 칸에 넣습니다." 원문은 접힌 '저장 형식' 줄로 내리고 복사 버튼을 붙였다.

없애지는 않았다. 인스펙터 툴팁·진단 덤프·레거시 문서가 전부 이 표기로 이야기되고,
이 속성에는 자유 텍스트 편집 경로가 없어 무엇이 저장되는지 볼 수 있는 곳이 이 줄뿐이다.

## 곁가지로 고친 조용한 결함 둘

- <b>행 번호가 조용히 0 이 됐다</b>. 전각 '1' 을 넣으면 파싱 실패가 그대로 0 이 되어
  아무 말 없이 첫 행을 가리켰다. 이제 이유를 말하고 확인을 막는다.
- <b>형태를 바꾸면 조건이 조용히 사라졌다</b>. Rows 조립은 Filter 를 아예 쓰지 않는데
  화면은 칸을 흐리게만 해서 값이 살아 있는 것처럼 보였다. 다시 열면 영구히 없어진다.
  이제 명시적 버튼이고, 버려지는 조건 원문을 확인 문구에 적어 보여 준다.
- 데이터소스가 0개일 때 '데이터소스를 선택하세요'(따를 수 없는 지시) 대신 다음 행동을 말한다.

## 판정

- 단위 시험 388 · edit-smoke 405건 전건 통과 (옛 문구를 기대한 검사 4건도 새 낱말로 갱신)
- --dialog-shots 69장 전건 통과 — 다크·라이트 양쪽을 눈으로 확인
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- <b>저장되는 문자열은 한 글자도 바꾸지 않았다</b> — 화면 문구와 배치만 바뀐다

## 남은 것

컬럼 목록과 값 확인은 DB 접속이 있어야 동작한다. 오프라인 설계에서는 여전히 직접 입력이고,
그때 컬럼명 대소문자 함정이 남는다. '가져올 값 확인'(현재 환자로 실제 한 칸을 뽑아 보여 주기)은
이번에 넣지 않았다 — 환자 세션까지 이 창에 들이는 일이라 별건이다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-21 12:16:55 +09:00
co-authored by Claude Fable 5
parent 592275fa97
commit 5771a824d7
6 changed files with 370 additions and 76 deletions
+3 -1
View File
@@ -66,7 +66,9 @@ public static class ControlRegistry
/// </summary>
public static PropertyDef DataTableField => new()
{
Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.DataTableField,
// 라벨에 '배선' 을 쓰지 않는다 — 전산실에서 배선은 랜선·전원이다.
// '값 가져오기' 는 방향이 이름에 들어 있어 형제 속성(데이터 태그)과도 구별된다.
Key = "DataTableField", Label = "값 가져오기", Editor = PropEditorKind.DataTableField,
};
}
@@ -2515,22 +2515,25 @@ public static class EditSmoke
designer.Inspector.SelectedTab = ViewModels.Inspector.InspectorTab.Data;
var dtfRow = designer.Inspector.Rows
.OfType<ViewModels.Inspector.DataTableFieldRowViewModel>().FirstOrDefault();
Check("배선: 전용 편집 행이 붙는다", dtfRow is not null);
Check("값 가져오기: 전용 편집 행이 붙는다", dtfRow is not null);
if (dtfRow is null)
{
designer.Inspector.SelectedTab = previousTab;
goto skipWiring;
}
Check("배선: 값이 없으면 안내 문구", dtfRow.Summary == "(배선 없음)", dtfRow.Summary);
// 빈 상태 문구는 대화상자의 지우기 버튼과 <b>글자까지 같다</b> —
// 그 버튼을 누르면 무엇이 되는지 미리 읽히게 하려는 것이다
Check("값 가져오기: 값이 없으면 지우기 버튼과 같은 말을 쓴다",
dtfRow.Summary == "(가져오지 않음)", dtfRow.Summary);
// 운영 다수파인 Rows 형을 읽어야 한다(1,624건, 76%)
dtfRow.ValueText = $"{source.Id}.Rows(0).Item(\"ALGYON\")";
Check("배선: Rows 형을 읽는다", dtfRow.Summary.Contains(source.Id) && dtfRow.Summary.Contains("ALGYON"),
Check("값 가져오기: 저장된 값을 사람 말로 요약한다", dtfRow.Summary.Contains(source.Id) && dtfRow.Summary.Contains("ALGYON"),
dtfRow.Summary);
// 해석 못 하는 값은 조용히 고치지 않고 경고로 드러낸다
dtfRow.ValueText = "MDataTable1.Rows";
Check("배선: 미완성 값은 경고로 드러낸다", dtfRow.Summary.StartsWith("⚠"), dtfRow.Summary);
Check("값 가져오기: 미완성 값은 경고로 드러낸다", dtfRow.Summary.StartsWith("⚠"), dtfRow.Summary);
dtfRow.ValueText = string.Empty;
designer.Inspector.SelectedTab = previousTab;
@@ -927,7 +927,12 @@ public sealed class InspectorViewModel : ViewModelBase
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label) { ControlNamesOf = ControlNames },
PropEditorKind.Mask => new MaskRowViewModel(def.Label),
// 배선 대상 목록은 '지금 이 서식'의 데이터소스다 — 런타임도 같은 서식 안에서만 이름을 찾는다
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames),
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames)
{
// 컬럼 목록과 대상 컨트롤 이름도 함께 준다 — 창이 사용자를 밖으로 내보내지 않게 한다
OwnerLabelOf = () => designer.Selection.Primary?.Id ?? string.Empty,
ColumnsOf = DataSourceColumns,
},
_ => new TextRowViewModel(def.Label),
};
// 선의 방향·굵기는 값만 바꾸는 것이 아니라 <b>크기까지</b> 바꾼다(ApplyLineChange).
@@ -986,6 +991,36 @@ public sealed class InspectorViewModel : ViewModelBase
return names;
}
/// <summary>
/// 데이터소스의 조회 결과 컬럼 이름 — 못 읽으면 null(미접속·쿼리 실패).
///
/// 값 가져오기 창이 컬럼을 목록으로 보여 줄 수 있게 하는 재료다. 전에는 그 창이
/// "컬럼 후보는 목록으로 제시하지 않습니다" 라고 적어 두고 사용자를 밖으로 보냈는데,
/// 그 창은 모달이라 밖이 잠겨 있다 — 취소하고 쿼리를 열어 컬럼명을 외워 다시 와야 했다.
///
/// 실행은 <see cref="Services.MDataTableRunner"/> 가 한다 — 접속·치환·캐시·실패 처리가
/// 이미 그 한 군데에 있고, 여기서 다시 만들면 두 벌이 된다.
/// 러너가 데이터소스별로 결과를 캐시하므로 창이 여러 번 물어도 조회는 한 번이다.
/// </summary>
private IReadOnlyList<string>? DataSourceColumns(string name)
{
try
{
var patient = Services.PatientSession.Current;
var runner = new Services.MDataTableRunner(designer.Document,
patient is null ? null
: new Services.PatientQueryVariableSource(patient, Services.UserSession.Current.UidCod));
var result = runner.Run(name);
return result.Ok && result.Columns.Count > 0 ? result.Columns : null;
}
catch
{
// 컬럼 목록은 <b>도움</b>이다 — 못 읽는다고 창이 안 열려서는 안 된다.
// 창은 null 을 받으면 이유를 적고 직접 입력 칸을 남긴다.
return null;
}
}
private IReadOnlyList<string> DataTableNames()
{
var names = new List<string>();
@@ -968,7 +968,12 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
{
private readonly Func<IReadOnlyList<string>> tablesOf;
/// <summary>사람이 읽는 요약 — 배선이 없으면 안내 문구</summary>
/// <summary>
/// 사람이 읽는 요약. <b>'배선' 이라는 낱말을 쓰지 않는다</b> —
/// 전산실에서 배선은 랜선·전원이고, '배선 없음' 은 데이터소스가 없는 것처럼 읽힌다.
/// 빈 상태 문구는 대화상자의 지우기 버튼과 <b>글자까지 같게</b> 둔다 —
/// 그 버튼을 누르면 무엇이 되는지 미리 보이게 하려는 것이다.
/// </summary>
public string Summary
{
get
@@ -979,19 +984,40 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
}
if (ValueText.Length == 0)
{
return "(배선 없음)";
return "(가져오지 않음)";
}
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText);
return spec is null
// 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다
? $"⚠ 형식을 알 수 없음: {ValueText}"
: $"{spec.TableName} · {spec.Field}" + (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex}행");
// 행 0 은 '첫 행' 이다. 그 밖은 사람이 세는 번호(0 → 1번째)로 적는다.
// 조건이 있으면 그 사실을 덧붙인다 — 조건이 화면 어디에도 안 보인 채
// 결과를 바꾸는 상태를 남기지 않는다.
: $"{spec.TableName} · {spec.Field}"
+ (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex + 1}번째 행")
+ (spec.Form == SheetMe.Core.Catalog.DataTableFieldForm.Select && spec.Filter.Length > 0
? " · 조건 있음" : string.Empty);
}
}
/// <summary>편집기 열기</summary>
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
/// <summary>
/// 대상 컨트롤 이름 — 창 제목에 넣는다. 모달이라 인스펙터가 가려져서,
/// 여러 칸을 연달아 채울 때 지금 어느 칸 차례인지 알 방법이 없었다.
/// </summary>
public Func<string>? OwnerLabelOf { get; init; }
/// <summary>
/// 데이터소스 이름 → 그 조회 결과의 컬럼 이름(없으면 null).
///
/// 전에는 창이 '컬럼 후보는 목록으로 제시하지 않습니다' 라고 적어 두고 사용자를 밖으로 보냈는데,
/// 그 창은 모달이라 밖이 잠겨 있다 — 취소하고 쿼리를 열어 컬럼명을 외워 다시 와야 했다.
/// 실행은 이 행이 하지 않는다(호출부가 이미 만들어 둔 러너를 함수로 받는다).
/// </summary>
public Func<string, IReadOnlyList<string>?>? ColumnsOf { get; init; }
public DataTableFieldRowViewModel(string label, Func<IReadOnlyList<string>> tablesOf) : base(label)
{
this.tablesOf = tablesOf;
@@ -1007,7 +1033,8 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
private void OnEdit()
{
var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf())
var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf(),
OwnerLabelOf?.Invoke(), ColumnsOf)
{
Owner = System.Windows.Application.Current.MainWindow,
};
@@ -1,29 +1,58 @@
<Window x:Class="SheetMe.Designer.Views.DataTableFieldDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="데이터 필드 배선" Width="560" Height="520"
Title="값 가져오기" Width="620" Height="560"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,10"
Text="데이터소스(조회 결과)의 한 칸을 이 컨트롤에 꽂습니다. 형식이 어긋나면 런타임이 아무 경고 없이 값을 비워 두므로, 문법은 여기서 조립합니다."/>
<!--
이 창은 "조회 결과의 한 칸을 이 컨트롤에 채우는" 설정이다.
<!-- 아래쪽 버튼 -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="배선 지우기" Padding="12,4" Click="OnClear"/>
<Button x:Name="OkButton" Content="적용" Padding="18,4" Margin="8,0,0,0"
<b>왜 다시 썼나.</b> 전에는 네 칸(형태·필터·행 번호·필드)이 같은 굵기로 나란히 있어서
넷 다 채워야 하는 것처럼 보였다. 정작 <b>필수는 둘</b>(데이터소스·컬럼)이고
그 둘이 화면에서 가장 눈에 안 띄는 자리에 있었다.
게다가 선언 순서 때문에 첫 Tab 정지점이 '배선 지우기' 라,
화면이 "무엇을 먼저 하나"의 답을 '지우기' 로 주고 있었다.
실사용 근거: 조건(필터)을 쓴 배선이 <b>0건</b>이고 행 번호는 98%가 0 이다.
그래서 그 둘은 접어 두고, 기본값이 아닐 때만 칩으로 드러낸다.
-->
<DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,12"
Text="서식이 열릴 때 데이터소스가 받아 온 조회 결과에서 한 칸을 읽어 이 칸을 자동으로 채웁니다. 고를 것은 둘입니다 — ① 어느 데이터소스에서, ② 그 결과의 어느 컬럼을."/>
<!-- 아래쪽 버튼. '가져오지 않음' 은 왼쪽으로 떼어 확인·취소와 섞이지 않게 한다 -->
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0" LastChildFill="False">
<Button DockPanel.Dock="Left" Content="가져오지 않음" Padding="12,4" Click="OnClear"
ToolTip="이 칸을 자동으로 채우지 않습니다 — 데이터소스나 쿼리는 지워지지 않습니다"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button x:Name="OkButton" Content="확인" Padding="18,4"
Style="{StaticResource Primary}" Click="OnApply" IsDefault="True"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
</DockPanel>
<!-- 조립 결과 미리보기 + 검증 메시지 -->
<!-- 사람 말 한 줄 + 접힌 저장 형식 -->
<Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="10,8" CornerRadius="6"
Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1">
<StackPanel>
<TextBlock Text="저장될 값" FontSize="10.5" Foreground="{DynamicResource B.Muted}"/>
<TextBlock x:Name="PreviewText" FontFamily="Consolas" FontSize="12" TextWrapping="Wrap" Margin="0,3,0,0"/>
<TextBlock x:Name="PlainText" TextWrapping="Wrap" FontSize="12.5"/>
<TextBlock x:Name="ProblemText" FontSize="11" TextWrapping="Wrap" Margin="0,5,0,0"
Foreground="{DynamicResource B.Danger}" Visibility="Collapsed"/>
<!--
저장 원문을 없애지 않고 접어 둔다. 인스펙터 툴팁·진단 덤프·레거시 문서가
전부 이 표기로 이야기되므로 원문을 들고 온 사람이 대조할 자리가 있어야 한다.
다만 큰 자리를 주면 창이 '문법 조립하는 곳' 으로 스스로를 재정의한다 —
아무것도 안 고른 상태의 첫 표시가 마침표로 시작하는 것도 그 탓이었다.
-->
<Expander x:Name="RawExpander" Margin="0,6,0,0" Header="저장 형식">
<DockPanel Margin="0,4,0,0">
<Button DockPanel.Dock="Right" Content="복사" Padding="8,2" VerticalAlignment="Top"
Margin="6,0,0,0" Click="OnCopyRaw"/>
<TextBlock x:Name="RawText" FontFamily="Consolas" FontSize="11.5" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}"
ToolTip="레거시 XML 에 이 글자 그대로 들어갑니다"/>
</DockPanel>
</Expander>
</StackPanel>
</Border>
@@ -34,39 +63,67 @@
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 데이터소스 -->
<!-- 데이터소스 -->
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Text="데이터소스" Margin="0,0,0,4"/>
<TextBlock DockPanel.Dock="Top" Text="① 가져올 데이터소스" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock x:Name="NoTableHint" DockPanel.Dock="Bottom" FontSize="11" TextWrapping="Wrap"
Margin="0,6,0,0" Foreground="{DynamicResource B.Warning}" Visibility="Collapsed"
Text="이 서식에 데이터소스가 없습니다. 팔레트에서 '데이터소스'를 먼저 놓고 쿼리를 지정하세요."/>
<ListBox x:Name="TableList" SelectionChanged="OnFormChanged" FontSize="12.5"/>
<!-- 도움말은 라벨 바로 아래다. Bottom 에 두면 목록이 열을 채워
설명이 목록에서 한참 떨어진 빈 공간에 떠 있었다. -->
<TextBlock DockPanel.Dock="Top" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,6"
Foreground="{DynamicResource B.Muted}"
Text="이 서식에 놓아 둔 조회 상자입니다 — 목록에서만 고릅니다."/>
<ListBox x:Name="TableList" SelectionChanged="OnTableChanged" FontSize="12.5"/>
</DockPanel>
<!-- 나머지 조각 -->
<StackPanel Grid.Column="2">
<TextBlock Text="형태" Margin="0,0,0,4"/>
<ComboBox x:Name="FormBox" SelectionChanged="OnFormChanged"
ToolTip="Select 형은 레거시 편집기가 만드는 형태, Rows 형은 손으로 쓰던 형태입니다. 연 값의 형태를 그대로 유지합니다.">
<ComboBoxItem Content="Select — 필터로 행을 고름"/>
<ComboBoxItem Content="Rows — 행 번호로 직접"/>
</ComboBox>
<!-- ② 컬럼 + ③ 접힌 행 고르기 -->
<DockPanel Grid.Column="2">
<TextBlock DockPanel.Dock="Top" Text="② 가져올 컬럼" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBox x:Name="FieldBox" DockPanel.Dock="Top" TextChanged="OnFormChanged"/>
<TextBlock x:Name="FieldHint" DockPanel.Dock="Top" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
Foreground="{DynamicResource B.Muted}"/>
<TextBlock x:Name="FilterLabel" Text="필터" Margin="0,10,0,4"/>
<TextBox x:Name="FilterBox" TextChanged="OnFormChanged"
ToolTip="DataTable.Select 문법 (예: CODCOD='A'). 비워 두면 전체 행입니다. 큰따옴표는 쓸 수 없습니다."/>
<TextBlock Text="행 번호" Margin="0,10,0,4"/>
<TextBox x:Name="RowBox" TextChanged="OnFormChanged" ToolTip="0부터 셉니다"/>
<TextBlock Text="필드" Margin="0,10,0,4"/>
<TextBox x:Name="FieldBox" TextChanged="OnFormChanged"
ToolTip="조회 결과의 컬럼명. SQL 별칭 표현식도 됩니다 (예: A||'-'||B). 큰따옴표는 쓸 수 없습니다."/>
<TextBlock x:Name="FieldHint" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
Foreground="{DynamicResource B.Muted}"
Text="필드 후보는 쿼리를 실행해야 알 수 있어 목록으로 제시하지 않습니다. 데이터소스의 쿼리를 열어 컬럼명을 확인하세요."/>
<!--
③ 은 접어 둔다. 조건을 쓴 실사용 배선이 0건이고 행 번호는 98%가 0 이라,
첫 방문자가 평생 안 쓸 개념을 먼저 보게 할 이유가 없다.
기본값이 아닐 때만 머리에 칩을 띄워 접혀 있어도 숨지 않게 한다.
-->
<Expander x:Name="RowExpander" DockPanel.Dock="Top" Margin="0,14,0,0">
<Expander.Header>
<StackPanel Orientation="Horizontal">
<TextBlock Text="③ 여러 행 중에서 고르기"/>
<Border x:Name="RowChip" Margin="8,0,0,0" Padding="6,1" CornerRadius="3"
Background="{DynamicResource B.Chip}" Visibility="Collapsed">
<TextBlock x:Name="RowChipText" FontSize="10.5"
Foreground="{DynamicResource B.Muted}"/>
</Border>
</StackPanel>
</Expander.Header>
<StackPanel Margin="0,8,0,0">
<TextBlock Text="몇 번째 행" Margin="0,0,0,4"/>
<TextBox x:Name="RowBox" TextChanged="OnFormChanged"/>
<TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,4,0,0"
Foreground="{DynamicResource B.Muted}"
Text="첫 행이 0 입니다. 1 은 두 번째 행입니다. 반각 숫자만 됩니다."/>
<!--
조건은 저장된 값이 조건 형태일 때만 보인다. 새로 만들 때는 아예 안 보인다 —
실사용에서 조건을 쓴 배선이 0건이고, 이 문법은 SQL WHERE 가 아니라
이미 받아 온 표를 거르는 식이라 NVL·TO_CHAR·|| 가 통하지 않는다.
-->
<StackPanel x:Name="FilterArea" Visibility="Collapsed">
<TextBlock Text="행 조건" Margin="0,12,0,4"/>
<TextBox x:Name="FilterBox" TextChanged="OnFormChanged"/>
<TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,4,0,0"
Foreground="{DynamicResource B.Muted}"
Text="이 배선은 조건으로 행을 고르게 저장돼 있습니다. SQL 의 WHERE 가 아니라 받아 온 표를 거르는 식입니다 — 되는 예 CODCOD='A' · 안 되는 예 NVL, TO_CHAR, ||"/>
<Button x:Name="DropFilterButton" Content="조건을 버리고 순서대로 고르기"
Margin="0,8,0,0" Padding="10,3" Click="OnDropFilter"/>
</StackPanel>
</StackPanel>
</Expander>
</DockPanel>
</Grid>
</DockPanel>
</Window>
@@ -6,30 +6,74 @@ using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Views;
/// <summary>
/// DataTableField 전용 편집기 — 문법 골격을 코드가 조립하고, 저장 전에 검사한다.
/// 조회 결과의 한 칸을 컨트롤에 채우는 설정 — 문법 골격을 코드가 조립하고 저장 전에 검사한다.
///
/// 레거시 편집기와 다르게 <b>기존 값을 되읽어 채운다</b>. 레거시는 열 때마다 백지에서 시작해
/// 한 조각만 고치려 해도 전부 다시 골라야 했다(fmMDataTableField.InitializeSelectedData).
/// 형태(Select/Rows)도 연 값 그대로 유지한다 — 운영 다수는 Rows 형이고(1,624건, 76%),
/// 형태를 바꾸면 그 형태만 이해하는 보조 로더에서 동작이 달라질 수 있다.
///
/// <b>새 배선은 Rows 형이다.</b> 전에는 레거시 편집기와 같은 Select 형으로 시작했는데,
/// 그러면 첫 화면이 굳이 <b>조건을 요구하는 어려운 쪽</b>이 된다.
/// 동작은 같다 — 조건이 비면 <c>Select("")(0)</c> 과 <c>Rows(0)</c> 이 같은 행을 가리키고,
/// 해석기 자신이 그 등가를 코드로 인정한다(DataTableFieldPicker 는 조건이 비어 있으면
/// 필터 경로를 아예 타지 않는다). 실사용에서 조건을 쓴 배선은 <b>0건</b>이다.
///
/// <b>기존 값의 형태는 그대로 유지한다</b> — 저장된 원문을 우리가 바꾸지 않는 것이
/// XML 왕복 무손실의 조건이다. Select→Rows 전환은 조건을 버리는 일이라
/// 명시적 버튼으로만 하고, 버려지는 조건 원문을 확인 문구에 적어 보여 준다.
/// </summary>
public partial class DataTableFieldDialogView : Window
{
#region Member Fields
private readonly IReadOnlyList<string> tables;
/// <summary>
/// 데이터소스 이름 → 그 조회 결과의 컬럼 이름. 없으면 null(오프라인·미접속).
///
/// <b>여기가 이 창의 유일한 작업 포기 지점이었다.</b> 전에는 "필드 후보는 쿼리를 실행해야
/// 알 수 있어 목록으로 제시하지 않습니다" 라고 적어 두고 사용자를 창 밖으로 보냈는데,
/// 이 창은 모달이라 밖이 잠겨 있다 — 취소 → 데이터소스 찾기 → 쿼리 편집기 → 검증 실행 →
/// 컬럼명 외우기 → 다시 열기. 레거시 편집기는 이 목록을 이미 보여 줬으므로 그 문구는
/// 퇴행을 설명하는 문장이었다.
///
/// 실행 자체는 이 창이 하지 않는다 — 호출부가 이미 만들어 둔 러너를 함수로 감싸 넘긴다.
/// 그래야 접속·캐시·실패 처리가 한 군데(MDataTableRunner)에만 있다.
/// </summary>
private readonly Func<string, IReadOnlyList<string>?>? columnsOf;
/// <summary>
/// 데이터소스별 컬럼 캐시 — 이 창이 사는 동안 <b>데이터소스마다 한 번만</b> 묻는다.
/// Refresh 는 키 입력마다 도는데 그때마다 조회하면 타이핑이 끊긴다.
/// </summary>
private readonly Dictionary<string, IReadOnlyList<string>?> columnCache = new(StringComparer.Ordinal);
private bool loading = true;
/// <summary>연 값의 형태 — 저장할 때 그대로 되돌려 쓴다(원문을 우리가 바꾸지 않는다)</summary>
private DataTableFieldForm form = DataTableFieldForm.Rows;
#endregion
#region Properties
/// <summary>적용 결과 — 취소면 null, '배선 지우기'면 빈 문자열</summary>
/// <summary>적용 결과 — 취소면 null, '가져오지 않음'이면 빈 문자열</summary>
public string? Result { get; private set; }
#endregion
#region Constructors
public DataTableFieldDialogView(string? current, IReadOnlyList<string> dataTables)
/// <param name="current">지금 저장된 값 — 없으면 새 배선</param>
/// <param name="dataTables">이 서식의 데이터소스 이름</param>
/// <param name="ownerLabel">대상 컨트롤 이름 — 제목에 넣어 "지금 어느 칸 차례인지"를 알린다</param>
/// <param name="columnsOf">데이터소스의 컬럼 이름을 주는 함수(없으면 직접 입력만)</param>
public DataTableFieldDialogView(string? current, IReadOnlyList<string> dataTables,
string? ownerLabel = null, Func<string, IReadOnlyList<string>?>? columnsOf = null)
{
InitializeComponent();
tables = dataTables;
this.columnsOf = columnsOf;
if (ownerLabel is { Length: > 0 })
{
// 모달이라 인스펙터가 가려진다 — 여러 칸을 연달아 채울 때 어느 칸 차례인지 알 수 없었다
Title = $"값 가져오기 — {ownerLabel}";
}
foreach (var name in tables)
{
@@ -40,16 +84,21 @@ public partial class DataTableFieldDialogView : Window
var spec = DataTableFieldSpec.Parse(current);
if (spec is not null)
{
FormBox.SelectedIndex = spec.Form == DataTableFieldForm.Rows ? 1 : 0;
form = spec.Form;
TableList.SelectedItem = tables.FirstOrDefault(t => string.Equals(t, spec.TableName, StringComparison.Ordinal));
FilterBox.Text = spec.Filter;
RowBox.Text = spec.RowIndex.ToString(CultureInfo.InvariantCulture);
FieldBox.Text = spec.Field;
// 기본값이 아닌 것이 접혀 있으면 숨는다 — 그때만 펼친 채로 연다
if (spec.RowIndex != 0 || (form == DataTableFieldForm.Select && spec.Filter.Length > 0))
{
RowExpander.IsExpanded = true;
}
}
else
{
// 새 배선 — 레거시 편집기와 같은 Select 형으로 시작한다
FormBox.SelectedIndex = 0;
// 새 배선 — 조건 없이 첫 행. 첫 화면에 평생 안 쓸 개념을 두지 않는다.
form = DataTableFieldForm.Rows;
RowBox.Text = "0";
if (tables.Count == 1)
{
@@ -59,47 +108,134 @@ public partial class DataTableFieldDialogView : Window
loading = false;
Refresh();
// 첫 포커스 — 데이터소스가 이미 정해졌으면 다음 할 일(컬럼)로 보낸다.
// 이 호출이 없으면 선언 순서상 첫 Tab 정지점이 파괴적 버튼이었다.
Loaded += (_, _) =>
{
if (TableList.SelectedItem is null && TableList.Items.Count > 0)
{
TableList.Focus();
}
else
{
FieldBox.Focus();
}
};
}
#endregion
#region Methods
/// <summary>
/// 현재 입력을 명세로 옮긴다 — 행 번호는 숫자가 아니면 0 으로 본다
/// (레거시도 빈 값을 0 으로 강제한다: fmMDataTableField.vb:135).
/// 현재 입력을 명세로 옮긴다.
///
/// <b>행 번호를 조용히 0 으로 바꾸지 않는다.</b> 전에는 파싱 실패가 그대로 0 이 됐는데,
/// 그러면 전각 '1' 을 넣은 배선이 아무 말 없이 첫 행을 가리킨다(실사용에 그런 값이 실재한다).
/// 잘못된 입력은 <see cref="Refresh"/> 가 이유를 말하고 확인을 막는다.
/// </summary>
private DataTableFieldSpec CurrentSpec()
private DataTableFieldSpec CurrentSpec() => new()
{
var row = int.TryParse(RowBox.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0;
return new DataTableFieldSpec
{
Form = FormBox.SelectedIndex == 1 ? DataTableFieldForm.Rows : DataTableFieldForm.Select,
Form = form,
TableName = TableList.SelectedItem as string ?? string.Empty,
Filter = FilterBox.Text,
RowIndex = row,
Filter = form == DataTableFieldForm.Select ? FilterBox.Text : string.Empty,
RowIndex = ParsedRow() ?? 0,
Field = FieldBox.Text.Trim(),
};
/// <summary>행 번호 — 반각 숫자로 읽히지 않으면 null</summary>
private int? ParsedRow()
{
var text = RowBox.Text.Trim();
if (text.Length == 0)
{
return 0; // 빈 값은 첫 행이다(레거시도 빈 값을 0 으로 본다)
}
return int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) ? parsed : null;
}
private void Refresh()
{
// 조건 칸은 저장된 값이 조건 형태일 때만 보인다
FilterArea.Visibility = form == DataTableFieldForm.Select ? Visibility.Visible : Visibility.Collapsed;
var spec = CurrentSpec();
var rowsForm = spec.Form == DataTableFieldForm.Rows;
RawText.Text = spec.Compose();
UpdateRowChip(spec);
UpdateFieldHint(spec.TableName);
// Rows 형은 필터를 아예 쓰지 않는다 — 켜 두면 입력해도 무시되는 칸이 된
FilterBox.IsEnabled = !rowsForm;
FilterLabel.Opacity = rowsForm ? 0.45 : 1;
FilterBox.Opacity = rowsForm ? 0.45 : 1;
// 전각 숫자처럼 읽을 수 없는 행 번호는 <b>먼저</b> 말한다 — 조용히 0 으로 바꾸지 않는
var problem = ParsedRow() is null
? "몇 번째 행: 반각 숫자만 됩니다"
// 고를 데이터소스가 아예 없으면 '고르세요' 는 따를 수 없는 지시다 — 다음 행동을 말한다
: tables.Count == 0
? "이 서식에 데이터소스가 없습니다 — 팔레트에서 먼저 놓고 쿼리를 지정하세요"
: spec.Validate(tables);
PreviewText.Text = spec.Compose();
var problem = spec.Validate(tables);
PlainText.Text = problem is null ? PlainSentence(spec) : "아직 가져올 값이 정해지지 않았습니다.";
ProblemText.Text = problem ?? string.Empty;
ProblemText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible;
// 레거시는 아무것도 안 고르고 확인하면 ")(" 같은 찌꺼기를 저장한다 — 그 경로를 막는다
OkButton.IsEnabled = problem is null;
}
/// <summary>사람 말 한 줄 — 이 창이 무엇을 하기로 했는지</summary>
private static string PlainSentence(DataTableFieldSpec spec)
{
var where = spec.RowIndex == 0 ? "첫 행" : $"{spec.RowIndex + 1}번째 행";
var scope = spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0
? "조건을 통과한 행 중 " + where
: where;
return $"서식을 열면 — {spec.TableName} 조회 결과의 {scope}에서 "
+ $"{spec.Field} 값을 가져와 이 칸에 넣습니다.";
}
/// <summary>접힌 ③ 의 머리 칩 — 기본값이 아니면 접혀 있어도 드러낸다</summary>
private void UpdateRowChip(DataTableFieldSpec spec)
{
var parts = new List<string>();
if (spec.RowIndex != 0)
{
parts.Add($"{spec.RowIndex + 1}번째 행");
}
if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0)
{
parts.Add("조건 있음");
}
RowChipText.Text = string.Join(" · ", parts);
RowChip.Visibility = parts.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
/// <summary>
/// 컬럼 칸 아래 안내 — 실제 컬럼 목록을 얻을 수 있으면 그것을 보여 준다.
/// 못 얻으면 <b>왜 못 얻는지</b>를 말한다(전에는 "목록으로 제시하지 않습니다"로 끝났다).
/// </summary>
private void UpdateFieldHint(string tableName)
{
if (tableName.Length == 0)
{
FieldHint.Text = "데이터소스를 먼저 고르면 그 조회 결과의 컬럼 이름을 보여 줍니다.";
return;
}
if (!columnCache.TryGetValue(tableName, out var columns))
{
columns = columnsOf?.Invoke(tableName);
columnCache[tableName] = columns;
}
if (columns is { Count: > 0 })
{
FieldHint.Text = $"{tableName} 의 컬럼 {columns.Count}개 — "
+ string.Join(", ", columns.Take(12))
+ (columns.Count > 12 ? " …" : string.Empty);
return;
}
// 못 읽었을 때도 <b>왜</b>인지 말한다. 전에는 "목록으로 제시하지 않습니다" 로 끝나
// 사용자가 무엇을 하면 되는지 알 수 없었다. 문구는 짧게 — 여기가 도움말 자리다.
FieldHint.Text = "컬럼 목록을 읽지 못했습니다(DB 미접속·쿼리 실패) — 직접 입력하세요."
+ " 조회 결과 표의 머리글 이름이고 대개 전부 대문자입니다(예: ALB, ACPDTE).";
}
private void OnTableChanged(object sender, SelectionChangedEventArgs e) => OnFormChanged(sender, e);
private void OnFormChanged(object sender, RoutedEventArgs e)
{
if (!loading)
@@ -108,8 +244,42 @@ public partial class DataTableFieldDialogView : Window
}
}
/// <summary>
/// 조건을 버리고 순서대로 고르기 — <b>되돌릴 수 없어</b> 버려지는 조건을 먼저 보여 준다.
///
/// 전에는 형태 콤보를 바꾸는 것만으로 조건이 조립에서 조용히 사라졌고(Rows 조립은 Filter 를
/// 아예 쓰지 않는다) 다시 열면 Rows 해석에 조건 항목이 없어 영구히 없어졌다.
/// 화면은 칸을 흐리게만 해서 값이 살아 있는 것처럼 보였다.
/// </summary>
private void OnDropFilter(object sender, RoutedEventArgs e)
{
var filter = FilterBox.Text;
if (filter.Length > 0 && !Services.DialogService.Confirm("조건 버리기",
"이 배선의 행 조건을 지우고 순서대로 고르게 바꿉니다.",
$"지워지는 조건: {filter}\n확인을 누르면 되돌릴 수 없습니다.",
destructive: true, owner: this))
{
return;
}
form = DataTableFieldForm.Rows;
FilterBox.Text = string.Empty;
Refresh();
}
private void OnCopyRaw(object sender, RoutedEventArgs e)
{
if (RawText.Text is { Length: > 0 } raw)
{
Clipboard.SetText(raw);
}
}
private void OnApply(object sender, RoutedEventArgs e)
{
if (ParsedRow() is null)
{
return;
}
var spec = CurrentSpec();
if (spec.Validate(tables) is not null)
{