Skip to content

Read appearance rules written on properties, not only on classes - #21

Merged
peopleworks merged 3 commits into
peopleworks:mainfrom
MBrekhof:fix/property-level-appearance-rules
Aug 17, 2026
Merged

Read appearance rules written on properties, not only on classes#21
peopleworks merged 3 commits into
peopleworks:mainfrom
MBrekhof:fix/property-level-appearance-rules

Conversation

@MBrekhof

Copy link
Copy Markdown

AppearanceAttribute is declared

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method |
                AttributeTargets.Property | AttributeTargets.Interface, AllowMultiple = true)]

and Declare Conditional Appearance Rules in Code lists applying it to a property as Approach 1, and applying it to the class with the property named in TargetItems as Approach 2 — two spellings of one rule.

ExtractAppearanceRules read only classDecl.AttributeLists, so the property spelling produced nothing at all. ExtractValidationRules, immediately above it, has walked the properties as well as the class from the beginning — this brings the two into line.

Why the suite agreed with it

Every [Appearance] in every fixture happened to be class-level. So the tests confirmed the class-only walk was sufficient, and a form that no fixture used could not be seen to be missing. That is the same shape as the CustomMessageTemplate blind spot you found while closing #14, where all 299 tests agreed the last positional literal was the criteria because no fixture passed a message by position.

It matters more here than a miscount would: an entity's section is presented as its complete inventory, so a rule that governs a property and is reported nowhere leads the reader to conclude the property is unconditionally editable.

The TargetItems decision

A property rule that does not name its own TargetItems now records the property it was written on — which is exactly what the equivalent class-level spelling states outright, so the two approaches extract alike rather than documenting differently depending on which the author happened to choose.

An explicit TargetItems is left untouched. Overwriting it would silently narrow a rule that names several targets, so StockBatch's TargetItems = "*" is pinned by a test.

If you would rather the property name landed somewhere other than TargetItems — a distinct TargetProperty on ExtractedAppearanceRule, mirroring ExtractedValidationRule — say so and I will move it. I picked TargetItems because it needs no model or renderer change and reads identically to Approach 2, but the model is your call.

Measured

before after
WLNCentral (196 entities, legacy) 25 rules 33
A generated XAF app whose only appearance rules are property-level 0 rules 2

Tests

PropertyLevelAppearanceRuleTests — the rule is found, keeps its criteria, targets the property it was written on, does not displace the class-level rule on the same entity, and does not overwrite an explicit TargetItems.

Product in the demo fixture gains one property-level rule, so TheDemoApplicationKeepsItsShape moves 9 → 10 deliberately, and the README test count moves 318 → 323.

323 tests, no warnings.

`AppearanceAttribute` is usable on a class, a property, a method or an
interface, and "Declare Conditional Appearance Rules in Code" teaches the
property form first: a rule on `UnitPrice` and a rule on the class naming
`TargetItems = "UnitPrice"` are two spellings of one rule.

`ExtractAppearanceRules` read only `classDecl.AttributeLists`, so the property
spelling produced nothing. `ExtractValidationRules`, immediately above it, had
walked the properties as well as the class from the beginning.

Every `[Appearance]` in every fixture happened to be class-level, so the suite
agreed the class-only walk was enough -- the same shape as the
`CustomMessageTemplate` blind spot, where a form no fixture used could not be
seen to be missing.

A property rule that does not name its own `TargetItems` now records the
property it was written on, which is what the equivalent class-level spelling
states outright; the two forms extract alike. An explicit `TargetItems` is left
untouched, since overwriting it would silently narrow a rule naming several
targets.

Measured: WLNCentral 25 -> 33 rules, and a generated XAF app whose only
appearance rules were property-level went 0 -> 2.

323 tests.
Comment thread src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs Fixed
Comment thread src/XafLogicExplainer.Core/Analyzers/EntityAnalyzer.cs Fixed
Reading rules off properties made an existing gap reachable. The fold that
carries a base class's rules down to its descendants keys appearance rules on
`rule.Id` alone, and `FoldInto` skips a key it has already seen -- so two rules
with no id looked like one rule and the second was dropped in silence.

An empty id is ordinary rather than an omission: a rule written on a property
already says what it governs, and the DevExpress non-persistent-objects demo
writes `[Appearance("", Enabled = false, TargetItems = "*")]`. Before rules were
read off properties this was hard to reach, since one unnamed rule per class is
the most anyone writes.

`ValidationRuleKey` already guards the same way -- it falls back to the attribute
and the property when a rule has no id. `AppearanceRuleKey` now mirrors it,
falling back to the targets, which for a property rule is the property.

It inherits that fallback's documented limitation unchanged: two unnamed rules
over the same targets still cannot be told apart.

`AuditedObject` gains the two unnamed rules that reproduce it, so the fixtures
now cover the form as well as the class-level one. Its folded totals are pinned
alongside the declared ones they exist to contrast with.

326 tests. WLNCentral stays at 33 rules and the generated app at 2 -- both name
their rules, so this changes nothing for them.
@MBrekhof

Copy link
Copy Markdown
Author

Reviewing this against itself before you spend time on it, since the last few rounds each turned up something the tests agreed with.

It had a bug, now fixed in c06f27f.

Reading rules off properties made an existing gap reachable rather than introducing one. FoldInto keys appearance rules on rule.Id alone and skips a key it has already seen, so two rules with no id looked like one rule and the second was dropped in silence — no warning, and the survivor reads as the whole truth.

An empty id is ordinary rather than an omission. A rule on a property already says what it governs, and your own reference points at the non-persistent-objects demo writing [Appearance("", Enabled = false, TargetItems = "*")]. Before this PR it was hard to reach, because one unnamed rule per class is the most anyone writes on a class.

ValidationRuleKey already guards exactly this way, so AppearanceRuleKey mirrors it and falls back to the targets — which, for a property rule, is the property. It inherits that fallback's documented limitation unchanged: two unnamed rules over the same targets still cannot be told apart. Consistent with the validation side rather than a second convention, but say the word if you want them keyed harder.

AuditedObject gains the two unnamed rules that reproduce it, so the fixtures now cover that form too. Its folded totals are pinned next to the declared ones they exist to contrast with, so the numbers in that test's comment are enforced rather than asserted in prose.

Three things I checked and left alone, in case you disagree with any:

  • Method-level rules are still not read. AttributeTargets includes Method, and the reference notes you can apply the attribute to the activation method and omit the Method parameter. Out of scope here; happy to file it.
  • Interface-level rules are also not read, but nothing in the analyser looks at InterfaceDeclarationSyntax at all, so that is a bigger question than this PR.
  • The Contains("Appearance") filter is loose and now applies to property attributes as well. Across WLNCentral and a generated app the only thing it matches is [Appearance(, so I left the existing predicate rather than tighten it inside a bug fix.

326 tests, no warnings. WLNCentral stays at 33 rules and the generated app at 2 — both name their rules, so the fold fix changes nothing for either; it is purely defensive.

Both new loops existed only to skip what `ReadAppearanceRule` returned nothing
for, which CodeQL reads as a filter written in the wrong place. Saying it with
`Select` and `OfType` states the same thing directly, and matches how
`AppearanceAttributesOf` above already selects and filters.

No behaviour change; 326 tests.
@peopleworks
peopleworks merged commit e803423 into peopleworks:main Aug 17, 2026
6 checks passed
@peopleworks

Copy link
Copy Markdown
Owner

Merged. Thank you — this is the second time you have found a form the whole suite agreed did not exist, and the reasoning in the description is why I could review it quickly rather than in spite of it.

TargetItems — keep it where you put it

Your instinct was right, and the documentation says so outright. The table in Declare Conditional Appearance Rules in Code reads:

Element to be affected Attribute Target AppearanceItemType TargetItems
Business class property — Approach 1 Property ViewItem (blank)
Business class property — Approach 2 Class ViewItem The required property's name

The blank in Approach 1 is not missing information: it means "the property this is written on". Filling it in states what XAF itself resolves at run time. A separate TargetProperty would make the two approaches extract differently again — just in a new field — which is precisely what this PR exists to remove. No change wanted.

What your change made reachable

I probed the generated documents rather than the extraction result, and found three things. Before naming them: none is a regression from your PR. I checked, because it would have changed whether I merged. All three are on main today.

1. An unnamed rule renders with no name and no condition

Your two new fixture rules produce this in the Markdown, verbatim:

- **** — when ``: enabled=false (fields: ChangedBy) — inherited from `AuditedObject`
- **** — when ``: visibility=Hide (fields: AuditNotes) — inherited from `AuditedObject`

**** from the empty id, and when from a rule that has no criteria. The second half matters more than it looks: in XAF a rule with no criteria is always active, which is a stronger claim than a conditional one, and the page renders it as though the condition failed to load.

To confirm it predates you, I ran main — without your branch — against the class-level spelling the DevExpress non-persistent-objects demo uses:

[Appearance("", Enabled = false, TargetItems = "*")]
public class Article : BaseObject
main, no PR:  - **** — when ``: enabled=false (fields: *)

So this is ours and it is old. What your change does is make it common: rules on properties are the first form the documentation teaches, and a rule on a property is exactly the one an author leaves unnamed.

The fix is already half-written in this repo — HtmlExplainerGenerator.cs:654 says E(rule.Criteria ?? "always"). The HTML author had already decided the right word. MarkdownDocumentationGenerator.cs:544 and :696, and XafDetailTools.cs:802, never got it.

2. The diff cannot see an unnamed appearance rule appear or disappear

ProjectDiffEngine.cs:132-133 keys appearance rules on r.Id alone and collects them into a HashSet<string> — the same collapse you just fixed in FoldInto, one file over, and reachable now for the same reason.

Probed by removing one of the two unnamed rules and comparing the snapshots:

declared appearance rules: before=2  after=3
appearance-rule changes reported by the diff: 0

A rule was added to the application and the diff said nothing. AppearanceRuleKey is the fix; it just needs to be used in both places.

3. Criteria passed by position is dropped

This one is a genuinely separate defect, so it is #22 rather than a paragraph in a merged thread.

AppearanceAttribute has three constructors, and two of them take the criteria positionally:

AppearanceAttribute(string id)
AppearanceAttribute(string id, string criteria)
AppearanceAttribute(string id, AppearanceItemType appearanceItemType, string criteria)

ReadAppearanceRule reads Criteria only when it is named. Probed with a throwaway fixture:

[Appearance("Ticket_ClosedIsGrey", "Status = 'Closed'", FontColor = "Gray")]
extracted:  id=[Ticket_ClosedIsGrey]  targets=[]  criteria=[]

And the criteria index — the page that introduces itself as "every distinct expression in this application" — does not list Status = 'Closed'.

It is the same shape as the CustomMessageTemplate blind spot you cite in your description, one argument over: every fixture writes Criteria = named, so the suite agrees. ApplyPositionalArguments in the same file is the model for the fix.

Who takes what

1 and 2 are mine. They are our renderers and our diff, they predate your first contribution, and I do not want **** sitting in released output while it waits for someone else's time.

3 is yours if you want it#22. It is the substantial one, it has a working model in the same file, and nothing blocks on it. If you would rather not, say so and I will pick it up; you have found two of these now and being handed the cleanup for it is the wrong reward.

@MBrekhof
MBrekhof deleted the fix/property-level-appearance-rules branch August 18, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants