Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ jobs:
- name: Convert source (validates SFDX project structure)
run: sf project convert source --output-dir mdapi-out

- name: Verify manifest members exist in source
- name: Verify manifest members exist in source (all manifests)
run: |
python3 - <<'EOF'
import sys, xml.etree.ElementTree as ET
import glob, sys, xml.etree.ElementTree as ET
from pathlib import Path

NS = {'m': 'http://soap.sforce.com/2006/04/metadata'}
Expand All @@ -54,20 +54,30 @@ jobs:
'Dashboard': '.dashboardFolder-meta.xml',
}

tree = ET.parse('manifest/package.xml')
def candidates_for(kind, value):
if kind == 'ListView':
obj, _, name = value.partition('.')
return [Path('force-app/main/default/objects') / obj / 'listViews' / f'{name}.listView-meta.xml']
if kind not in SUFFIX:
return None
base, suffix = SUFFIX[kind]
paths = [Path(base) / (value + suffix)]
if '/' not in value and kind in FOLDER_SUFFIX:
paths.append(Path(base) / (value + FOLDER_SUFFIX[kind]))
return paths

missing = []
for t in tree.getroot().findall('m:types', NS):
name = t.find('m:name', NS).text
if name not in SUFFIX:
continue
base, suffix = SUFFIX[name]
for member in t.findall('m:members', NS):
value = member.text
candidates = [Path(base) / (value + suffix)]
if '/' not in value and name in FOLDER_SUFFIX:
candidates.append(Path(base) / (value + FOLDER_SUFFIX[name]))
if not any(c.is_file() for c in candidates):
missing.append(f'{name}: {value}')
for manifest in sorted(glob.glob('manifest/*.xml')):
tree = ET.parse(manifest)
for t in tree.getroot().findall('m:types', NS):
kind = t.find('m:name', NS).text
for member in t.findall('m:members', NS):
paths = candidates_for(kind, member.text)
if paths is None:
continue
if not any(p.is_file() for p in paths):
missing.append(f'{manifest}: {kind} {member.text}')
print(f'checked {manifest}')
if missing:
print('Manifest members with no matching source file:')
print('\n'.join(missing))
Expand Down
55 changes: 50 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
[![Validate metadata](https://github.com/mshresponse/nzc-customreports/actions/workflows/validate.yml/badge.svg)](https://github.com/mshresponse/nzc-customreports/actions/workflows/validate.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

**16 custom report types, 16 reports and 2 dashboards for Salesforce Net Zero Cloud**
(Agentforce Net Zero), using core Lightning reporting only. No CRM Analytics, no Einstein,
no managed package, no custom objects or fields. API 67.0.
**16 custom report types, 21 reports, 3 dashboards and 6 list views for Salesforce Net Zero
Cloud** (now sold as Agentforce Net Zero), using core Lightning reporting only. No CRM
Analytics, no Einstein, no managed package, no custom objects or fields. API 67.0.

On the name: the product launched as Sustainability Cloud, became Net Zero Cloud in 2022 and
Agentforce Net Zero in 2025. Everything here is labelled `NZC:` because that is what the
object API names, the Setup menu and the developer guide still say — and because a report
type's API name can't be renamed in place, only deleted and recreated in every org that has it.

The reports are laid out for **inline editing**, so a sustainability team can correct data
in the report instead of raising a ticket for a CSV re-upload.
Expand Down Expand Up @@ -77,11 +82,24 @@ nothing explains.
**3 summaries** — Emissions by Reporting Year, Fuel Consumption by Fuel Type, Fleet Fuel by
Fuel Type.

**5 audit reports** — the part of the CRM Analytics audit dashboard you can rebuild in core
Lightning. Emissions by Asset and Year is a matrix, one site per row and one year per
column, so a figure that jumped or collapsed between years stands out on a single screen.
Footprints by Stage and Footprints by Year and Stage (stationary and fleet) show how much of
each disclosure year is still draft or in progress — a number that could still change under
audit.

**16 report types** covering both stationary and fleet, in three shapes each — `with`
(inner join), `with/without` (outer join), and standalone. Build your own reports on any of
them; that's rather the point of shipping the source.

**2 dashboards** — NZC Data Quality and NZC Emissions Coverage.
**3 dashboards** — NZC Audit, NZC Data Quality and NZC Emissions Coverage. NZC Audit has a
**Reporting Year** filter across the top, so an admin can audit one disclosure year at a
time; the year list is 2023–2026 and is a one-minute edit in the dashboard builder. Every
component opens its report, and every report is editable inline where the record exists.

**6 list views**, optional, for Winter '27 bulk editing — see
[List views for bulk edits](#list-views-for-bulk-edits-winter-27).

---

Expand Down Expand Up @@ -121,13 +139,40 @@ The limits shaped the report layouts, so they're worth knowing before you promis
| Grouping fields are not editable | Group by something you don't need to fix. |
| A lookup that's null across the row can't be edited | You can't create a missing record from a report. |
| FLS and validation rules still apply | A failing edit shows the rule's own message. |
| The field must be on the record's page layout, on the default tab | A column that's greyed out in the report is usually a field that's missing from the layout or sits on a second tab. Winter '27 lifts this for **list views only** (see below); reports still need the field on the layout. |

That fourth row is why the **Missing** reports are upload worklists, not fix-it-here
reports. If an asset has no footprint record, there's no footprint row to edit. Inline
editing fixes what exists; it doesn't conjure records.

---

<a name="list-views-for-bulk-edits-winter-27"></a>
## List views for bulk edits (Winter '27)

Winter '27 adds two **User Interface** settings (Setup → User Interface), both off by
default:

- **Make inline edits in list views with multiple record types** — a list mixing record
types used to turn editing off entirely.
- **Remove list view inline edit dependencies on page layout** — edit any field you have
edit access to, on the layout or not.

With both on, a list view becomes the better bulk-edit surface: edit several rows, save
once, no refresh after every cell. The package ships one list view per Net Zero Cloud
object with the same narrow columns as the data-entry reports. They're in their own
manifest so they never gate the core deploy:

```bash
sf project deploy start --manifest manifest/package-4-list-views.xml --target-org <alias> --dry-run
```

All six validate against a licensed Net Zero Cloud org. They're kept separate anyway, because
they only earn their place once those two settings are on, and because list view column
names are their own name space (see [`docs/building-your-own.md`](docs/building-your-own.md)).

---

<a name="orgs-that-work"></a>
## Orgs that work

Expand Down Expand Up @@ -176,7 +221,7 @@ fuel, consumption and distance all live on `VehicleAssetEnrgyUse`.
## Continuous integration

`.github/workflows/validate.yml` checks XML well-formedness, that `sf project convert
source` succeeds, and that manifest members have matching files.
source` succeeds, and that every member in every `manifest/*.xml` has a matching source file.

**None of that can see a wrong object or field name.** A reference to an object that does
not exist produces well-formed XML that converts cleanly and matches the manifest. This
Expand Down
73 changes: 73 additions & 0 deletions docs/building-your-own.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,76 @@ report type is not a refinement here but the only way in.
reports, dashboards, documents and email templates all require named folders. This wastes
an afternoon roughly once per career.

## Matrix reports and dashboard filters

The audit dashboard uses two constructs the rest of the package doesn't.

**A matrix report** is a summary report with a second grouping axis. `groupingsDown` is the
row grouping, `groupingsAcross` the column grouping, and each `<columns>` entry with an
`aggregateTypes` becomes a cell value. Grouping on a lookup column (`…$StnryAssetEnvrSrc`)
groups by the parent's name, which is what you want for one-row-per-asset.

**A dashboard filter** is declared once at the top of the dashboard:

```xml
<dashboardFilters>
<dashboardFilterOptions>
<operator>equals</operator>
<values>2025</values>
</dashboardFilterOptions>
<name>Reporting Year</name>
</dashboardFilters>
```

and then each component names *its own* column for that filter, so components on different
report types can share one picker:

```xml
<dashboardFilterColumns>
<column>VehicleAssetCrbnFtprnt$ReportingYear</column>
</dashboardFilterColumns>
```

The options are literal values, so the year list is hard-coded. Editing it in the dashboard
builder is faster than editing the XML.

## Year-over-year change: build it in the UI

The one audit signal deliberately not shipped as metadata is a **% change from the previous
year** column. It's a custom summary formula using `PREVGROUPVAL`, and its metadata form
references columns and groupings by internal keys that can only be confirmed by retrieving a
formula Salesforce itself emitted. Rather than ship a guess that would fail the all-or-nothing
deploy for everyone, add it in the report builder — it takes a minute:

1. Open **Emissions by Reporting Year** → Edit.
2. Columns → **Add Summary Formula**. Name it `Scope 1 YoY change`, format Percent.
3. Formula (grouping level: Reporting Year):

```
IF(ISBLANK(PREVGROUPVAL(StnryAssetCrbnFtprnt.TotScope1EmissionsInTco2e:SUM, StnryAssetCrbnFtprnt.ReportingYear)), 0,
(StnryAssetCrbnFtprnt.TotScope1EmissionsInTco2e:SUM
- PREVGROUPVAL(StnryAssetCrbnFtprnt.TotScope1EmissionsInTco2e:SUM, StnryAssetCrbnFtprnt.ReportingYear))
/ PREVGROUPVAL(StnryAssetCrbnFtprnt.TotScope1EmissionsInTco2e:SUM, StnryAssetCrbnFtprnt.ReportingYear))
```

The builder's insert-field picker will substitute the exact keys your org uses if these
differ.

4. Save, then retrieve the report and commit the `<aggregates>` block Salesforce emitted.

If you do that, please send it back as a pull request — it's the one thing this repo could
not verify without an org.

## List views are a fourth name space

List view `<columns>` entries are neither describe names nor report keys, and the form
depends on the object's vintage. On the classic CRM objects, standard fields take legacy
keys like `ACCOUNT.NAME`, and on custom objects the record name column is `NAME`. On the
Net Zero Cloud objects, **neither applies**: a deploy of `<columns>NAME</columns>` fails with
`Could not resolve list view column: NAME`, and the column that works is the plain field API
name, `Name` — the same form as every other field (`ReportingYear`, `FootprintStage`,
`FuelConsumption`). All six list views in this package deploy against a licensed org on
that pattern. The lesson is the same one as the report type joins: the platform's newer
objects use plain API names in places where its older objects use legacy keys, and only a
deploy tells you which you've got.

Loading
Loading