태그 선택 — 타입별로 자주 쓰는 것을 그 자리에 깐다
태그를 하나 걸려면 매번 모달을 열어 500여 개(액션 160여 개) 목록에서 찾아야 했다. 한 서식이 쓰는 서로 다른 태그는 중앙값 6개, 최대 27개다(--db-tags) — 서식 하나 만들 때마다 그 짓을 예닐곱 번 한다. ■ 실측이 설계를 정했다 --db-tags 진단을 신설해 운영 디자인 1,271건의 태그 사용 8,032건을 컨트롤 타입별로 집계했다. 후보가 타입마다 거의 완전히 갈린다: 이미지 · 데이터 태그 343건 / 7종 — 상위 16종이 100% (서명·직인·로고가 전부) 라디오 · 액션 태그 736건 / 7종 — 100% (RadCheckControl 이 580건) 체크박스 · 액션 태그 357건 / 7종 — 100% 텍스트박스 · 액션 태그 200건 / 6종 — 100% 마스크입력 · 데이터 태그 57건 / 13종 — 100% 버튼 · 액션 태그 393건 / 46종 — 상위 16종이 83% 텍스트박스 · 데이터 태그 5,927건 / 144종 — 상위 16종이 65% (여기만 꼬리가 길다) 즉 일곱 조합 중 다섯은 **짧은 목록이 운영 전체를 덮는다**. 이미지 하나 고르려고 500개를 뒤지고 있었다는 뜻이다. ■ 바꾼 것 · 인스펙터 행 아래에 그 타입에서 자주 쓰는 태그를 빈도순 칩으로 깐다(최대 8개). 누르면 바로 값이 된다. 칩은 **값이 비어 있을 때만** 뜬다 — 이미 정해진 행 아래에도 깔면 좁은 패널이 금방 길어진다. · 피커를 열면 자주 쓰는 것이 맨 위로 올라온다(그 안에서는 빈도순, 나머지는 원래 가나다순 유지). 건수 표시에 '자주 쓰는 것 N건이 위에' 를 덧붙였다. · 전체 카탈로그와 자유 입력은 그대로다. 제안은 제안이지 제한이 아니다 — 타입별 자료가 없는 조합의 액션 태그는 아예 비워 둔다. 추측으로 권하면 그 칸은 배포된 뒤에야 빈칸으로 드러난다. 순서를 가나다순이 아니라 빈도순으로 둔 것이 핵심이다. 가나다순은 운영의 절반을 차지하는 RadCheckControl 과 한 번도 안 쓰인 태그를 같은 무게로 보여 준다. ■ 곁다리로 확인한 것 카탈로그에 없는 값이 데이터 태그 1종(PAT_전체주소 ×11), 액션 태그 1종(OprEmrList ×5) 나왔다. 둘 다 레거시 bzDataInterface / EN_DataActionTyp 에 존재하지 않는다 — 우리 카탈로그가 맞고, 저 값들은 운영 서식에 이미 박힌 죽은 태그다(런타임이 해석하지 못한다). 우리가 만든 것이 아니라 손대지 않았다. 편집 스모크 17건 추가(타입별 좁힘 / 빈도 순서 / 자료 없는 타입은 빈 목록 / 제안이 전부 카탈로그에 있는지 12조합 검사 / 값이 정해지면 칩 감춤). 회귀: 테스트 142/142, 편집 스모크 210건 실패 0, DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ddf2bdaee8
commit
de01012b7e
@@ -353,18 +353,38 @@
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:TagPickerRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<!-- 26px 칩 옆에서 "…" 는 점 하나로 보인다 — 아이콘 버튼으로 -->
|
||||
<Button DockPanel.Dock="Right" Width="28" Height="26" Padding="0" Margin="4,0,0,0"
|
||||
Command="{Binding BrowseCommand}" ToolTip="목록에서 선택"
|
||||
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
|
||||
<Grid>
|
||||
<TextBox Style="{StaticResource RowInput}" bh:InspectorFieldBehavior.CommitOnEnter="True" Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
|
||||
ToolTip="직접 입력하거나 오른쪽 버튼으로 목록에서 선택"/>
|
||||
<TextBlock Style="{StaticResource MixedHintOverlay}"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
<StackPanel Margin="0,2">
|
||||
<DockPanel>
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<!-- 26px 칩 옆에서 "…" 는 점 하나로 보인다 — 아이콘 버튼으로 -->
|
||||
<Button DockPanel.Dock="Right" Width="28" Height="26" Padding="0" Margin="4,0,0,0"
|
||||
Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택"
|
||||
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
|
||||
<Grid>
|
||||
<TextBox Style="{StaticResource RowInput}" bh:InspectorFieldBehavior.CommitOnEnter="True" Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
|
||||
ToolTip="직접 입력하거나 아래 제안을 누르세요"/>
|
||||
<TextBlock Style="{StaticResource MixedHintOverlay}"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
<!-- 이 컨트롤 타입에서 실제로 쓰이는 태그(빈도순). 아직 안 골랐을 때만 뜬다 —
|
||||
이미 정해진 행 아래에도 칩을 깔면 좁은 인스펙터가 금방 길어진다. -->
|
||||
<ItemsControl ItemsSource="{Binding Suggestions}" Margin="92,5,0,2"
|
||||
Visibility="{Binding ShowSuggestions, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Style="{StaticResource SuggestChip}" Content="{Binding}"
|
||||
Command="{Binding DataContext.PickCommand,
|
||||
RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
|
||||
|
||||
@@ -13,15 +13,25 @@ public partial class TagPickerDialogView : Window
|
||||
private readonly IReadOnlyList<string> allTags;
|
||||
#endregion
|
||||
|
||||
#region Member Fields - Ordering
|
||||
/// <summary>
|
||||
/// 이 컨트롤 타입에서 자주 쓰이는 태그 — 목록 맨 위로 올린다.
|
||||
/// 가나다순으로만 늘어놓으면 운영 절반을 차지하는 태그와 한 번도 안 쓰인 태그가 같은 무게로 보인다.
|
||||
/// </summary>
|
||||
private readonly IReadOnlyList<string> preferred;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>선택된 태그 — '값 지우기'면 빈 문자열</summary>
|
||||
public string? SelectedTag { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue)
|
||||
public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue,
|
||||
IReadOnlyList<string>? preferredTags = null)
|
||||
{
|
||||
allTags = tags;
|
||||
preferred = preferredTags ?? Array.Empty<string>();
|
||||
InitializeComponent();
|
||||
TitleText.Text = title;
|
||||
ApplyFilter(string.Empty);
|
||||
@@ -41,17 +51,43 @@ public partial class TagPickerDialogView : Window
|
||||
private void ApplyFilter(string keyword)
|
||||
{
|
||||
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var filtered = tokens.Length == 0
|
||||
IEnumerable<string> matched = tokens.Length == 0
|
||||
? allTags
|
||||
: allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
: allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
// 자주 쓰는 것 먼저, 그 안에서는 빈도 순서 그대로. 나머지는 원래(가나다) 순서를 지킨다.
|
||||
// OrderBy 는 안정 정렬이라 같은 순위끼리는 입력 순서가 보존된다.
|
||||
var filtered = matched
|
||||
.OrderBy(t =>
|
||||
{
|
||||
var index = IndexOfPreferred(t);
|
||||
return index < 0 ? int.MaxValue : index;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
TagList.ItemsSource = filtered;
|
||||
CountText.Text = $"{filtered.Count}/{allTags.Count}건";
|
||||
var hit = filtered.Count(t => IndexOfPreferred(t) >= 0);
|
||||
CountText.Text = hit > 0
|
||||
? $"{filtered.Count}/{allTags.Count}건 · 자주 쓰는 것 {hit}건이 위에"
|
||||
: $"{filtered.Count}/{allTags.Count}건";
|
||||
if (filtered.Count > 0)
|
||||
{
|
||||
TagList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private int IndexOfPreferred(string tag)
|
||||
{
|
||||
for (var i = 0; i < preferred.Count; i++)
|
||||
{
|
||||
if (string.Equals(preferred[i], tag, StringComparison.Ordinal))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void OnPick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (TagList.SelectedItem is not string tag)
|
||||
|
||||
Reference in New Issue
Block a user