mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-10 13:10:14 +00:00
* 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>
186 lines
8.9 KiB
TypeScript
186 lines
8.9 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {channelAttributeWriteLockKey} from '@actions/remote/channel_attributes';
|
|
import {accessControlWriteLockKey, fetchAccessControlAttributeFields, fetchChannelAttributeValues} from '@actions/remote/classification';
|
|
import {CHANNEL_ATTRIBUTE_OBJECT_TYPE, OWNED_OBJECT_TYPES} from '@constants/channel_attributes';
|
|
import DatabaseManager from '@database/manager';
|
|
import {getAccessControlGroupId, getPropertyValuesByFieldId} from '@queries/servers/properties';
|
|
import EphemeralStore from '@store/ephemeral_store';
|
|
import {getFullErrorMessage} from '@utils/errors';
|
|
import {safeParseJSON} from '@utils/helpers';
|
|
import {logDebug, logError} from '@utils/log';
|
|
|
|
import type {PropertyValueModel} from '@database/models/server';
|
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
|
|
|
export async function handlePropertyFieldCreatedOrUpdated(serverUrl: string, msg: WebSocketMessage) {
|
|
const data = msg.data as {property_field?: string; object_type?: string};
|
|
if (!data.property_field) {
|
|
logDebug('handlePropertyFieldCreatedOrUpdated', 'No property_field in WS event');
|
|
return;
|
|
}
|
|
|
|
const field = safeParseJSON(data.property_field) as PropertyField | string;
|
|
if (typeof field === 'string') {
|
|
logDebug('handlePropertyFieldCreatedOrUpdated', 'Failed to parse property_field from WS event');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const needsFieldFetch = await EphemeralStore.runExclusive(accessControlWriteLockKey(serverUrl), async () => {
|
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const models = await operator.handlePropertyFields({fields: [field], prepareRecordsOnly: true});
|
|
await operator.batchRecords(models, 'handlePropertyFieldCreatedOrUpdated', true);
|
|
|
|
// The event carries a group_id but no group name, so there is no way to tell
|
|
// from here whether this field is ours. What we can tell is that we do not
|
|
// know our group id yet — which happens when the last fetch found no fields
|
|
// and stamped its one-hour cache. Without this, the first attribute an
|
|
// administrator creates stays invisible for the rest of that hour.
|
|
//
|
|
// Narrowed to the object types this feature owns, and self-limiting: once
|
|
// the fetch publishes the id, this stops firing.
|
|
return OWNED_OBJECT_TYPES.has(field.object_type) && !(await getAccessControlGroupId(database));
|
|
});
|
|
|
|
// Called after the lock is released: the fetch takes the same lock, so
|
|
// awaiting it from inside would wait on itself forever.
|
|
if (needsFieldFetch) {
|
|
await fetchAccessControlAttributeFields(serverUrl, true);
|
|
}
|
|
} catch (error) {
|
|
logError('handlePropertyFieldCreatedOrUpdated', getFullErrorMessage(error));
|
|
}
|
|
}
|
|
|
|
export async function handlePropertyFieldDeleted(serverUrl: string, msg: WebSocketMessage) {
|
|
const data = msg.data as {field_id?: string; object_type?: string};
|
|
if (!data.field_id) {
|
|
logDebug('handlePropertyFieldDeleted', 'No field_id in WS event');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await EphemeralStore.runExclusive(accessControlWriteLockKey(serverUrl), async () => {
|
|
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
const models = await operator.handlePropertyFields({fields: [{id: data.field_id, delete_at: Date.now()} as PropertyField], prepareRecordsOnly: true});
|
|
await operator.batchRecords(models, 'handlePropertyFieldDeleted', true);
|
|
});
|
|
} catch (error) {
|
|
logError('handlePropertyFieldDeleted', getFullErrorMessage(error));
|
|
}
|
|
}
|
|
|
|
// Stamping delete_at is how the operator is told to remove a row; it has no
|
|
// delete-by-query path for values. Prepared and committed as one propagated
|
|
// batch so a failed write throws rather than silently leaving stale rows.
|
|
async function destroyValues(serverUrl: string, operator: ServerDataOperator, stale: PropertyValueModel[], description: string) {
|
|
if (!stale.length) {
|
|
return;
|
|
}
|
|
|
|
const lockKeys = [...new Set(stale.map((value) => {
|
|
if (value.targetType === 'channel') {
|
|
return channelAttributeWriteLockKey(serverUrl, value.targetId);
|
|
}
|
|
if (value.targetType === 'system') {
|
|
return accessControlWriteLockKey(serverUrl);
|
|
}
|
|
return '';
|
|
}).filter(Boolean))].sort();
|
|
|
|
const commit = async () => {
|
|
const models = await operator.handlePropertyValues({
|
|
values: stale.map((v) => ({id: v.id, delete_at: Date.now()} as PropertyValue)),
|
|
prepareRecordsOnly: true,
|
|
});
|
|
await operator.batchRecords(models, description, true);
|
|
};
|
|
const withLock = (index: number): Promise<void> => {
|
|
const key = lockKeys[index];
|
|
return key ? EphemeralStore.runExclusive(key, () => withLock(index + 1)) : commit();
|
|
};
|
|
await withLock(0);
|
|
}
|
|
|
|
export async function handlePropertyValuesUpdated(serverUrl: string, msg: WebSocketMessage) {
|
|
const data = msg.data as PropertyValuesUpdatedData;
|
|
if (!data.values) {
|
|
return;
|
|
}
|
|
|
|
const values = safeParseJSON(data.values) as Array<PropertyValue<string>> | string;
|
|
if (typeof values === 'string') {
|
|
logDebug('handlePropertyValuesUpdated', 'Failed to parse values from WS event');
|
|
return;
|
|
}
|
|
|
|
// The server sends this event in four shapes, and they need different writes:
|
|
//
|
|
// upsert -> target_id + only the values just written
|
|
// single delete -> target_id + that one value, delete_at set
|
|
// all values on target -> target_id + an empty list
|
|
// all values on a field -> field_id + an empty list
|
|
//
|
|
// So a non-empty list is a merge, never authoritative: passing targetId there
|
|
// would prune every other attribute on the channel whenever one is edited. An
|
|
// empty list is the opposite — it carries no ids, so it can only be acted on
|
|
// as a prune, which is why it must not be treated as a no-op.
|
|
try {
|
|
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
|
|
|
if (values.length) {
|
|
const persistValues = async () => {
|
|
const models = await operator.handlePropertyValues({values, prepareRecordsOnly: true});
|
|
await operator.batchRecords(models, 'handlePropertyValuesUpdated', true);
|
|
};
|
|
const targetId = data.target_id ?? values[0].target_id;
|
|
if (values.some((value) => value.target_type === 'channel')) {
|
|
// Serialized against the REST full-channel fetch and
|
|
// setChannelAttributeValue for the same channel, so this upsert
|
|
// cannot land in the gap between that fetch's read and its write.
|
|
await EphemeralStore.runExclusive(channelAttributeWriteLockKey(serverUrl, targetId), persistValues);
|
|
return;
|
|
}
|
|
|
|
if (values.some((value) => value.target_type === 'system')) {
|
|
await EphemeralStore.runExclusive(accessControlWriteLockKey(serverUrl), persistValues);
|
|
return;
|
|
}
|
|
|
|
await persistValues();
|
|
return;
|
|
}
|
|
|
|
if (data.target_id) {
|
|
// This event carries no group identity, so an "all cleared" broadcast
|
|
// from another property group sharing this table (managed channel
|
|
// categories, custom profile attributes) is indistinguishable from one
|
|
// for access_control. Deleting locally here would apply someone else's
|
|
// clear to our data. Instead, force a group-scoped REST reconciliation:
|
|
// if the clear really was ours, the fetch will observe it and prune;
|
|
// if it belonged to another group, the fetch finds our values
|
|
// unchanged and nothing is lost. Non-channel targets (users, posts,
|
|
// system) can never hold channel attributes, so they are skipped.
|
|
if (data.object_type !== CHANNEL_ATTRIBUTE_OBJECT_TYPE) {
|
|
logDebug('handlePropertyValuesUpdated', 'Ignoring cleared values for non-channel object type', data.object_type);
|
|
return;
|
|
}
|
|
await fetchChannelAttributeValues(serverUrl, data.target_id, true);
|
|
return;
|
|
}
|
|
|
|
if (data.field_id) {
|
|
// Safe to key on the field alone: a value belongs to exactly one field,
|
|
// so this cannot reach another group's rows.
|
|
await destroyValues(serverUrl, operator, await getPropertyValuesByFieldId(database, data.field_id), 'handlePropertyValuesUpdated');
|
|
return;
|
|
}
|
|
|
|
logDebug('handlePropertyValuesUpdated', 'Empty values with no target or field to prune');
|
|
} catch (error) {
|
|
logError('handlePropertyValuesUpdated', getFullErrorMessage(error));
|
|
}
|
|
}
|