mattermost-mobile-upstream/app/utils/channel_attributes.ts
Pablo Vélez 4d842026f6
Some checks failed
ci / lint-typecheck (push) Has been cancelled
ci / detox-lint-typecheck (push) Has been cancelled
ci / test (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Mm 70564 channel labels use in channel creation (#10146)
* MM-70516 - Add ability to edit channel attribute values from Channel Info

* Add API methods for patching property fields and setting channel attribute requirements

* Update channel attribute clearing behavior for required and non-required fields

* MM-70564 - use channel attributes on channel creation

* Fix undefined propertyValues and ternary formatting in channel attributes

* Fix spelling of "colour" to "color" in attribute chip tests and related comments

* Add visibility checks for channel attribute labels in tests

* Fix data-integrity, permission, and accessibility issues in channel attributes: scoped pruning, atomic writes, race serialization, required clear/stale-option guards

* Fix handling of null values in apiGetChannelAttributeValue and update test assertions for channel attributes

* Improve channel attribute editor sizing for long labels, keep Save visible, and prevent keyboard overlap on Android

* Address CodeRabbit feedback for nullable values, bottom-sheet state, async validation, and reliable E2E cleanup

* lock-safe field fetch, create-time gate recheck, scoped value pruning, and tappable header chips

* Address CodeRabbit feedback: drop queued attribute saves on channel switch and use page object selectors in E2E

* Respect the ChannelAttributesRequired flag on channel creation and enable it for channel attribute E2E

* Skip required channel attribute E2E tests and stop enabling ChannelAttributesRequired on the shared server until  they move to SITE_3

* Fix clearing channel attributes on iOS by sending an empty value instead of null, and stop E2E suites from turning  off the ChannelAttributes flag mid-run

* Refactor getEmptyPropertyValue to accept string type for fieldType parameter

* E2E: give channel attribute test fields a suite-unique suffix

Pool shards may only create optional property fields named <name>_<6 hex>; fixed names were blocked by the pool guard.

* E2E: restore channel attribute cleanup checks; name network error codes

Brings back the PR's afterEach/afterAll error handling that the main merge replaced, and makes a no-response error show its codes (an AggregateError has an empty message).

* E2E: run channel attribute creation and editing specs on server B

Their 11 required-attribute tests could not run on the shared servers. Both specs now run on the mutation server with ChannelAttributesRequired on for their run, and the tests are re-enabled. setChannelAttributesRequired moves to the shared helper.

* Check header chips instead of their wrapper and use toExist for the create screen in channel attribute E2E

---------

Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
2026-10-30 15:03:55 +01:00

1095 lines
44 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
DISPLAY_BANNER_BOTTOM,
DISPLAY_BANNER_TOP,
OPTION_BACKED_FIELD_TYPES,
SIGNATURE_SEPARATOR,
} from '@constants/channel_attributes';
import {CLASSIFICATIONS_FIELD_NAME} from '@constants/classification';
import type {PropertyFieldModel, PropertyValueModel} from '@database/models/server';
// Minimal structural shapes that both the WatermelonDB models and plain API
// payloads satisfy, so the same pure helpers serve the observers and the tests.
// Selection by group, object_type and delete_at happens in the scoped queries in
// @queries/servers/properties; these helpers only project what was selected.
// `type` and `permissionValues` are here for the editor: the first decides
// whether an editor exists for the field at all, the second is the per-field
// permission tier layered above the channel-level one.
export type ChannelAttributeField = Pick<PropertyFieldModel, 'id' | 'name' | 'type' | 'attrs' | 'permissionValues'>;
export type ChannelAttributeValue = Pick<PropertyValueModel, 'fieldId' | 'value'>;
export type ResolvedChannelAttribute = {
field: ChannelAttributeField;
// The channel's raw stored value, absent when the attribute is unset.
rawValue?: unknown;
// Resolved option for select-shaped fields. Absent for text fields, and for
// a stored option id that no longer exists on the field.
option?: PropertyFieldOption;
// Display string, empty when the attribute is unset. For a multi-valued
// attribute this is every value joined, kept for surfaces that render plain
// text (the banner).
displayValue: string;
// One entry per underlying value, in order, each with its own resolved
// color when the field is options-bearing. A single-valued attribute has
// one entry; an unset attribute has none. Chip-rendering surfaces should
// render one chip per entry instead of collapsing to displayValue, so a
// multiselect or graph attribute shows each value as its own chip.
displayValues: Array<{value: string; color?: string}>;
// Option ids that no longer resolve on an options-bearing field.
unresolvedOptionIds?: string[];
};
// Why a row cannot be edited. Shown to the user rather than hidden: a row that
// silently does nothing when tapped reads as broken, and hiding it makes a
// correctly marked channel look like one missing a marking.
export type AttributeLockReason = 'never' | 'raise_only' | 'lower_only' | 'permission' | 'unsupported_type';
export type AttributeEditability =
| {editable: true}
| {editable: false; reason: AttributeLockReason};
// What a field's permission_values tier requires beyond the channel-level gate.
export type AttributeTierGate = 'never' | 'channel_only' | 'manage_channel_roles' | 'manage_system';
// The channel-wide permission answers every row is judged against.
export type ChannelAttributePermissions = {
canManageChannelProperties: boolean;
canManageChannelRoles: boolean;
canManageSystem: boolean;
};
export type ChannelAttributeBannerState = {
hasBanner: boolean;
banner: ChannelBannerInfo | undefined;
// The channel's own banner_info text with its template resolved, for when
// no designated attribute produces a banner and the native one shows.
nativeText?: string;
};
const NO_BANNER: ChannelAttributeBannerState = {hasBanner: false, banner: undefined};
const EMPTY_RESOLVED: ResolvedChannelAttribute[] = [];
const EMPTY_OPTIONS: PropertyFieldOption[] = [];
const EDITABLE: AttributeEditability = {editable: true};
// The field types with an editor. Mirrors the webapp, which leaves date, user and
// multiuser read-only.
const EDITABLE_FIELD_TYPES = new Set<string>(['text', 'select', 'multiselect', 'rank']);
// Ranks a field for display. Absent sort_order sorts last rather than first, so
// an unconfigured field never jumps ahead of a configured one.
const NO_SORT_ORDER = Number.MAX_SAFE_INTEGER;
const DEFAULT_BANNER_COLOR = '#DDDDDD';
function getFieldOptions(field: ChannelAttributeField): PropertyFieldOption[] {
return field.attrs?.options ?? [];
}
/**
* Whether a stored value counts as set. Null, an empty string, and an empty
* list all count as unset.
*/
export function isPropertyValueSet(raw: unknown): boolean {
if (raw === null || raw === undefined || raw === '') {
return false;
}
return !(Array.isArray(raw) && raw.length === 0);
}
/**
* The value that clears a field: an empty list for list-valued types, an empty
* string otherwise. Never null — on iOS the native bridge drops null-valued keys,
* so {value: null} reaches the server as an item with no value and is rejected.
* The server reads "" and [] as unset just as it does null.
*/
export function getEmptyPropertyValue(fieldType: string): string | string[] {
return fieldType === 'multiselect' || fieldType === 'multiuser' ? [] : '';
}
/**
* Drops draft values a live field-list update has stranded: a field that no
* longer appears at all, or a selected option id an administrator has since
* removed from the field.
*
* Only the channel-creation form needs this. It holds its draft as local
* component state rather than a persisted value, and the field list it draws
* from is a live subscription — unlike Channel Info, where every write goes
* straight to the server and is judged against the live field there instead.
* Without this, a draft option id that stopped existing between the pick and
* the tap on Create would still be sent, for the server to reject with no
* option even reachable in the sheet's own picker to explain why.
*
* Returns the same object when nothing changed, so a caller storing this in
* React state does not re-render on every unrelated field-list emission.
*/
export function pruneStaleAttributeValues(
fields: ChannelAttributeField[],
values: Record<string, string | string[]>,
): Record<string, string | string[]> {
const fieldById = new Map(fields.map((field) => [field.id, field]));
let changed = false;
const next: Record<string, string | string[]> = {};
for (const [fieldId, value] of Object.entries(values)) {
const field = fieldById.get(fieldId);
if (!field || !OPTION_BACKED_FIELD_TYPES.has(field.type)) {
if (field) {
next[fieldId] = value;
} else {
changed = true;
}
continue;
}
const validIds = new Set(getFieldOptions(field).map((option) => option.id));
let kept: string | string[] | undefined;
if (Array.isArray(value)) {
kept = value.filter((id) => validIds.has(id));
} else if (validIds.has(value)) {
kept = value;
}
if (kept === undefined || (Array.isArray(kept) && kept.length === 0)) {
changed = true;
continue;
}
if (Array.isArray(kept) && kept.length !== value.length) {
changed = true;
}
next[fieldId] = kept;
}
return changed ? next : values;
}
/**
* Anything non-boolean predates the server-side validation of this key, so it
* reads as not required rather than being guessed at.
*/
export function isPropertyFieldRequired(field: ChannelAttributeField): boolean {
return field.attrs?.required === true;
}
/**
* display_name is the admin-facing override; name is the CEL-safe slug fallback.
*
* The server does not copy display_name onto a linked channel field, so in
* practice this returns the machine name for channel attributes.
*/
export function getPropertyFieldLabel(field: ChannelAttributeField): string {
const displayName = field.attrs?.display_name;
return displayName || field.name;
}
/**
* change_policy wins when present. An explicit editable=false reads as "never"
* so fields written before the key keep their behaviour. Anything unrecognised
* falls back to the permissive default.
*/
export function getPropertyFieldChangePolicy(field: ChannelAttributeField): PropertyChangePolicy {
const policy = field.attrs?.change_policy;
if (policy === 'never' || policy === 'raise_only' || policy === 'lower_only' || policy === 'any') {
return policy;
}
if (field.attrs?.editable === false) {
return 'never';
}
return 'any';
}
/**
* The server normalises rank fields to a contiguous 1..N, so the positional
* fallback only covers option lists cached before that ran.
*/
export function getOptionRank(field: ChannelAttributeField, optionId: string): number | undefined {
const options = getFieldOptions(field);
const index = options.findIndex((option) => option.id === optionId);
if (index < 0) {
return undefined;
}
const {rank} = options[index];
return typeof rank === 'number' ? rank : index + 1;
}
/**
* Whether the field's change policy permits moving from its current value to the
* given option. Higher rank is higher, matching the server.
*
* An unset value may move anywhere: the policy governs changes, not the first
* write, so a required-and-locked attribute whose creation-time write failed
* stays fillable. A rank that cannot be resolved on either side fails closed —
* an unresolvable comparison on a marking must not read as permitted.
*/
export function canMoveToOption(field: ChannelAttributeField, currentValue: unknown, optionId: string): boolean {
if (!isPropertyValueSet(currentValue)) {
return true;
}
const policy = getPropertyFieldChangePolicy(field);
if (policy === 'any') {
return true;
}
if (policy === 'never') {
return false;
}
const currentId = typeof currentValue === 'string' ? currentValue : undefined;
if (!currentId) {
return false;
}
const currentRank = getOptionRank(field, currentId);
const nextRank = getOptionRank(field, optionId);
if (currentRank === undefined || nextRank === undefined) {
return false;
}
return policy === 'raise_only' ? nextRank > currentRank : nextRank < currentRank;
}
/**
* The options a field's change policy still permits, given its current value.
*
* The editor narrows its list to these rather than rendering the rest disabled:
* under a directional policy the unreachable options are not choices, and showing
* them greyed out only invites the tap.
*/
export function reachableOptions(field: ChannelAttributeField, currentValue: unknown): PropertyFieldOption[] {
const options = getFieldOptions(field);
if (options.length === 0) {
return EMPTY_OPTIONS;
}
const reachable = options.filter((option) => canMoveToOption(field, currentValue, option.id));
return reachable.length === options.length ? options : reachable;
}
/**
* Whether this field's type has an editor at all.
*
* date, user and multiuser attributes are displayed but not editable — there is
* no editor for them on the webapp either, and inventing one on a phone first is
* the wrong order.
*/
export function hasAttributeEditor(field: ChannelAttributeField): boolean {
return EDITABLE_FIELD_TYPES.has(field.type);
}
/**
* What this field's permission_values tier demands, on top of the channel-level
* manage_*_channel_properties gate.
*
* Anything absent or unrecognised reads as `never`, which matches the server: its
* switch has no default branch and a nil tier returns false. The webapp instead
* treats an empty tier as `member`, so it offers an edit affordance the server
* refuses — deliberately not copied here.
*
* A tier is returned rather than a permission name because the permissions have
* to be observed for the channel as a whole: one subscription each, then every
* row selects from the result, instead of a subscription per row.
*/
export function attributeTierGate(field: ChannelAttributeField): AttributeTierGate {
switch (field.permissionValues) {
case 'sysadmin':
return 'manage_system';
case 'admin':
return 'manage_channel_roles';
case 'member':
return 'channel_only';
default:
return 'never';
}
}
/**
* Whether the caller may set this field while creating a channel, mirroring the
* server's canSetChannelAttributeOnCreate (api4/channel.go).
*
* The channel does not exist yet, so there is no channel-level gate to check —
* only the field's own tier, answered from what creation itself guarantees: the
* creator is saved as a channel admin, which satisfies both `member` and `admin`.
* Only `sysadmin` needs anything beyond that. Anything absent or unrecognised
* fails closed, matching the server's switch having no default branch.
*/
export function canSetChannelAttributeOnCreate(field: ChannelAttributeField, canManageSystem: boolean): boolean {
switch (field.permissionValues) {
case 'member':
case 'admin':
return true;
case 'sysadmin':
return canManageSystem;
default:
return false;
}
}
export type ChannelAttributeCreateFlags = {
// FeatureFlagChannelAttributesRequired, on top of channel attributes being enabled.
requiredEnforced: boolean;
// FeatureFlagClassificationMarkings.
classificationAvailable: boolean;
};
// Matched by name, as in deriveChannelAttributeBanner and for the same reason:
// mobile does not persist linked_field_id, and the server allows only one live
// channel field per name in the group.
function isClassificationField(field: ChannelAttributeField): boolean {
return field.name === CLASSIFICATIONS_FIELD_NAME;
}
// Mirrors the server's channelAttributeFieldAvailable: it refuses a value for
// the classification field while its feature is off, so offering it would only
// lead to a 400.
function isFieldAvailableOnCreate(field: ChannelAttributeField, classificationAvailable: boolean): boolean {
return classificationAvailable || !isClassificationField(field);
}
function isOfferableOnCreate(field: ChannelAttributeField, canManageSystem: boolean): boolean {
return hasAttributeEditor(field) && canSetChannelAttributeOnCreate(field, canManageSystem);
}
// A plain copy, never the WatermelonDB model with its attrs changed in place.
function asOptionalField(field: ChannelAttributeField): ChannelAttributeField {
return {
id: field.id,
name: field.name,
type: field.type,
permissionValues: field.permissionValues,
attrs: {...field.attrs, required: false},
};
}
/**
* The fields the channel-creation form should offer, in display order.
*
* With required attributes enforced: the required ones with a mobile editor that
* the caller may set on create. With the kill switch engaged the server demands
* nothing, so only classification is offered, as an optional copy, matching the
* webapp's courtesy exception. Every other field stays hidden, and a stored
* required=true no longer forces a value.
*/
export function getCreatableChannelAttributeFields(
fields: ChannelAttributeField[],
canManageSystem: boolean,
{requiredEnforced, classificationAvailable}: ChannelAttributeCreateFlags,
): ChannelAttributeField[] {
const available = fields.filter((field) => isFieldAvailableOnCreate(field, classificationAvailable) && isOfferableOnCreate(field, canManageSystem));
if (!requiredEnforced) {
return available.filter(isClassificationField).map(asOptionalField);
}
return available.
filter(isPropertyFieldRequired).
sort(compareChannelAttributeFields);
}
/**
* Whether some field the server will require from this caller (required, and the caller's
* tier passes canSetChannelAttributeOnCreate) but that has no mobile editor at
* all. The create form drops it for the same reason a sysadmin-only field is
* dropped — there is nothing to render — but unlike the sysadmin case the server
* does *not* also skip the requirement here, since the tier check passes. Left
* undetected, the create screen would let Create be tapped with no way to have
* supplied a value the server then rejects.
*
* Never true with the kill switch engaged, and never for a field whose feature is
* off: the server requires neither.
*/
export function hasCreationBlockingChannelAttribute(
fields: ChannelAttributeField[],
canManageSystem: boolean,
{requiredEnforced, classificationAvailable}: ChannelAttributeCreateFlags,
): boolean {
if (!requiredEnforced) {
return false;
}
return fields.some((field) => isPropertyFieldRequired(field) &&
isFieldAvailableOnCreate(field, classificationAvailable) &&
!hasAttributeEditor(field) &&
canSetChannelAttributeOnCreate(field, canManageSystem));
}
export function channelAttributePermissionsEqual(a: ChannelAttributePermissions, b: ChannelAttributePermissions): boolean {
return a.canManageChannelProperties === b.canManageChannelProperties &&
a.canManageChannelRoles === b.canManageChannelRoles &&
a.canManageSystem === b.canManageSystem;
}
/**
* Both permission gates for one field: the channel-level one, already resolved by
* the caller, and this field's tier.
*/
export function canEditAttributeField(field: ChannelAttributeField, permissions: ChannelAttributePermissions): boolean {
if (!permissions.canManageChannelProperties) {
return false;
}
switch (attributeTierGate(field)) {
case 'channel_only':
return true;
case 'manage_channel_roles':
return permissions.canManageChannelRoles;
case 'manage_system':
return permissions.canManageSystem;
default:
return false;
}
}
/**
* Whether a row may be edited, and when it may not, which of the reasons to show.
*
* `hasPermission` is both permission gates already resolved: the channel-level
* one and this field's tier.
*
* Two details are easy to get wrong and both are load-bearing:
*
* - The check reads the stored value, not the display string. A stored option id
* that no longer resolves still counts as set, and — being unresolvable — is
* correctly refused by canMoveToOption.
* - A locked policy only locks a field that already has a value. The server
* exempts the first write, so a required attribute whose creation-time write
* failed is not stranded as "Not set" forever.
*/
export function getAttributeEditability(
field: ChannelAttributeField,
currentValue: unknown,
hasPermission: boolean,
): AttributeEditability {
if (!hasAttributeEditor(field)) {
return {editable: false, reason: 'unsupported_type'};
}
// The policy is checked before the permission, so a locked field reports why it
// is locked even to someone who could not have edited it anyway. That reason
// describes the channel rather than the viewer, and it is the more useful of
// the two: "cannot be changed after it is set" explains the row, where "you do
// not have permission" only explains the reader.
if (isPropertyValueSet(currentValue)) {
const policy = getPropertyFieldChangePolicy(field);
if (policy === 'never') {
return {editable: false, reason: 'never'};
}
// A directional policy compares option ranks, so a field with nothing left
// to compare can never satisfy it. The server reaches the same conclusion
// from the other side: an unresolvable rank is refused. That also covers a
// text field, which only ends up here through a type change.
if (policy !== 'any' && reachableOptions(field, currentValue).length === 0) {
return {editable: false, reason: policy};
}
}
if (!hasPermission) {
return {editable: false, reason: 'permission'};
}
return EDITABLE;
}
/**
* Orders fields for display: attrs.sort_order ascending, ties broken by name.
*
* The locale is pinned to 'en' deliberately. The default is the viewer's, which
* would order equal-ranked chips differently per user, and chip order is
* something people are told to read. Names are ASCII slugs, so this is total.
*
* Nothing writes sort_order today — the System Console has no control for it —
* so in practice the name tie-break decides every comparison.
*/
export function compareChannelAttributeFields(a: ChannelAttributeField, b: ChannelAttributeField): number {
const rankA = typeof a.attrs?.sort_order === 'number' ? a.attrs.sort_order : NO_SORT_ORDER;
const rankB = typeof b.attrs?.sort_order === 'number' ? b.attrs.sort_order : NO_SORT_ORDER;
if (rankA !== rankB) {
return rankA - rankB;
}
return a.name.localeCompare(b.name, 'en');
}
function resolveDisplayValue(field: ChannelAttributeField, raw: unknown): Pick<ResolvedChannelAttribute, 'option' | 'displayValue' | 'displayValues' | 'unresolvedOptionIds'> {
if (!isPropertyValueSet(raw)) {
return {displayValue: '', displayValues: []};
}
const options = getFieldOptions(field);
const optionsBearing = OPTION_BACKED_FIELD_TYPES.has(field.type);
if (Array.isArray(raw)) {
const unresolvedOptionIds: string[] = [];
const displayValues = raw.map((id) => {
const option = options.find((candidate) => candidate.id === id);
if (option === undefined && optionsBearing) {
unresolvedOptionIds.push(String(id));
}
return {value: option?.name ?? String(id), color: option?.color};
});
return {
displayValue: displayValues.map((entry) => entry.value).join(', '),
displayValues,
unresolvedOptionIds: unresolvedOptionIds.length > 0 ? unresolvedOptionIds : undefined,
};
}
if (typeof raw !== 'string') {
const value = String(raw);
return {displayValue: value, displayValues: [{value}]};
}
const option = options.find((candidate) => candidate.id === raw);
if (option) {
return {option, displayValue: option.name, displayValues: [{value: option.name, color: option.color}]};
}
// Text fields store the display string directly. A select field whose option
// was deleted lands here too and renders the raw id, which is wrong but
// visible — better than silently dropping a marking.
return {
displayValue: raw,
displayValues: [{value: raw}],
unresolvedOptionIds: optionsBearing ? [raw] : undefined,
};
}
/**
* Every channel attribute paired with this channel's value, in display order.
*
* Fields with no value are included with an empty displayValue, so each surface
* decides whether to render them: the header omits them, Channel Info keeps the
* required ones so an incomplete channel is visible.
*/
export function resolveChannelAttributes(
fields: ChannelAttributeField[],
values: ChannelAttributeValue[],
): ResolvedChannelAttribute[] {
if (fields.length === 0) {
return EMPTY_RESOLVED;
}
const valueByFieldId = new Map<string, unknown>();
for (const value of values) {
valueByFieldId.set(value.fieldId, value.value);
}
return [...fields].sort(compareChannelAttributeFields).map((field) => {
const rawValue = valueByFieldId.get(field.id);
return {field, rawValue, ...resolveDisplayValue(field, rawValue)};
});
}
/**
* The attributes designated for one display action, with a value to show.
*
* An attribute designated for display but unset is omitted: a chip with nothing
* in it says nothing.
*/
export function selectAttributesForAction(
attributes: ResolvedChannelAttribute[],
action: PropertyFieldAction,
): ResolvedChannelAttribute[] {
const selected = attributes.filter((attribute) => {
if (!attribute.displayValue) {
return false;
}
return hasAction(attribute.field, action);
});
return selected.length === 0 ? EMPTY_RESOLVED : selected;
}
/**
* The attributes listed in Channel Info, filtered by role not by display configuration.
*
* Display configuration (attrs.actions / display_label_info) controls only the
* chip/banner surfaces. Channel Info is the editing surface, so it must show every
* attribute the viewer can act on — hiding a value because an admin did not tick
* "show in info panel" would leave a channel admin with no way to correct it.
*
* Required-and-unset rows are shown per field, not per a single "is this user a
* channel admin" boolean: a custom role can hold manage_public/private_channel_
* properties and edit a member-tier field without holding manage_channel_roles,
* and that user must still see the only row that lets them complete it. Each
* field's own effective editability (canEditAttributeField) decides, not the
* channel-role permission alone.
*
* Any attribute with a stored value is listed regardless of who can edit it —
* read-only for a viewer who cannot act on it.
*
* Optional unset attributes are reached through Add Attribute (a later story).
*
* "Has a stored value" is tested against rawValue (server semantics: null / '' / []
* all count as unset) rather than displayValue, so a stored id that no longer
* resolves to an option is still counted as set and the row is the only way to reach
* it.
*/
export function selectChannelInfoAttributes(
attributes: ResolvedChannelAttribute[],
permissions: ChannelAttributePermissions,
): ResolvedChannelAttribute[] {
const listed = attributes.filter((attribute) => {
if (isPropertyValueSet(attribute.rawValue)) {
return true;
}
return isPropertyFieldRequired(attribute.field) && canEditAttributeField(attribute.field, permissions);
});
return listed.length === 0 ? EMPTY_RESOLVED : listed;
}
export type ChannelAttributeChipItem = {
key: string;
fieldId: string;
label: string;
value: string;
color?: string;
testID: string;
};
/**
* Flattens every attribute's values into individual chip items, one per value,
* so a caller can cap overflow by chip count rather than attribute count. An
* attribute with a single value keeps the bare `{testIDPrefix}.{field.name}`
* testID it has always used; an attribute with several values gets an index
* suffix per chip so each one stays addressable.
*/
export function flattenChannelAttributesToChips(attributes: ResolvedChannelAttribute[], testIDPrefix: string): ChannelAttributeChipItem[] {
return attributes.flatMap((attribute) => {
const label = getPropertyFieldLabel(attribute.field);
const multiValue = attribute.displayValues.length > 1;
return attribute.displayValues.map((entry, index) => ({
key: `${attribute.field.id}-${index}`,
fieldId: attribute.field.id,
label,
value: entry.value,
color: entry.color,
testID: multiValue ? `${testIDPrefix}.${attribute.field.name}.${index}` : `${testIDPrefix}.${attribute.field.name}`,
}));
});
}
export type ChannelAttributeChipGroup = {
fieldId: string;
label: string;
items: ChannelAttributeChipItem[];
};
/**
* Groups chip items back by field, in order, so a caller can render one row
* per field with every value of that field as its own chip.
*/
export function groupChannelAttributeChipsByField(items: ChannelAttributeChipItem[]): ChannelAttributeChipGroup[] {
const groups: ChannelAttributeChipGroup[] = [];
const byFieldId = new Map<string, ChannelAttributeChipGroup>();
for (const item of items) {
let group = byFieldId.get(item.fieldId);
if (!group) {
group = {fieldId: item.fieldId, label: item.label, items: []};
byFieldId.set(item.fieldId, group);
groups.push(group);
}
group.items.push(item);
}
return groups;
}
/**
* Everything the downstream surfaces read, as one string per attribute.
*
* The configuration keys have to be in here, not just the rendered value: which
* surface an attribute appears on is decided *after* this comparator runs, by
* selectAttributesForAction and selectChannelInfoAttributes reading attrs.actions
* and attrs.required. Lives with the helpers it mirrors, since it touches no
* database; the observers in @queries/servers/properties only apply it. Comparing
* only the value meant an administrator unticking "show in header" produced an
* emission this treated as identical, so the chip stayed on screen until the app
* restarted.
*/
function renderSignature(attribute: ResolvedChannelAttribute): string {
const {attrs} = attribute.field;
const actions = Array.isArray(attrs?.actions) ? attrs.actions.join(',') : '';
return [
attribute.field.id,
attribute.field.name,
attribute.field.type,
attribute.displayValue,
// The raw stored value, not just the rendered display string: two options
// that happen to share a name and colour render identical display values,
// and without this, picking one over the other produced an emission this
// treated as unchanged.
JSON.stringify(attribute.rawValue ?? null),
attribute.option?.color ?? '',
// Multi-valued attributes carry per-value colors, never an option, so
// an option color change only shows up here.
attribute.displayValues.map((entry) => entry.color ?? '').join(','),
attribute.unresolvedOptionIds?.join(',') ?? '',
actions,
attrs?.required === true ? '1' : '0',
attrs?.display_name ?? '',
typeof attrs?.sort_order === 'number' ? String(attrs.sort_order) : '',
...editorSignatureKeys(attribute.field),
].join('|');
}
/**
* The keys the editor gates on, plus the option list it offers.
*
* Without the first three, narrowing a policy or revoking a tier produced an
* emission treated as identical. The option list is in here as a digest rather
* than by identity because it is what the editor offers: an administrator
* renaming, recolouring or removing an option has to reach an open row, and
* comparing only the rendered value meant the row kept offering an option the
* server had already stopped accepting. Joined with SIGNATURE_SEPARATOR rather
* than ':'/',' because option.name is admin-authored free text that can legally
* contain either, which would let two different option sets collide.
*/
function editorSignatureKeys(field: ChannelAttributeField): string[] {
const {attrs} = field;
return [
attrs?.change_policy ?? '',
attrs?.editable === false ? '0' : '1',
typeof field.permissionValues === 'string' ? field.permissionValues : '',
optionsSignature(field),
];
}
function optionsSignature(field: ChannelAttributeField): string {
const {attrs} = field;
return Array.isArray(attrs?.options) ? attrs.options.map((option) => [option.id, option.rank ?? '', option.color ?? '', option.name].join(SIGNATURE_SEPARATOR)).join(SIGNATURE_SEPARATOR) : '';
}
/**
* One string standing for everything the chip, banner and Channel Info surfaces
* read from a resolved attribute list.
*
* Callers must take this at emission time rather than comparing two lists inside
* distinctUntilChanged. WatermelonDB updates a record in place and re-emits the
* same model instance, so by the time a comparator ran, the *previous* emission's
* field already reported the new attrs and every comparison of a configuration
* change came out equal.
*/
export function resolvedAttributesSignature(attributes: ResolvedChannelAttribute[]): string {
return attributes.map(renderSignature).join(SIGNATURE_SEPARATOR);
}
/**
* What Channel Info renders and edits for one attribute. Narrower than
* renderSignature on purpose: Channel Info lists attributes whatever their
* display actions, so an administrator toggling "show in header" must not
* re-render it. It still carries the stored value and every key the editor and
* its submit-time revalidation read, since Channel Info is the editing surface.
*/
function channelInfoSignature(attribute: ResolvedChannelAttribute): string {
return [
attribute.field.id,
attribute.field.name,
attribute.field.type,
getPropertyFieldLabel(attribute.field),
JSON.stringify(attribute.rawValue ?? null),
attribute.displayValues.map((entry) => [entry.value, entry.color ?? ''].join(SIGNATURE_SEPARATOR)).join(SIGNATURE_SEPARATOR),
attribute.unresolvedOptionIds?.join(',') ?? '',
isPropertyFieldRequired(attribute.field) ? '1' : '0',
...editorSignatureKeys(attribute.field),
].join('|');
}
/**
* The Channel Info counterpart of resolvedAttributesSignature, under the same
* rule: take it at emission time, never compare two lists after the fact.
*/
export function channelInfoAttributesSignature(attributes: ResolvedChannelAttribute[]): string {
return attributes.map(channelInfoSignature).join(SIGNATURE_SEPARATOR);
}
// What the create form renders for one field, plus what decides whether the
// field is in the list at all (required flag, permission tier). Taken at
// emission time for the same reason as resolvedAttributesSignature: a field
// changed in place re-emits the same model instance.
function creatableFieldSignature(field: ChannelAttributeField): string {
return [
field.id,
// The fallback label (getPropertyFieldLabel falls back to name when
// display_name is absent), the sort tie-break, and every row testID are
// all keyed off name, not just display_name.
field.name,
field.type,
field.permissionValues ?? '',
isPropertyFieldRequired(field) ? '1' : '0',
field.attrs?.display_name ?? '',
typeof field.attrs?.sort_order === 'number' ? String(field.attrs.sort_order) : '',
optionsSignature(field),
].join(SIGNATURE_SEPARATOR);
}
/**
* The create-form counterpart of resolvedAttributesSignature, under the same
* rule: take it at emission time, never compare two lists after the fact.
*/
export function creatableFieldsSignature(fields: ChannelAttributeField[]): string {
return fields.map(creatableFieldSignature).join('|');
}
function hasAction(field: ChannelAttributeField, action: PropertyFieldAction): boolean {
const {actions} = field.attrs ?? {};
return Array.isArray(actions) && actions.includes(action);
}
function hasBannerAction(field: ChannelAttributeField): boolean {
return hasAction(field, DISPLAY_BANNER_TOP) || hasAction(field, DISPLAY_BANNER_BOTTOM);
}
/**
* Whether a field predates any display configuration.
*
* The distinction is load-bearing. The System Console writes attrs.actions on
* every save, empty array included, because the server merges attrs and an
* omitted key keeps its previous value. So `[]` means an administrator looked at
* this attribute and ticked nothing — which has to mean no banner — while an
* absent key means nobody has configured it yet. Testing `actions.length` rather
* than `Array.isArray` would resurrect the banner every time Banner is unticked.
*/
function hasNoDisplayConfiguration(field: ChannelAttributeField): boolean {
return !Array.isArray(field.attrs?.actions);
}
/**
* Resolves one channel banner from every attribute designated for that surface,
* following the same rules as the webapp.
*
* Falls back to the classification field while that field carries no display
* configuration at all, which is how an install that upgraded before an
* administrator configured anything keeps the banner it has today. The fallback
* switches off as soon as the field carries any actions, so an unticked Banner
* means no banner.
*
* On the designated path the channel's own banner settings are honored: once a
* channel has authored banner text, switching its banner off hides it whatever
* attributes are designated, unless one of them is required. The authored text
* wins over the designated default even when every attribute it references is
* unset. Classification's color is enforced only while classification actually
* appears on the banner; once the channel removes its token, the color is the
* channel's.
*
* The classification field is matched by name because mobile does not persist
* linked_field_id. That is sufficient rather than lax: the template field is
* object_type='template' and the global field is 'system', so within the
* channel-object fields the name is already unambiguous.
*
* @param fields channel-object fields in the access_control group, any order
* @param values every property value on this channel
* @param [bannerInfo] the channel's own banner_info: template, color and toggle
* @param [attributesEnabled] whether the channel attributes feature is on
*/
export function deriveChannelAttributeBanner(
fields: ChannelAttributeField[],
values: ChannelAttributeValue[],
bannerInfo?: ChannelBannerInfo,
attributesEnabled = false,
): ChannelAttributeBannerState {
const ordered = [...fields].sort(compareChannelAttributeFields);
// With channel attributes off, only classification may produce a banner — it
// ships on its own flag and predates this feature. Any other attribute
// designating a banner is ignored until the feature it belongs to is enabled,
// so a configured-but-disabled attribute cannot take over the channel banner.
const candidates = attributesEnabled ? ordered : ordered.filter((field) => field.name === CLASSIFICATIONS_FIELD_NAME);
const designatedFields = candidates.filter(hasBannerAction);
const fallback = designatedFields.length > 0 ? undefined : candidates.find(
(field) => field.name === CLASSIFICATIONS_FIELD_NAME && hasNoDisplayConfiguration(field),
);
if (designatedFields.length === 0 && !fallback) {
return NO_BANNER;
}
const resolvedAttributes = resolveChannelAttributes(ordered, values);
const templateAttributes = withoutUnresolvedOptions(resolvedAttributes);
const authoredText = bannerInfo?.text;
if (fallback) {
const resolved = resolvedAttributes.find((attribute) => attribute.field.id === fallback.id);
if (!resolved?.option?.name || resolved.unresolvedOptionIds?.length) {
return NO_BANNER;
}
const text = authoredText ?
renderBannerTemplate(authoredText, templateAttributes) :
`**${resolved.option.name}**`;
if (!text) {
return NO_BANNER;
}
return {
hasBanner: true,
banner: {
enabled: true,
text,
background_color: resolved.option.color || DEFAULT_BANNER_COLOR,
},
};
}
// A channel that never authored a banner carries enabled=false and still shows
// the designated default, so the toggle only counts once text exists.
const bannerRequired = designatedFields.some(isPropertyFieldRequired);
if (authoredText !== undefined && !bannerInfo?.enabled && !bannerRequired) {
return NO_BANNER;
}
const contributions = designatedFields.
map((field) => resolvedAttributes.find((attribute) => attribute.field.id === field.id)).
filter((attribute): attribute is ResolvedChannelAttribute => Boolean(
attribute?.displayValue && !attribute.unresolvedOptionIds?.length,
));
let text: string;
if (authoredText !== undefined) {
text = authoredText ? renderBannerTemplate(authoredText, templateAttributes) : '';
} else if (contributions.length === 0) {
return NO_BANNER;
} else {
text = contributions.map((attribute) => attribute.displayValue).join(' · ');
}
if (!text.trim()) {
return NO_BANNER;
}
const classificationContribution = contributions.find((attribute) => attribute.field.name === CLASSIFICATIONS_FIELD_NAME);
const classificationColor = classificationContribution?.option?.color;
const classificationInBanner = Boolean(classificationColor) &&
(authoredText === undefined || hasAttributeToken(authoredText, CLASSIFICATIONS_FIELD_NAME));
let backgroundColor: string;
if (classificationInBanner && classificationColor) {
backgroundColor = classificationColor;
} else {
const colorContributions = contributions.filter((attribute) => attribute !== classificationContribution);
backgroundColor = bannerInfo?.background_color ||
(colorContributions.length === 1 ? colorContributions[0].option?.color : undefined) ||
DEFAULT_BANNER_COLOR;
}
return {
hasBanner: true,
banner: {
enabled: true,
text,
background_color: backgroundColor,
},
};
}
// A deleted option would otherwise render its raw id in the banner.
function withoutUnresolvedOptions(attributes: ResolvedChannelAttribute[]): ResolvedChannelAttribute[] {
if (!attributes.some((attribute) => attribute.unresolvedOptionIds?.length)) {
return attributes;
}
return attributes.map((attribute) => (attribute.unresolvedOptionIds?.length ? {...attribute, displayValue: '', displayValues: []} : attribute));
}
// Separators the banner composer offers. Excludes '-' and '/': a banner authored
// as "- {{classification}}" is a markdown list, and stripping its marker would
// rewrite what the author wrote.
const SEPARATORS = '·|';
const TOKEN_PATTERN = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
/**
* The channel's own banner_info text with its template resolved against this
* channel's attribute values, matching the webapp.
*
* The native banner shows when no designated attribute has a value, and its
* text is the same template the attribute banner renders, so without this an
* unset attribute leaves its raw "{{name}}" token on screen. With channel
* attributes off the text passes through untouched, as on the webapp.
*
* @param fields channel-object fields in the access_control group, any order
* @param values every property value on this channel
* @param [nativeBannerText] the channel's own banner_info.text template
* @param [attributesEnabled] whether the channel attributes feature is on
*/
export function renderNativeBannerText(
fields: ChannelAttributeField[],
values: ChannelAttributeValue[],
nativeBannerText?: string,
attributesEnabled = false,
): string | undefined {
if (!attributesEnabled || !nativeBannerText?.includes('{{')) {
return nativeBannerText;
}
return renderBannerTemplate(nativeBannerText, resolveChannelAttributes(fields, values));
}
export function hasAttributeToken(text: string, fieldName: string): boolean {
for (const match of text.matchAll(TOKEN_PATTERN)) {
if (match[1] === fieldName) {
return true;
}
}
return false;
}
export function renderBannerTemplate(template: string, attributes: ResolvedChannelAttribute[]): string {
if (!template || !template.includes('{{')) {
return template;
}
const byName = new Map(attributes.map((attribute) => [attribute.field.name, attribute]));
const substituted = template.replace(TOKEN_PATTERN, (_full, name: string) => {
return byName.get(name)?.displayValue ?? '';
});
return tidySeparators(substituted);
}
/**
* Removes unresolved `{{token}}` spans and tidies the punctuation they strand.
*
* This is a degradation guard, not a template renderer. banner_info.text arrives
* already resolved from the server; a token surviving to the client means either
* an older server or a re-resolve that has not run yet. Braces on screen are the
* one outcome that cannot be defended, whereas an over-tidied banner is
* recoverable, so the tokens go and the separators are cleaned up after them.
*/
export function stripUnresolvedTokens(text: string): string {
if (!text.includes('{{')) {
return text;
}
const stripped = text.replace(TOKEN_PATTERN, '');
return tidySeparators(stripped);
}
function tidySeparators(text: string): string {
const run = new RegExp(`(?:\\s*[${SEPARATORS}]\\s*){2,}`, 'g');
const leading = new RegExp(`^[\\s${SEPARATORS}]+`);
const trailing = new RegExp(`[\\s${SEPARATORS}]+$`);
return text.
replace(run, (match) => ` ${match.trim().charAt(0)} `).
replace(leading, '').
replace(trailing, '').
replace(/\s{2,}/g, ' ');
}