Rename isUIReadOnly to isUIEditable, add isUICreatable, expose both to app developers (#21504)

<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# UI capability flags: `isUIEditable` + `isUICreatable`

## Per-verb capability model

This PR replaces the negative `isUIReadOnly` metadata flag with
positive, per-verb capability flags (à la Salesforce
`createable`/`updateable`):

- **`isUIEditable: boolean`, default `true`** — rename of `isUIReadOnly`
with inverted polarity, on **both** `objectMetadata` and
`fieldMetadata`. It is one concept ("can the user edit this through the
generic UI?") at two altitudes, so it carries one name at both levels.
- **`isUICreatable: boolean`, default `true`** — new, **object-level
only** (fields have no create verb). When `false`, no generic UI
affordance to create a record of this object appears anywhere (table "+"
buttons, board column add, calendar add, relation-section "Add new",
record picker "Add new", command-menu create action and its keyboard
shortcut).

Both flags are **UI-affordance flags only**: the server does not block
create/edit mutations based on them, so the system, API, and workflows
continue to mutate these records freely. They are orthogonal statements
about the object's nature with no implication rule in the data model.
Because today's inline creation UX creates a blank record the user must
then edit, the frontend create predicate currently requires both
`isUICreatable` and effective editability.

There is no CREATE permission in `ObjectPermissions`; the frontend keeps
gating creation on `canUpdateObjectRecords` as a proxy, ANDed with the
new flags.

## Unified create predicate

All generic creation entry points now flow through one predicate,
`canCreateRecordsForObjectMetadataItem` (`isUICreatable` && not
`isSystem` && not effectively read-only, where effective read-only
covers `isUIEditable`, `isRemote`, and the `canUpdateObjectRecords`
proxy via `isObjectMetadataReadOnly`). This deletes the previously
hardcoded suppression lists:

- `isRecordTableCreateDisabled.ts` and its hardcoded
`WorkflowRun`/`WorkflowVersion` list — deleted; those objects (plus
`workspaceMember`) now declare `isUICreatable: false` in the standard
application instead.
- The hardcoded `workspaceMember` guard inside
`useAddNewRecordAndOpenSidePanel.ts` — deleted.
- The `CREATE_NEW_RECORD` command menu item's availability expression
now checks `objectMetadataItem.isUICreatable`, `isUIEditable`,
`isSystem`, and `isRemote`; a workspace upgrade command re-syncs the
expression in existing workspaces.

Component-local conditions (soft-delete filter active, layout
customization mode) stay in their components.

## GraphQL compatibility and removal plan

The schema delta versus main is **purely additive plus deprecations —
zero breaking changes**:

- `isUIReadOnly` remains on both the ObjectMetadata and FieldMetadata
GraphQL output types for **one release** as a deprecated field computed
as `!isUIEditable` (`deprecationReason: 'Use isUIEditable'`). The Twenty
frontend no longer queries it.
- `isUIReadOnly` also remains on the **input side** for one release
(`CreateFieldInput`, `UpdateFieldInput`, `FieldFilter`, `ObjectFilter`),
keeping the schema shape identical to main for those members. On create
it acts as a legacy alias mapped to `!isUIReadOnly` (`isUIEditable` wins
when both are provided); on update it is ignored, exactly as on main (it
was never an editable property). Filtering on the deprecated member
keeps working until the column is dropped at upgrade time; after that it
is a deprecated no-op surface kept only for schema compatibility.

**Removal plan for next release: drop `isUIReadOnly` from the output
DTOs (and resolvers' `@ResolveField`s), from the input/filter types,
from the create-input mapping, and the `@WasRemovedInUpgrade`-retained
entity columns and decorators.**

## ⚠️ Webhook / database-event payload shape change

The `database-event-payload` type in `twenty-shared` got a clean rename
(no alias): metadata snapshots in webhook and database-event payloads
now carry `isUIEditable` (and `isUICreatable` at object level) **instead
of** `isUIReadOnly`, with inverted polarity. Consumers of these payloads
that read `isUIReadOnly` must switch to `isUIEditable`.

## New manifest properties (app-developer DX)

Application developers can now set these flags in their app manifests
(purely additive — existing manifests and older `twenty-sdk` versions
are unaffected, defaults apply when omitted):

- `objects[].isUICreatable?: boolean` (default `true`)
- `objects[].isUIEditable?: boolean` (default `true`)
- `fields[].isUIEditable?: boolean` (default `true`)

The manifest converters previously hardcoded `isUIReadOnly: false`; they
now read the manifest values with `?? true` defaults. The types are
re-exported through `twenty-sdk` from `twenty-shared`.

## Migration & backfill

- One fast instance command: adds `isUIEditable` (NOT NULL default
`true`) on `core."objectMetadata"` and `core."fieldMetadata"`, backfills
`isUIEditable = false` exactly where `isUIReadOnly = true`, drops
`isUIReadOnly`, and adds `isUICreatable` (default `true`) on
`objectMetadata`. The `down` is the exact inverse. Uses `ADD/DROP COLUMN
IF (NOT) EXISTS`, matching the 2-12 drop-`isCustom` precedent. Verified
up and down in separate transactions against a dev database with exact
backfill counts.
- **Cross-version upgrade safety (multi-version self-hosted jumps):**
the upgrade sequence interleaves per version (instance → workspace
commands), so pre-2.13 workspace commands run **before** the 2.13 rename
when an old instance jumps several versions. Following the `isCustom`
precedent: `isUIEditable`/`isUICreatable` are marked
`@WasIntroducedInUpgrade` and `isUIReadOnly` stays on both entities as
`@WasRemovedInUpgrade`, so the upgrade-aware entity metadata adapter
hides the not-yet-existing columns (and keeps the legacy column live) at
pre-2.13 cursors. **No committed upgrade command outside the 2-13
directory is modified**: the old 1-21/2-8/2-9 commands keep their
original `isUIReadOnly: true` inputs, which still compile (entity
property retained, deprecated create-input alias mapped) and still
produce the correct legacy column writes pre-rename.
- A 2-13 workspace command (`sync-standard-ui-capability-flags`)
re-syncs `isUICreatable` **and** `isUIEditable` on standard objects and
`isUIEditable` on standard fields from the standard-application
definitions. This backfills `isUICreatable: false` on
`workflowRun`/`workflowVersion`/`workspaceMember` and heals fields
created mid-cross-upgrade by pre-2.13 commands (whose hidden
`isUIEditable` value cannot reach the insert). Both 2-13 sync commands
pass `isSystemBuild: true` — the flat metadata validator otherwise
rejects direct updates to system objects (verified against a
deliberately drifted dev database; the run is idempotent).
- A second 2-13 workspace command re-syncs the create-record command
availability expression.

## Testing

- Unit tests for `canCreateRecordsForObjectMetadataItem`
(flag/permission/system combinations) and for the manifest converters
(flags set / omitted → defaults).
- Full `upgrade --dry-run` boots the sequence (107 steps) and validates
the upgrade-aware decorator references; both 2-13 sync commands verified
end to end against real drift and re-run idempotently.
- Schema verified by live introspection after the input-alias restore:
all four input/filter members match main, output deprecations intact;
frontend metadata types and `twenty-client-sdk` schema regenerated from
the running server.
- Read-only-related and touched jest suites pass on both packages;
typecheck and lint pass on `twenty-server` and `twenty-front`.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a
href="https://cursor.com/agents/bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a
href="https://cursor.com/background-agent?bcId=bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21504?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait
2026-06-13 07:10:22 +02:00
committed by GitHub
parent d910b2f943
commit 1efa3567ef
145 changed files with 2001 additions and 1095 deletions
@@ -480,7 +480,8 @@ type Field {
isCustom: Boolean @deprecated(reason: "isCustom is derived from the owning application and will be removed; a field is custom when it does not belong to the twenty-standard application.")
isActive: Boolean
isSystem: Boolean
isUIReadOnly: Boolean
isUIEditable: Boolean
isUIReadOnly: Boolean @deprecated(reason: "Use isUIEditable")
isNullable: Boolean
isUnique: Boolean
defaultValue: JSON
@@ -591,6 +592,8 @@ input ObjectFilter {
isRemote: BooleanFieldComparison
isActive: BooleanFieldComparison
isSystem: BooleanFieldComparison
isUIEditable: BooleanFieldComparison
isUICreatable: BooleanFieldComparison
isUIReadOnly: BooleanFieldComparison
isSearchable: BooleanFieldComparison
}
@@ -649,7 +652,9 @@ type Object {
isRemote: Boolean!
isActive: Boolean!
isSystem: Boolean!
isUIReadOnly: Boolean!
isUIEditable: Boolean!
isUICreatable: Boolean!
isUIReadOnly: Boolean! @deprecated(reason: "Use isUIEditable")
isSearchable: Boolean!
applicationId: UUID!
createdAt: DateTime!
@@ -682,6 +687,7 @@ input FieldFilter {
id: UUIDFilterComparison
isActive: BooleanFieldComparison
isSystem: BooleanFieldComparison
isUIEditable: BooleanFieldComparison
isUIReadOnly: BooleanFieldComparison
objectMetadataId: UUIDFilterComparison
}
@@ -4151,6 +4157,7 @@ input CreateFieldInput {
isCustom: Boolean
isActive: Boolean
isSystem: Boolean
isUIEditable: Boolean
isUIReadOnly: Boolean
isNullable: Boolean
isUnique: Boolean
@@ -4180,6 +4187,7 @@ input UpdateFieldInput {
icon: String
isActive: Boolean
isSystem: Boolean
isUIEditable: Boolean
isUIReadOnly: Boolean
isNullable: Boolean
isUnique: Boolean
@@ -364,6 +364,8 @@ export interface Field {
isCustom?: Scalars['Boolean']
isActive?: Scalars['Boolean']
isSystem?: Scalars['Boolean']
isUIEditable?: Scalars['Boolean']
/** @deprecated Use isUIEditable */
isUIReadOnly?: Scalars['Boolean']
isNullable?: Scalars['Boolean']
isUnique?: Scalars['Boolean']
@@ -442,6 +444,9 @@ export interface Object {
isRemote: Scalars['Boolean']
isActive: Scalars['Boolean']
isSystem: Scalars['Boolean']
isUIEditable: Scalars['Boolean']
isUICreatable: Scalars['Boolean']
/** @deprecated Use isUIEditable */
isUIReadOnly: Scalars['Boolean']
isSearchable: Scalars['Boolean']
applicationId: Scalars['UUID']
@@ -3268,6 +3273,8 @@ export interface FieldGenqlSelection{
isCustom?: boolean | number
isActive?: boolean | number
isSystem?: boolean | number
isUIEditable?: boolean | number
/** @deprecated Use isUIEditable */
isUIReadOnly?: boolean | number
isNullable?: boolean | number
isUnique?: boolean | number
@@ -3332,7 +3339,7 @@ first?: (Scalars['Int'] | null),
/** Paginate last */
last?: (Scalars['Int'] | null)}
export interface ObjectFilter {and?: (ObjectFilter[] | null),or?: (ObjectFilter[] | null),id?: (UUIDFilterComparison | null),isRemote?: (BooleanFieldComparison | null),isActive?: (BooleanFieldComparison | null),isSystem?: (BooleanFieldComparison | null),isUIReadOnly?: (BooleanFieldComparison | null),isSearchable?: (BooleanFieldComparison | null)}
export interface ObjectFilter {and?: (ObjectFilter[] | null),or?: (ObjectFilter[] | null),id?: (UUIDFilterComparison | null),isRemote?: (BooleanFieldComparison | null),isActive?: (BooleanFieldComparison | null),isSystem?: (BooleanFieldComparison | null),isUIEditable?: (BooleanFieldComparison | null),isUICreatable?: (BooleanFieldComparison | null),isUIReadOnly?: (BooleanFieldComparison | null),isSearchable?: (BooleanFieldComparison | null)}
export interface UUIDFilterComparison {is?: (Scalars['Boolean'] | null),isNot?: (Scalars['Boolean'] | null),eq?: (Scalars['UUID'] | null),neq?: (Scalars['UUID'] | null),gt?: (Scalars['UUID'] | null),gte?: (Scalars['UUID'] | null),lt?: (Scalars['UUID'] | null),lte?: (Scalars['UUID'] | null),like?: (Scalars['UUID'] | null),notLike?: (Scalars['UUID'] | null),iLike?: (Scalars['UUID'] | null),notILike?: (Scalars['UUID'] | null),in?: (Scalars['UUID'][] | null),notIn?: (Scalars['UUID'][] | null)}
@@ -3368,6 +3375,9 @@ export interface ObjectGenqlSelection{
isRemote?: boolean | number
isActive?: boolean | number
isSystem?: boolean | number
isUIEditable?: boolean | number
isUICreatable?: boolean | number
/** @deprecated Use isUIEditable */
isUIReadOnly?: boolean | number
isSearchable?: boolean | number
applicationId?: boolean | number
@@ -3393,7 +3403,7 @@ export interface ObjectGenqlSelection{
__scalar?: boolean | number
}
export interface FieldFilter {and?: (FieldFilter[] | null),or?: (FieldFilter[] | null),id?: (UUIDFilterComparison | null),isActive?: (BooleanFieldComparison | null),isSystem?: (BooleanFieldComparison | null),isUIReadOnly?: (BooleanFieldComparison | null),objectMetadataId?: (UUIDFilterComparison | null)}
export interface FieldFilter {and?: (FieldFilter[] | null),or?: (FieldFilter[] | null),id?: (UUIDFilterComparison | null),isActive?: (BooleanFieldComparison | null),isSystem?: (BooleanFieldComparison | null),isUIEditable?: (BooleanFieldComparison | null),isUIReadOnly?: (BooleanFieldComparison | null),objectMetadataId?: (UUIDFilterComparison | null)}
export interface IndexFilter {and?: (IndexFilter[] | null),or?: (IndexFilter[] | null),id?: (UUIDFilterComparison | null),isCustom?: (BooleanFieldComparison | null)}
@@ -6251,7 +6261,7 @@ export interface CreateOneFieldMetadataInput {
/** The record to create */
field: CreateFieldInput}
export interface CreateFieldInput {type: FieldMetadataType,name: Scalars['String'],label: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),isCustom?: (Scalars['Boolean'] | null),isActive?: (Scalars['Boolean'] | null),isSystem?: (Scalars['Boolean'] | null),isUIReadOnly?: (Scalars['Boolean'] | null),isNullable?: (Scalars['Boolean'] | null),isUnique?: (Scalars['Boolean'] | null),defaultValue?: (Scalars['JSON'] | null),options?: (Scalars['JSON'] | null),settings?: (Scalars['JSON'] | null),objectMetadataId: Scalars['UUID'],isLabelSyncedWithName?: (Scalars['Boolean'] | null),isRemoteCreation?: (Scalars['Boolean'] | null),relationCreationPayload?: (Scalars['JSON'] | null),morphRelationsCreationPayload?: (Scalars['JSON'][] | null)}
export interface CreateFieldInput {type: FieldMetadataType,name: Scalars['String'],label: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),isCustom?: (Scalars['Boolean'] | null),isActive?: (Scalars['Boolean'] | null),isSystem?: (Scalars['Boolean'] | null),isUIEditable?: (Scalars['Boolean'] | null),isUIReadOnly?: (Scalars['Boolean'] | null),isNullable?: (Scalars['Boolean'] | null),isUnique?: (Scalars['Boolean'] | null),defaultValue?: (Scalars['JSON'] | null),options?: (Scalars['JSON'] | null),settings?: (Scalars['JSON'] | null),objectMetadataId: Scalars['UUID'],isLabelSyncedWithName?: (Scalars['Boolean'] | null),isRemoteCreation?: (Scalars['Boolean'] | null),relationCreationPayload?: (Scalars['JSON'] | null),morphRelationsCreationPayload?: (Scalars['JSON'][] | null)}
export interface UpdateOneFieldMetadataInput {
/** The id of the record to update */
@@ -6259,7 +6269,7 @@ id: Scalars['UUID'],
/** The record to update */
update: UpdateFieldInput}
export interface UpdateFieldInput {universalIdentifier?: (Scalars['String'] | null),name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),isSystem?: (Scalars['Boolean'] | null),isUIReadOnly?: (Scalars['Boolean'] | null),isNullable?: (Scalars['Boolean'] | null),isUnique?: (Scalars['Boolean'] | null),defaultValue?: (Scalars['JSON'] | null),options?: (Scalars['JSON'] | null),settings?: (Scalars['JSON'] | null),objectMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),morphRelationsUpdatePayload?: (Scalars['JSON'][] | null)}
export interface UpdateFieldInput {universalIdentifier?: (Scalars['String'] | null),name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),isSystem?: (Scalars['Boolean'] | null),isUIEditable?: (Scalars['Boolean'] | null),isUIReadOnly?: (Scalars['Boolean'] | null),isNullable?: (Scalars['Boolean'] | null),isUnique?: (Scalars['Boolean'] | null),defaultValue?: (Scalars['JSON'] | null),options?: (Scalars['JSON'] | null),settings?: (Scalars['JSON'] | null),objectMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),morphRelationsUpdatePayload?: (Scalars['JSON'][] | null)}
export interface DeleteOneFieldInput {
/** The id of the field to delete. */
@@ -916,6 +916,9 @@ export default {
"isSystem": [
6
],
"isUIEditable": [
6
],
"isUIReadOnly": [
6
],
@@ -1085,6 +1088,12 @@ export default {
"isSystem": [
53
],
"isUIEditable": [
53
],
"isUICreatable": [
53
],
"isUIReadOnly": [
53
],
@@ -1239,6 +1248,12 @@ export default {
"isSystem": [
6
],
"isUIEditable": [
6
],
"isUICreatable": [
6
],
"isUIReadOnly": [
6
],
@@ -1318,6 +1333,9 @@ export default {
"isSystem": [
53
],
"isUIEditable": [
53
],
"isUIReadOnly": [
53
],
@@ -10589,6 +10607,9 @@ export default {
"isSystem": [
6
],
"isUIEditable": [
6
],
"isUIReadOnly": [
6
],
@@ -10659,6 +10680,9 @@ export default {
"isSystem": [
6
],
"isUIEditable": [
6
],
"isUIReadOnly": [
6
],
@@ -23,7 +23,8 @@ const METADATA_QUERY = `
isRemote
isActive
isSystem
isUIReadOnly
isUIEditable
isUICreatable
createdAt
updatedAt
labelIdentifierFieldMetadataId
@@ -64,7 +65,7 @@ const METADATA_QUERY = `
isCustom
isActive
isSystem
isUIReadOnly
isUIEditable
isNullable
isUnique
createdAt
File diff suppressed because one or more lines are too long
@@ -170,7 +170,7 @@ export const CalendarEventDetails = ({
objectPermissions,
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isUIEditable: fieldMetadataItem.isUIEditable ?? true,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
@@ -15,7 +15,8 @@ export const OBJECT_METADATA_FRAGMENT = gql`
isRemote
isActive
isSystem
isUIReadOnly
isUIEditable
isUICreatable
createdAt
updatedAt
labelIdentifierFieldMetadataId
@@ -54,7 +55,7 @@ export const OBJECT_METADATA_FRAGMENT = gql`
isCustom
isActive
isSystem
isUIReadOnly
isUIEditable
isNullable
isUnique
createdAt
@@ -31,7 +31,7 @@ export const CREATE_ONE_OBJECT_METADATA_ITEM = gql`
isCustom
isActive
isSystem
isUIReadOnly
isUIEditable
isNullable
isUnique
createdAt
@@ -12,6 +12,8 @@ export type EnrichedObjectMetadataItem = Omit<
| 'labelIdentifierFieldMetadataId'
| 'fieldsList'
| 'indexMetadataList'
// Deprecated GraphQL field kept server-side for one release; no longer queried
| 'isUIReadOnly'
> & {
__typename?: string;
applicationId?: string;
@@ -24,6 +24,8 @@ export type FieldMetadataItem = Omit<
| 'options'
| 'relation'
| 'morphRelations'
// Deprecated GraphQL field kept server-side for one release; no longer queried
| 'isUIReadOnly'
> & {
__typename?: string;
applicationId?: string;
@@ -49,7 +49,7 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
settings: field.settings,
isNullable: field.isNullable,
isCustom: field.isCustom ?? false,
isUIReadOnly: field.isUIReadOnly ?? false,
isUIEditable: field.isUIEditable ?? true,
};
return {
@@ -50,7 +50,7 @@ export const fieldMetadataItemSchema = (existingLabels?: string[]) => {
isNullable: z.boolean(),
isUnique: z.boolean(),
isSystem: z.boolean(),
isUIReadOnly: z.boolean(),
isUIEditable: z.boolean(),
label: metadataLabelSchema(existingLabels),
isLabelSyncedWithName: z.boolean(),
morphId: z.string().nullable().optional(),
@@ -23,7 +23,8 @@ export const objectMetadataItemSchema = z.object({
isCustom: z.boolean(),
isRemote: z.boolean(),
isSystem: z.boolean(),
isUIReadOnly: z.boolean(),
isUIEditable: z.boolean(),
isUICreatable: z.boolean(),
isSearchable: z.boolean(),
labelIdentifierFieldMetadataId: z.uuid(),
labelPlural: metadataLabelSchema(),
@@ -56,7 +56,8 @@ const mockObjectMetadataItem: EnrichedObjectMetadataItem = {
isLabelSyncedWithName: true,
isRemote: false,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
};
const Wrapper = getJestMetadataAndApolloMocksWrapper({
@@ -37,7 +37,8 @@ const objectMetadataItemWithPositionField: EnrichedObjectMetadataItem = {
icon: 'icon',
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
isCustom: false,
isRemote: false,
isSearchable: false,
@@ -199,7 +200,8 @@ describe('turnSortsIntoOrderBy', () => {
icon: 'IconBuildingSkyscraper',
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
isCustom: false,
isRemote: false,
isSearchable: false,
@@ -249,7 +251,8 @@ describe('turnSortsIntoOrderBy', () => {
icon: 'IconUser',
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
isCustom: false,
isRemote: false,
isSearchable: false,
@@ -9,7 +9,7 @@ describe('isObjectMetadataReadOnly', () => {
restrictedFields: {},
},
objectMetadataItem: {
isUIReadOnly: false,
isUIEditable: true,
isRemote: false,
},
});
@@ -25,7 +25,7 @@ describe('isObjectMetadataReadOnly', () => {
restrictedFields: {},
},
objectMetadataItem: {
isUIReadOnly: false,
isUIEditable: true,
isRemote: false,
},
});
@@ -41,7 +41,7 @@ describe('isObjectMetadataReadOnly', () => {
restrictedFields: {},
},
objectMetadataItem: {
isUIReadOnly: true,
isUIEditable: false,
isRemote: false,
},
});
@@ -57,7 +57,7 @@ describe('isObjectMetadataReadOnly', () => {
restrictedFields: {},
},
objectMetadataItem: {
isUIReadOnly: false,
isUIEditable: true,
isRemote: true,
},
});
@@ -69,7 +69,7 @@ describe('isObjectMetadataReadOnly', () => {
const result = isObjectMetadataReadOnly({
objectMetadataItem: {
applicationId: 'applicationId',
isUIReadOnly: false,
isUIEditable: true,
isRemote: false,
},
});
@@ -81,7 +81,7 @@ describe('isObjectMetadataReadOnly', () => {
const result = isObjectMetadataReadOnly({
objectMetadataItem: {
applicationId: undefined,
isUIReadOnly: false,
isUIEditable: true,
isRemote: false,
},
});
@@ -50,7 +50,7 @@ describe('isOneToManyRelationFieldReadOnlyDueToTargetUpdatePermission', () => {
targetFieldMetadataName: 'person',
settings: null,
isCustom: true,
isUIReadOnly: false,
isUIEditable: true,
},
} as FieldDefinition<FieldMetadata>;
@@ -13,7 +13,7 @@ describe('isRecordFieldReadOnly', () => {
objectPermissions: mockObjectPermissions,
fieldMetadataId: 'field-123',
fieldMetadataType: FieldMetadataType.TEXT,
isUIReadOnly: false,
isUIEditable: true,
};
it('should return true when record is read-only', () => {
@@ -22,7 +22,7 @@ describe('isRecordFieldReadOnly', () => {
isRecordReadOnly: true,
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isUIEditable: true,
isCustom: false,
},
});
@@ -39,7 +39,7 @@ describe('isRecordFieldReadOnly', () => {
},
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isUIEditable: true,
isCustom: false,
},
});
@@ -58,7 +58,7 @@ describe('isRecordFieldReadOnly', () => {
},
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isUIEditable: true,
isCustom: false,
},
});
@@ -71,7 +71,7 @@ describe('isRecordFieldReadOnly', () => {
...mockParams,
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: true,
isUIEditable: false,
isCustom: false,
},
});
@@ -84,7 +84,7 @@ describe('isRecordFieldReadOnly', () => {
...mockParams,
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isUIEditable: true,
isCustom: false,
},
});
@@ -98,7 +98,7 @@ describe('isRecordFieldReadOnly', () => {
isSystemObject: true,
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isUIEditable: true,
isCustom: false,
},
});
@@ -112,7 +112,7 @@ describe('isRecordFieldReadOnly', () => {
isSystemObject: true,
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isUIEditable: true,
isCustom: true,
},
});
@@ -125,7 +125,7 @@ describe('isRecordFieldReadOnly', () => {
...mockParams,
fieldMetadataItem: {
id: 'field-123',
isUIReadOnly: false,
isUIEditable: true,
isCustom: false,
},
});
@@ -9,7 +9,7 @@ describe('isRecordReadOnly', () => {
},
isRecordDeleted: false,
objectMetadataItem: {
isUIReadOnly: false,
isUIEditable: true,
isRemote: false,
},
});
@@ -25,7 +25,7 @@ describe('isRecordReadOnly', () => {
},
isRecordDeleted: false,
objectMetadataItem: {
isUIReadOnly: false,
isUIEditable: true,
isRemote: false,
},
});
@@ -41,7 +41,7 @@ describe('isRecordReadOnly', () => {
},
isRecordDeleted: true,
objectMetadataItem: {
isUIReadOnly: false,
isUIEditable: true,
isRemote: false,
},
});
@@ -57,7 +57,7 @@ describe('isRecordReadOnly', () => {
},
isRecordDeleted: true,
objectMetadataItem: {
isUIReadOnly: true,
isUIEditable: false,
isRemote: false,
},
});
@@ -73,7 +73,7 @@ describe('isRecordReadOnly', () => {
},
isRecordDeleted: false,
objectMetadataItem: {
isUIReadOnly: false,
isUIEditable: true,
isRemote: true,
},
});
@@ -6,7 +6,7 @@ type IsObjectMetadataReadOnlyParams = {
objectPermissions?: ObjectPermission;
objectMetadataItem?: Pick<
EnrichedObjectMetadataItem,
'isUIReadOnly' | 'isRemote' | 'applicationId'
'isUIEditable' | 'isRemote' | 'applicationId'
>;
};
@@ -18,6 +18,6 @@ export const isObjectMetadataReadOnly = ({
(isDefined(objectPermissions) &&
!objectPermissions.canUpdateObjectRecords) ||
(isDefined(objectMetadataItem) &&
(objectMetadataItem.isUIReadOnly || objectMetadataItem.isRemote))
(!objectMetadataItem.isUIEditable || objectMetadataItem.isRemote))
);
};
@@ -17,7 +17,7 @@ type IsRecordFieldReadOnlyParams = {
isSystemObject?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'id' | 'isUIReadOnly' | 'isCustom'
'id' | 'isUIEditable' | 'isCustom'
>;
objectPermissions: ObjectPermission;
fieldDefinition?: FieldDefinition<FieldMetadata>;
@@ -48,7 +48,7 @@ export const isRecordFieldReadOnly = ({
return (
isRecordReadOnly ||
(isSystemObject === true && fieldMetadataItem.isCustom !== true) ||
fieldMetadataItem.isUIReadOnly ||
!(fieldMetadataItem.isUIEditable ?? true) ||
fieldReadOnlyByPermissions ||
oneToManyTargetReadOnly
);
@@ -6,7 +6,7 @@ export type IsObjectReadOnlyParams = {
objectPermissions: ObjectPermission;
objectMetadataItem: Pick<
EnrichedObjectMetadataItem,
'isUIReadOnly' | 'isRemote'
'isUIEditable' | 'isRemote'
>;
isRecordDeleted: boolean;
};
@@ -78,9 +78,9 @@ export const RecordBoardCardBody = () => {
objectPermissions,
fieldMetadataItem: {
id: recordField.fieldMetadataItemId,
isUIReadOnly:
correspondingFieldDefinition.metadata.isUIReadOnly ??
false,
isUIEditable:
correspondingFieldDefinition.metadata.isUIEditable ??
true,
isCustom:
correspondingFieldDefinition.metadata.isCustom ?? false,
},
@@ -14,6 +14,7 @@ import { RecordGroupDefinitionType } from '@/object-record/record-group/types/Re
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
@@ -91,7 +92,10 @@ export const RecordBoardColumnHeader = () => {
objectMetadataItem.id,
);
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const canCreateRecords = canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
});
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
@@ -186,14 +190,13 @@ export const RecordBoardColumnHeader = () => {
});
}}
/>
{hasObjectUpdatePermissions &&
!hasAnySoftDeleteFilterOnView && (
<LightIconButton
accent="tertiary"
Icon={IconPlus}
onClick={handleCreateNewRecordClick}
/>
)}
{canCreateRecords && !hasAnySoftDeleteFilterOnView && (
<LightIconButton
accent="tertiary"
Icon={IconPlus}
onClick={handleCreateNewRecordClick}
/>
)}
</StyledHeaderActions>
)}
</StyledRightContainer>
@@ -3,6 +3,7 @@ import { RecordBoardContext } from '@/object-record/record-board/contexts/Record
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
@@ -45,17 +46,16 @@ export const RecordBoardColumnNewRecordButton = () => {
objectMetadataItem.id,
);
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const { createNewIndexRecord } = useCreateNewIndexRecord({
objectMetadataItem: objectMetadataItem,
});
if (!hasObjectUpdatePermissions) {
return null;
}
if (objectMetadataItem.isSystem) {
if (
!canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
})
) {
return null;
}
@@ -39,7 +39,8 @@ describe('buildRecordGqlFieldsAggregateForView', () => {
isCustom: false,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
isRemote: false,
isSearchable: false,
labelIdentifierFieldMetadataId: '06b33746-5293-4d07-9f7f-ebf5ad396064',
@@ -4,6 +4,7 @@ import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -41,8 +42,6 @@ export const RecordCalendarAddNew = ({
objectMetadataItem.id,
);
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
);
@@ -64,8 +63,10 @@ export const RecordCalendarAddNew = ({
if (
hasAnySoftDeleteFilterOnView === true ||
hasObjectUpdatePermissions === false ||
objectMetadataItem.isSystem === true ||
!canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
}) ||
calendarFieldMetadataItem === undefined ||
isCalendarFieldReadOnly === true
) {
@@ -87,9 +87,9 @@ export const RecordCalendarCardBody = ({
objectPermissions,
fieldMetadataItem: {
id: recordField.fieldMetadataItemId,
isUIReadOnly:
correspondingFieldDefinition.metadata.isUIReadOnly ??
false,
isUIEditable:
correspondingFieldDefinition.metadata.isUIEditable ??
true,
isCustom:
correspondingFieldDefinition.metadata.isCustom ?? false,
},
@@ -45,7 +45,7 @@ export const RecordCalendarCardDraggableContainer = ({
);
const calendarFieldMetadataItemIsUIReadOnly =
calendarFieldMetadataItem?.isUIReadOnly === true;
calendarFieldMetadataItem?.isUIEditable === false;
const calendarFieldMetadataItemIsRestrictedForUpdate = isDefined(
calendarFieldMetadataItem,
@@ -130,7 +130,7 @@ export const RecordFieldList = ({
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isUIEditable: fieldMetadataItem.isUIEditable ?? true,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
@@ -186,7 +186,7 @@ export const RecordFieldList = ({
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isUIEditable: fieldMetadataItem.isUIEditable ?? true,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
@@ -266,7 +266,7 @@ export const RecordFieldList = ({
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isUIEditable: fieldMetadataItem.isUIEditable ?? true,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
@@ -153,7 +153,9 @@ export const usePersistField = ({
const fieldIsFiles =
isFieldFiles(fieldDefinition) && isFieldFilesValue(valueToPersist);
const fieldIsUIReadOnly = fieldDefinition.metadata.isUIReadOnly ?? false;
const fieldIsUIReadOnly = !(
fieldDefinition.metadata.isUIEditable ?? true
);
if (fieldIsRawJson && fieldIsUIReadOnly) {
return;
@@ -145,7 +145,7 @@ export const RawJsonFieldInput = () => {
dependencies: [handleShiftTab, draftValue],
});
const showEditingButton = !fieldDefinition.metadata.isUIReadOnly;
const showEditingButton = fieldDefinition.metadata.isUIEditable ?? true;
const handleStartEditing = () => {
setIsEditing(true);
@@ -6,6 +6,8 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { viewableRecordIdState } from '@/object-record/record-side-panel/states/viewableRecordIdState';
@@ -51,8 +53,15 @@ export const useAddNewRecordAndOpenSidePanel = ({
const apolloCoreClient = useApolloCoreClient();
const relationObjectPermissions = useObjectPermissionsForObject(
relationObjectMetadataItem.id,
);
if (
relationObjectMetadataNameSingular === 'workspaceMember' ||
!canCreateRecordsForObjectMetadataItem({
objectPermissions: relationObjectPermissions,
objectMetadataItem: relationObjectMetadataItem,
}) ||
!isDefined(objectMetadataItem.nameSingular)
) {
return {
@@ -91,8 +100,7 @@ export const useAddNewRecordAndOpenSidePanel = ({
if (relationFieldMetadataItemRelationType === RelationType.ONE_TO_MANY) {
await updateOneRecord({
objectNameSingular:
objectMetadataItem.nameSingular ?? 'workspaceMember',
objectNameSingular: objectMetadataItem.nameSingular,
idToUpdate: recordId,
updateOneRecordInput: {
[`${fieldMetadataItem.name}Id`]: newRecordId,
@@ -13,5 +13,5 @@ export type FieldDefinition<T extends FieldMetadata> = {
metadata: T;
defaultValue?: any;
editButtonIcon?: IconComponent;
isUIReadOnly?: boolean;
isUIEditable?: boolean;
};
@@ -16,7 +16,7 @@ type BaseFieldMetadata = {
fieldName: string;
objectMetadataNameSingular?: string;
isCustom?: boolean;
isUIReadOnly?: boolean;
isUIEditable?: boolean;
};
export type FieldUuidMetadata = BaseFieldMetadata & {
@@ -1,7 +1,9 @@
import { useCallback, useRef } from 'react';
import { useStore } from 'jotai';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { MultipleRecordPickerItemsDisplay } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerItemsDisplay';
import { MultipleRecordPickerOnClickOutsideEffect } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerOnClickOutsideEffect';
import { MultipleRecordPickerSearchInput } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPickerSearchInput';
@@ -104,12 +106,26 @@ export const MultipleRecordPicker = ({
onCreate?.(recordPickerSearchFilter);
}, [multipleRecordPickerSearchFilterState, onCreate, store]);
const hasCreatePermissionOnObjectForCreate = useObjectPermissionsForObject(
const objectPermissionsForCreate = useObjectPermissionsForObject(
objectMetadataItemIdForCreate ?? '',
).canUpdateObjectRecords;
);
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItemForCreate = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === objectMetadataItemIdForCreate,
);
const canCreateRecordForCreate =
isDefined(objectMetadataItemForCreate) &&
canCreateRecordsForObjectMetadataItem({
objectPermissions: objectPermissionsForCreate,
objectMetadataItem: objectMetadataItemForCreate,
});
const createNewButtonSection =
isDefined(onCreate) && hasCreatePermissionOnObjectForCreate ? (
isDefined(onCreate) && canCreateRecordForCreate ? (
<DropdownMenuItemsContainer scrollable={false}>
<CreateNewButton
onClick={handleCreateNewButtonClick}
@@ -10,6 +10,7 @@ import { useSingleRecordPickerSearch } from '@/object-record/record-picker/singl
import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
import { type RecordPickerLayoutDirection } from '@/object-record/record-picker/types/RecordPickerLayoutDirection';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
@@ -68,11 +69,18 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const hasUpdatePermissions = objectMetadataItems.every(
(objectMetadataItem) =>
objectPermissionsByObjectMetadataId[objectMetadataItem.id]
?.canUpdateObjectRecords,
);
const canCreateRecords = objectMetadataItems.every((objectMetadataItem) => {
const objectPermissions =
objectPermissionsByObjectMetadataId[objectMetadataItem.id];
return (
isDefined(objectPermissions) &&
canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
})
);
});
const handleCreateNew = () => {
onCreate?.(singleRecordPickerSearchFilter);
@@ -83,7 +91,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
<SingleRecordPickerLoadingEffect loading={loading} />
{layoutDirection === 'search-bar-on-bottom' && (
<>
{isDefined(onCreate) && hasUpdatePermissions && (
{isDefined(onCreate) && canCreateRecords && (
<>
<DropdownMenuItemsContainer scrollable={false}>
<CreateNewButton
@@ -131,7 +139,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
}}
/>
</DropdownMenuItemsContainer>
{isDefined(onCreate) && hasUpdatePermissions && (
{isDefined(onCreate) && canCreateRecords && (
<>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
@@ -5,7 +5,7 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { isRecordTableCellsNonEditableComponentState } from '@/object-record/record-table/states/isRecordTableCellsNonEditableComponentState';
import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow';
import { isRecordTableCreateDisabled } from '@/object-record/record-table/utils/isRecordTableCreateDisabled';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useLoadRecordsToVirtualRows } from '@/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows';
import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -30,8 +30,6 @@ export const RecordTableNoRecordGroupAddNew = () => {
objectMetadataItem.id,
);
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
);
@@ -71,11 +69,12 @@ export const RecordTableNoRecordGroupAddNew = () => {
return null;
}
if (!hasObjectUpdatePermissions) {
return null;
}
if (isRecordTableCreateDisabled(objectMetadataItem)) {
if (
!canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
})
) {
return null;
}
@@ -1,9 +1,8 @@
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { isRecordTableCreateDisabled } from '@/object-record/record-table/utils/isRecordTableCreateDisabled';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -54,9 +53,9 @@ export const RecordTableEmptyStateDisplay = (
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const isReadOnly =
isLayoutCustomizationModeEnabled ||
isObjectMetadataReadOnly({
const canCreateRecords =
!isLayoutCustomizationModeEnabled &&
canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
});
@@ -83,9 +82,8 @@ export const RecordTableEmptyStateDisplay = (
</AnimatedPlaceholderEmptyTextContainer>
{'buttonComponent' in props && props.buttonComponent}
{'buttonTitle' in props &&
!isReadOnly &&
!hasAnySoftDeleteFilterOnView &&
!isRecordTableCreateDisabled(objectMetadataItem) && (
canCreateRecords &&
!hasAnySoftDeleteFilterOnView && (
<Button
Icon={props.ButtonIcon}
title={props.buttonTitle}
@@ -113,7 +113,7 @@ export const RecordTableCellFieldContextGeneric = ({
objectPermissions,
fieldMetadataItem: {
id: fieldDefinition.fieldMetadataId,
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
isUIEditable: fieldDefinition.metadata.isUIEditable ?? true,
isCustom: fieldDefinition.metadata.isCustom ?? false,
},
fieldDefinition,
@@ -70,7 +70,7 @@ export const RecordTableCellFieldContextLabelIdentifier = ({
objectPermissions,
fieldMetadataItem: {
id: recordField.fieldMetadataItemId,
isUIReadOnly: fieldDefinition.metadata.isUIReadOnly ?? false,
isUIEditable: fieldDefinition.metadata.isUIEditable ?? true,
isCustom: fieldDefinition.metadata.isCustom ?? false,
},
fieldDefinition,
@@ -1,9 +1,8 @@
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { isRecordTableCreateDisabled } from '@/object-record/record-table/utils/isRecordTableCreateDisabled';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
@@ -36,25 +35,18 @@ export const RecordTableHeaderLabelIdentifierCellPlusButton = () => {
});
};
const isReadOnly =
isLayoutCustomizationModeEnabled ||
isObjectMetadataReadOnly({
objectPermissions,
objectMetadataItem,
});
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
);
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
return (
!isMobile &&
!isReadOnly &&
hasObjectUpdatePermissions &&
!isLayoutCustomizationModeEnabled &&
!hasAnySoftDeleteFilterOnView &&
!isRecordTableCreateDisabled(objectMetadataItem) && (
canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
}) && (
<StyledHeaderIcon>
<LightIconButton
Icon={IconPlus}
@@ -5,7 +5,7 @@ import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow';
import { isRecordTableCreateDisabled } from '@/object-record/record-table/utils/isRecordTableCreateDisabled';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
@@ -37,13 +37,12 @@ export const RecordTableRecordGroupSectionAddNew = () => {
objectMetadataItem.id,
);
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
if (!hasObjectUpdatePermissions) {
return null;
}
if (isRecordTableCreateDisabled(objectMetadataItem)) {
if (
!canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
})
) {
return null;
}
@@ -1,20 +0,0 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
const OBJECTS_WITHOUT_MANUAL_RECORD_CREATION: readonly CoreObjectNameSingular[] =
[CoreObjectNameSingular.WorkflowRun, CoreObjectNameSingular.WorkflowVersion];
export const isRecordTableCreateDisabled = (
objectMetadataItem: Pick<
EnrichedObjectMetadataItem,
'nameSingular' | 'isSystem'
>,
): boolean => {
if (objectMetadataItem.isSystem) {
return true;
}
return OBJECTS_WITHOUT_MANUAL_RECORD_CREATION.includes(
objectMetadataItem.nameSingular as CoreObjectNameSingular,
);
};
@@ -12,7 +12,7 @@ const createMockFieldMetadataItem = (
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUnique: false,
isNullable: true,
...overrides,
@@ -0,0 +1,94 @@
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
const creatableObjectMetadataItem = {
isUICreatable: true,
isUIEditable: true,
isSystem: false,
isRemote: false,
applicationId: 'applicationId',
};
const objectPermissionsAllowingUpdate = {
canUpdateObjectRecords: true,
objectMetadataId: '123',
restrictedFields: {},
};
describe('canCreateRecordsForObjectMetadataItem', () => {
it('should return true for a creatable, editable, non-system, non-remote object with update permission', () => {
const result = canCreateRecordsForObjectMetadataItem({
objectPermissions: objectPermissionsAllowingUpdate,
objectMetadataItem: creatableObjectMetadataItem,
});
expect(result).toBe(true);
});
it('should return false when the object is not UI creatable', () => {
const result = canCreateRecordsForObjectMetadataItem({
objectPermissions: objectPermissionsAllowingUpdate,
objectMetadataItem: {
...creatableObjectMetadataItem,
isUICreatable: false,
},
});
expect(result).toBe(false);
});
it('should return false when the object is not UI editable', () => {
const result = canCreateRecordsForObjectMetadataItem({
objectPermissions: objectPermissionsAllowingUpdate,
objectMetadataItem: {
...creatableObjectMetadataItem,
isUIEditable: false,
},
});
expect(result).toBe(false);
});
it('should return false when the object is a system object', () => {
const result = canCreateRecordsForObjectMetadataItem({
objectPermissions: objectPermissionsAllowingUpdate,
objectMetadataItem: {
...creatableObjectMetadataItem,
isSystem: true,
},
});
expect(result).toBe(false);
});
it('should return false when the object is remote', () => {
const result = canCreateRecordsForObjectMetadataItem({
objectPermissions: objectPermissionsAllowingUpdate,
objectMetadataItem: {
...creatableObjectMetadataItem,
isRemote: true,
},
});
expect(result).toBe(false);
});
it('should return false when the user lacks the update permission proxy', () => {
const result = canCreateRecordsForObjectMetadataItem({
objectPermissions: {
...objectPermissionsAllowingUpdate,
canUpdateObjectRecords: false,
},
objectMetadataItem: creatableObjectMetadataItem,
});
expect(result).toBe(false);
});
it('should not require object permissions to be provided', () => {
const result = canCreateRecordsForObjectMetadataItem({
objectMetadataItem: creatableObjectMetadataItem,
});
expect(result).toBe(true);
});
});
@@ -23,7 +23,8 @@ describe('generateAggregateQuery', () => {
isLabelSyncedWithName: true,
isRemote: false,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
};
const mockRecordGqlFields = {
@@ -66,7 +67,8 @@ describe('generateAggregateQuery', () => {
isLabelSyncedWithName: true,
isRemote: false,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
};
const mockRecordGqlFields = {
@@ -0,0 +1,27 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isObjectMetadataReadOnly } from '@/object-record/read-only/utils/isObjectMetadataReadOnly';
import { type ObjectPermission } from '~/generated-metadata/graphql';
type CanCreateRecordsForObjectMetadataItemParams = {
objectPermissions?: ObjectPermission;
objectMetadataItem: Pick<
EnrichedObjectMetadataItem,
'isUICreatable' | 'isUIEditable' | 'isSystem' | 'isRemote' | 'applicationId'
>;
};
// Single predicate for every generic "create a record" UI affordance.
// Creation requires effective editability because today's inline creation UX
// creates a blank record that the user must then be able to edit.
// There is no CREATE permission yet, so canUpdateObjectRecords (checked
// through isObjectMetadataReadOnly) acts as a proxy.
export const canCreateRecordsForObjectMetadataItem = ({
objectPermissions,
objectMetadataItem,
}: CanCreateRecordsForObjectMetadataItemParams): boolean => {
return (
objectMetadataItem.isUICreatable &&
!objectMetadataItem.isSystem &&
!isObjectMetadataReadOnly({ objectPermissions, objectMetadataItem })
);
};
@@ -115,7 +115,7 @@ export const WidgetActionFieldEdit = () => {
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isUIEditable: fieldMetadataItem.isUIEditable ?? true,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
@@ -97,7 +97,7 @@ export const FieldWidgetDisplay = ({
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isUIEditable: fieldMetadataItem.isUIEditable ?? true,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
@@ -82,7 +82,7 @@ export const FieldsWidgetFieldItem = ({
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isUIEditable: fieldMetadataItem.isUIEditable ?? true,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
@@ -84,7 +84,7 @@ export const useWidgetActions = ({
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
isUIEditable: fieldMetadataItem.isUIEditable ?? true,
isCustom: fieldMetadataItem.isCustom ?? false,
},
fieldDefinition,
@@ -195,7 +195,7 @@ export const SettingsRolePermissionsObjectLevelObjectFieldPermissionTableRow =
<TableCell>
<OverridableCheckbox
disabled={
(fieldMetadataItem.isUIReadOnly || isLabelIdentifier) ?? false
!(fieldMetadataItem.isUIEditable ?? true) || isLabelIdentifier
}
checked={true}
onChange={handleSeeChange}
@@ -206,7 +206,7 @@ export const SettingsRolePermissionsObjectLevelObjectFieldPermissionTableRow =
{shouldShowUpdateTableHeader && (
<TableCell align="left">
<OverridableCheckbox
disabled={fieldMetadataItem.isUIReadOnly ?? false}
disabled={!(fieldMetadataItem.isUIEditable ?? true)}
checked={true}
onChange={handleUpdateChange}
type={isUpdateRestricted ? 'override' : 'default'}
@@ -5,7 +5,7 @@ export const useObjectMetadataItemsThatCanHavePermission = () => {
useFilteredObjectMetadataItems();
const objectMetadataItemsThatCanHavePermission = objectMetadataItems.filter(
(objectMetadataItem) => !objectMetadataItem.isUIReadOnly,
(objectMetadataItem) => objectMetadataItem.isUIEditable,
);
return {
@@ -46,7 +46,7 @@ export const mapViewFieldsToColumnDefinitions = ({
isLabelIdentifier,
isVisible: isLabelIdentifier || viewField.isVisible,
viewFieldId: viewField.id,
isUIReadOnly: correspondingColumnDefinition.metadata.isUIReadOnly,
isUIEditable: correspondingColumnDefinition.metadata.isUIEditable,
isSortable: correspondingColumnDefinition.isSortable,
isFilterable: correspondingColumnDefinition.isFilterable,
defaultValue: correspondingColumnDefinition.defaultValue,
@@ -26,7 +26,7 @@ const fields = [
isCustom: false,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isNullable: false,
createdAt: '',
updatedAt: '',
@@ -41,7 +41,7 @@ const fields = [
isCustom: false,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isNullable: true,
createdAt: '',
updatedAt: '',
@@ -56,7 +56,7 @@ const fields = [
isCustom: false,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isNullable: true,
createdAt: '',
updatedAt: '',
@@ -73,7 +73,8 @@ const mockObjectMetadataItem: EnrichedObjectMetadataItem = {
description: 'A company',
icon: 'IconBuilding',
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
isCustom: false,
isActive: true,
createdAt: '',
@@ -46,14 +46,14 @@ export const shouldDisplayFormField = ({
case 'UPDATE_RECORD':
return (
!isNotSupportedRelation &&
!fieldMetadataItem.isUIReadOnly &&
(fieldMetadataItem.isUIEditable ?? true) &&
!isHiddenSystemField(fieldMetadataItem) &&
fieldMetadataItem.isActive
);
case 'UPSERT_RECORD':
return (
(!isNotSupportedRelation &&
!fieldMetadataItem.isUIReadOnly &&
(fieldMetadataItem.isUIEditable ?? true) &&
!isHiddenSystemField(fieldMetadataItem) &&
fieldMetadataItem.isActive) ||
isIdField
@@ -163,7 +163,7 @@ const buildFieldMetadataItemFromMarketplaceField = (
isSystem: false,
isNullable: true,
isUnique: false,
isUIReadOnly: false,
isUIEditable: true,
createdAt: now,
updatedAt: now,
defaultValue: null,
@@ -244,7 +244,8 @@ const buildObjectMetadataItemsFromMarketplaceApp = (
isActive: true,
isSystem: false,
isSearchable: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
isLabelSyncedWithName: false,
labelIdentifierFieldMetadataId: '',
fields,
@@ -0,0 +1,64 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
// Replaces the negative isUIReadOnly flag with the positive isUIEditable flag
// (inverted polarity) on both objectMetadata and fieldMetadata, and adds the
// object-level isUICreatable flag.
@RegisteredInstanceCommand('2.13.0', 1781277453604)
export class RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" ADD COLUMN IF NOT EXISTS "isUIEditable" boolean NOT NULL DEFAULT true',
);
await queryRunner.query(
`UPDATE "core"."objectMetadata" SET "isUIEditable" = false WHERE "isUIReadOnly" = true`,
);
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" DROP COLUMN IF EXISTS "isUIReadOnly"',
);
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" ADD COLUMN IF NOT EXISTS "isUICreatable" boolean NOT NULL DEFAULT true',
);
await queryRunner.query(
'ALTER TABLE "core"."fieldMetadata" ADD COLUMN IF NOT EXISTS "isUIEditable" boolean NOT NULL DEFAULT true',
);
await queryRunner.query(
`UPDATE "core"."fieldMetadata" SET "isUIEditable" = false WHERE "isUIReadOnly" = true`,
);
await queryRunner.query(
'ALTER TABLE "core"."fieldMetadata" DROP COLUMN IF EXISTS "isUIReadOnly"',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."fieldMetadata" ADD COLUMN IF NOT EXISTS "isUIReadOnly" boolean NOT NULL DEFAULT false',
);
await queryRunner.query(
`UPDATE "core"."fieldMetadata" SET "isUIReadOnly" = true WHERE "isUIEditable" = false`,
);
await queryRunner.query(
'ALTER TABLE "core"."fieldMetadata" DROP COLUMN IF EXISTS "isUIEditable"',
);
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" DROP COLUMN IF EXISTS "isUICreatable"',
);
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" ADD COLUMN IF NOT EXISTS "isUIReadOnly" boolean NOT NULL DEFAULT false',
);
await queryRunner.query(
`UPDATE "core"."objectMetadata" SET "isUIReadOnly" = true WHERE "isUIEditable" = false`,
);
await queryRunner.query(
'ALTER TABLE "core"."objectMetadata" DROP COLUMN IF EXISTS "isUIEditable"',
);
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { SyncStandardUiCapabilityFlagsCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-workspace-command-1781277460000-sync-standard-ui-capability-flags.command';
import { SyncCreateRecordCommandAvailabilityExpressionCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-workspace-command-1781277470000-sync-create-record-command-availability-expression.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
ApplicationModule,
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [
SyncStandardUiCapabilityFlagsCommand,
SyncCreateRecordCommandAvailabilityExpressionCommand,
],
})
export class V2_13_UpgradeVersionCommandModule {}
@@ -0,0 +1,179 @@
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
// Re-syncs the UI capability flags of standard objects and fields with their
// standard-application definitions. Covers two cases the rename instance
// command cannot reach:
// - isUICreatable is a new column (default true) and workflowRun,
// workflowVersion and workspaceMember must become non-creatable; the
// twenty-standard application is not re-synced on existing workspaces.
// - standard fields created by pre-2.13 workspace upgrade commands during a
// cross-version upgrade lose their isUIEditable: false value (the column is
// hidden until the rename instance command has run), so they would
// otherwise stay editable after the rename backfill.
@RegisteredWorkspaceCommand('2.13.0', 1781277460000)
@Command({
name: 'upgrade:2-13:sync-standard-ui-capability-flags',
description:
'Re-sync isUICreatable and isUIEditable on standard objects and isUIEditable on standard fields from the standard-application definitions',
})
export class SyncStandardUiCapabilityFlagsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Syncing standard UI capability flags for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
]);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const objectsToUpdate = Object.values(
standardAllFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.map((standardObject) => {
const existingObject =
existingFlatObjectMetadataMaps.byUniversalIdentifier[
standardObject.universalIdentifier
];
if (
!isDefined(existingObject) ||
(existingObject.isUICreatable === standardObject.isUICreatable &&
existingObject.isUIEditable === standardObject.isUIEditable)
) {
return undefined;
}
return {
...existingObject,
isUICreatable: standardObject.isUICreatable,
isUIEditable: standardObject.isUIEditable,
updatedAt: new Date().toISOString(),
};
})
.filter(isDefined);
const fieldsToUpdate = Object.values(
standardAllFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.map((standardField) => {
const existingField =
existingFlatFieldMetadataMaps.byUniversalIdentifier[
standardField.universalIdentifier
];
if (
!isDefined(existingField) ||
existingField.isUIEditable === standardField.isUIEditable
) {
return undefined;
}
return {
...existingField,
isUIEditable: standardField.isUIEditable,
updatedAt: new Date().toISOString(),
};
})
.filter(isDefined);
if (objectsToUpdate.length === 0 && fieldsToUpdate.length === 0) {
this.logger.log(
`Standard UI capability flags already up to date for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${objectsToUpdate.length} standard object(s) and ${fieldsToUpdate.length} standard field(s) with drifted UI capability flags for workspace ${workspaceId}`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would sync UI capability flags on ${objectsToUpdate.length} standard object(s) and ${fieldsToUpdate.length} standard field(s) for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
objectMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: objectsToUpdate,
},
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: fieldsToUpdate,
},
},
workspaceId,
// workflowRun, workflowVersion and workspaceMember are system
// objects; without a system build the validator rejects the update.
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to sync standard UI capability flags:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to sync standard UI capability flags for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully synced UI capability flags on ${objectsToUpdate.length} standard object(s) and ${fieldsToUpdate.length} standard field(s) for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,136 @@
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@RegisteredWorkspaceCommand('2.13.0', 1781277470000)
@Command({
name: 'upgrade:2-13:sync-create-record-command-availability-expression',
description:
'Re-sync conditionalAvailabilityExpression on standard command menu items in existing workspaces (the create-record command is now gated on objectMetadataItem.isUICreatable and isUIEditable)',
})
export class SyncCreateRecordCommandAvailabilityExpressionCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Syncing command menu item availability expressions for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
]);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const itemsToUpdate = Object.values(
standardAllFlatEntityMaps.flatCommandMenuItemMaps.byUniversalIdentifier,
)
.filter(isDefined)
.map((standardItem) => {
const existingItem =
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
standardItem.universalIdentifier
];
if (
!isDefined(existingItem) ||
existingItem.conditionalAvailabilityExpression ===
standardItem.conditionalAvailabilityExpression
) {
return undefined;
}
return {
...existingItem,
conditionalAvailabilityExpression:
standardItem.conditionalAvailabilityExpression,
updatedAt: new Date().toISOString(),
};
})
.filter(isDefined);
if (itemsToUpdate.length === 0) {
this.logger.log(
`Command menu item availability expressions already up to date for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${itemsToUpdate.length} command menu item(s) with drifted availability expressions for workspace ${workspaceId}`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would sync ${itemsToUpdate.length} command menu item availability expression(s) for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: itemsToUpdate,
},
},
workspaceId,
// Standard command menu items are system-owned; without a system
// build the validator rejects direct updates.
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to sync command menu item availability expressions:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to sync command menu item availability expressions for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully synced ${itemsToUpdate.length} command menu item availability expression(s) for workspace ${workspaceId}`,
);
}
}
@@ -62,6 +62,7 @@ import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/comma
import { DropIsCustomFromObjectAndFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-12/2-12-instance-command-fast-1780579070012-drop-is-custom-from-object-and-field-metadata';
import { DropEmailingDomainDriverColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-11/2-11-instance-command-fast-1780926908000-drop-emailing-domain-driver-column';
import { ViewOverridableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-12/2-12-instance-command-fast-1781114009075-view-overridable-entity';
import { RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-13/2-13-instance-command-fast-1781277453604-rename-is-ui-read-only-to-is-ui-editable';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -126,4 +127,5 @@ export const INSTANCE_COMMANDS = [
DropIsCustomFromObjectAndFieldMetadataFastInstanceCommand,
DropEmailingDomainDriverColumnFastInstanceCommand,
ViewOverridableEntityFastInstanceCommand,
RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand,
];
@@ -13,6 +13,7 @@ import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module';
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-10/2-10-upgrade-version-command.module';
import { V2_13_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-13/2-13-upgrade-version-command.module';
@Module({
imports: [
@@ -29,6 +30,7 @@ import { V2_10_UpgradeVersionCommandModule } from 'src/database/commands/upgrade
V2_8_UpgradeVersionCommandModule,
V2_9_UpgradeVersionCommandModule,
V2_10_UpgradeVersionCommandModule,
V2_13_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}
@@ -163,7 +163,8 @@ export const mockPersonFlatObjectMetadata = (
shortcut: null,
description: null,
standardOverrides: null,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
applicationUniversalIdentifier: 'test-application-id',
fieldUniversalIdentifiers: mockFieldMetadatas.map(
(field) => field.universalIdentifier,
@@ -105,4 +105,36 @@ describe('fromFieldManifestToUniversalFlatFieldMetadata', () => {
expect(result.defaultValue).toBe("'todo'");
});
});
describe('isUIEditable', () => {
it('defaults to true when omitted from the manifest', () => {
const result = fromFieldManifestToUniversalFlatFieldMetadata({
fieldManifest: buildFieldManifest({}),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.isUIEditable).toBe(true);
});
it('uses the manifest value when set to false', () => {
const result = fromFieldManifestToUniversalFlatFieldMetadata({
fieldManifest: buildFieldManifest({ isUIEditable: false }),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.isUIEditable).toBe(false);
});
it('uses the manifest value when set to true', () => {
const result = fromFieldManifestToUniversalFlatFieldMetadata({
fieldManifest: buildFieldManifest({ isUIEditable: true }),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.isUIEditable).toBe(true);
});
});
});
@@ -0,0 +1,62 @@
import { type ObjectManifest } from 'twenty-shared/application';
import { fromObjectManifestToUniversalFlatObjectMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util';
const APP_UID = '11111111-1111-1111-1111-111111111111';
const OBJECT_UID = '22222222-2222-2222-2222-222222222222';
const LABEL_IDENTIFIER_FIELD_UID = '33333333-3333-3333-3333-333333333333';
const NOW = '2026-05-15T10:00:00.000Z';
const buildObjectManifest = (
overrides: Partial<ObjectManifest>,
): ObjectManifest =>
({
universalIdentifier: OBJECT_UID,
nameSingular: 'pet',
namePlural: 'pets',
labelSingular: 'Pet',
labelPlural: 'Pets',
fields: [],
labelIdentifierFieldMetadataUniversalIdentifier: LABEL_IDENTIFIER_FIELD_UID,
...overrides,
}) as ObjectManifest;
describe('fromObjectManifestToUniversalFlatObjectMetadata', () => {
describe('UI capability flags', () => {
it('defaults isUICreatable and isUIEditable to true when omitted from the manifest', () => {
const result = fromObjectManifestToUniversalFlatObjectMetadata({
objectManifest: buildObjectManifest({}),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.isUICreatable).toBe(true);
expect(result.isUIEditable).toBe(true);
});
it('uses the manifest values when set to false', () => {
const result = fromObjectManifestToUniversalFlatObjectMetadata({
objectManifest: buildObjectManifest({
isUICreatable: false,
isUIEditable: false,
}),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.isUICreatable).toBe(false);
expect(result.isUIEditable).toBe(false);
});
it('keeps the two flags independent', () => {
const result = fromObjectManifestToUniversalFlatObjectMetadata({
objectManifest: buildObjectManifest({ isUICreatable: false }),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.isUICreatable).toBe(false);
expect(result.isUIEditable).toBe(true);
});
});
});
@@ -94,7 +94,7 @@ export const fromFieldManifestToUniversalFlatFieldMetadata = ({
universalSettings: fieldManifest.universalSettings ?? null,
isActive: true,
isSystem: fieldManifest.name in PARTIAL_SYSTEM_FLAT_FIELD_METADATAS,
isUIReadOnly: false,
isUIEditable: fieldManifest.isUIEditable ?? true,
isNullable: fieldManifest.isNullable ?? true,
isUnique: fieldManifest.isUnique ?? false,
isLabelSyncedWithName: false,
@@ -26,7 +26,8 @@ export const fromObjectManifestToUniversalFlatObjectMetadata = ({
isRemote: false,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: objectManifest.isUIEditable ?? true,
isUICreatable: objectManifest.isUICreatable ?? true,
isAuditLogged: true,
isSearchable: objectManifest.isSearchable ?? true,
duplicateCriteria: null,
@@ -36,7 +36,8 @@ const mockObjectMetadata: FlatObjectMetadata = {
shortcut: null,
description: null,
standardOverrides: null,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
duplicateCriteria: null,
@@ -61,12 +61,12 @@ describe('compactMetadataOutput', () => {
const record = {
id: '123',
isLabelSyncedWithName: false,
isUIReadOnly: false,
isSystem: false,
isActive: true,
};
const result = compactMetadataOutput(record, {
stripWhenFalse: ['isLabelSyncedWithName', 'isUIReadOnly'],
stripWhenFalse: ['isLabelSyncedWithName', 'isSystem'],
});
expect(result).toEqual({
@@ -79,41 +79,75 @@ describe('compactMetadataOutput', () => {
const record = {
id: '123',
isLabelSyncedWithName: true,
isUIReadOnly: true,
isSystem: true,
};
const result = compactMetadataOutput(record, {
stripWhenFalse: ['isLabelSyncedWithName', 'isUIReadOnly'],
stripWhenFalse: ['isLabelSyncedWithName', 'isSystem'],
});
expect(result).toEqual({
id: '123',
isLabelSyncedWithName: true,
isUIReadOnly: true,
isSystem: true,
});
});
it('should apply both stripWhenNullish and stripWhenFalse together', () => {
it('should strip keys with true values when listed in stripWhenTrue', () => {
const record = {
id: '123',
isUIEditable: true,
isActive: true,
};
const result = compactMetadataOutput(record, {
stripWhenTrue: ['isUIEditable'],
});
expect(result).toEqual({
id: '123',
isActive: true,
});
});
it('should not strip keys with false values when listed in stripWhenTrue', () => {
const record = {
id: '123',
isUIEditable: false,
};
const result = compactMetadataOutput(record, {
stripWhenTrue: ['isUIEditable'],
});
expect(result).toEqual({
id: '123',
isUIEditable: false,
});
});
it('should apply stripWhenNullish, stripWhenFalse and stripWhenTrue together', () => {
const record = {
id: '123',
name: 'test',
description: null,
icon: 'IconStar',
isLabelSyncedWithName: false,
isUIReadOnly: true,
isUIEditable: false,
options: null,
};
const result = compactMetadataOutput(record, {
stripWhenNullish: ['description', 'options'],
stripWhenFalse: ['isLabelSyncedWithName', 'isUIReadOnly'],
stripWhenFalse: ['isLabelSyncedWithName'],
stripWhenTrue: ['isUIEditable'],
});
expect(result).toEqual({
id: '123',
name: 'test',
icon: 'IconStar',
isUIReadOnly: true,
isUIEditable: false,
});
});
@@ -1,6 +1,7 @@
type CompactConfig = {
stripWhenNullish?: string[];
stripWhenFalse?: string[];
stripWhenTrue?: string[];
};
export const compactMetadataOutput = (
@@ -21,5 +22,11 @@ export const compactMetadataOutput = (
}
}
for (const key of config.stripWhenTrue ?? []) {
if (result[key] === true) {
delete result[key];
}
}
return result;
};
@@ -120,6 +120,17 @@ export class FieldMetadataDTO<T extends FieldMetadataType = FieldMetadataType> {
@IsBoolean()
@IsOptional()
@FilterableField({ nullable: true })
isUIEditable?: boolean;
// Deprecated alias kept for one release: stays filterable so the GraphQL
// input types (CreateFieldInput, UpdateFieldInput, FieldFilter) keep their
// isUIReadOnly member and external API consumers are not broken.
@IsBoolean()
@IsOptional()
@FilterableField({
nullable: true,
deprecationReason: 'Use isUIEditable',
})
isUIReadOnly?: boolean;
@IsBoolean()
@@ -20,7 +20,9 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { WasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
import { RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME } from 'src/engine/metadata-modules/object-metadata/constants/rename-is-ui-read-only-to-is-ui-editable-upgrade-command-name.constant';
import { type FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import { AssignIfIsGivenFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/assign-if-is-given-field-metadata-type.type';
import { AssignTypeIfIsMorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/assign-type-if-is-morph-or-relation-field-metadata-type.type';
@@ -120,8 +122,19 @@ export class FieldMetadataEntity<
@Column({ default: false })
isSystem: boolean;
@Column({ default: false })
isUIReadOnly: boolean;
@WasIntroducedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
@Column({ default: true })
isUIEditable: boolean;
@WasRemovedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
@Column({ type: 'boolean', default: false })
isUIReadOnly: WasRemovedInUpgrade<boolean>;
// Is this really nullable ?
@Column({ nullable: true, default: true, type: 'boolean' })
@@ -61,6 +61,16 @@ export class FieldMetadataResolver {
});
}
@ResolveField(() => Boolean, {
nullable: true,
deprecationReason: 'Use isUIEditable',
})
async isUIReadOnly(
@Parent() fieldMetadata: Pick<FieldMetadataDTO, 'isUIEditable'>,
): Promise<boolean> {
return !(fieldMetadata.isUIEditable ?? true);
}
@ResolveField(() => String, { nullable: true })
async label(
@Parent() fieldMetadata: FieldMetadataStandardOverrideParent,
@@ -20,7 +20,10 @@ const FIELD_STRIP_WHEN_NULLISH = [
'deletedAt',
];
const FIELD_STRIP_WHEN_FALSE = ['isLabelSyncedWithName', 'isUIReadOnly'];
const FIELD_STRIP_WHEN_FALSE = ['isLabelSyncedWithName'];
// isUIEditable defaults to true, so only the non-default false value is informative
const FIELD_STRIP_WHEN_TRUE = ['isUIEditable'];
const GetFieldMetadataInputSchema = z.object({
id: z
@@ -176,6 +179,7 @@ export class FieldMetadataToolsFactory {
{
stripWhenNullish: FIELD_STRIP_WHEN_NULLISH,
stripWhenFalse: FIELD_STRIP_WHEN_FALSE,
stripWhenTrue: FIELD_STRIP_WHEN_TRUE,
},
);
});
@@ -22,7 +22,8 @@ export const fromFieldMetadataEntityToFieldMetadataDto = (
isCustom: entity.applicationId !== standardApplicationId,
isActive: entity.isActive,
isSystem: entity.isSystem,
isUIReadOnly: entity.isUIReadOnly,
isUIEditable: entity.isUIEditable,
isUIReadOnly: !entity.isUIEditable,
isNullable: entity.isNullable ?? false,
isUnique: uniqueFieldMetadataIds?.has(entity.id) ?? false,
defaultValue: entity.defaultValue ?? undefined,
@@ -71,6 +71,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"options",
"standardOverrides",
"universalSettings",
"isUIEditable",
],
"propertiesToStringify": [
"defaultValue",
@@ -165,6 +166,8 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"nameSingular",
"labelIdentifierFieldMetadataUniversalIdentifier",
"standardOverrides",
"isUIEditable",
"isUICreatable",
"isSearchable",
"imageIdentifierFieldMetadataUniversalIdentifier",
],
@@ -108,8 +108,8 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
isUIReadOnly: {
toCompare: false,
isUIEditable: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
@@ -212,8 +212,13 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
isUIReadOnly: {
toCompare: false,
isUIEditable: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
isUICreatable: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
@@ -22,6 +22,7 @@ type Assertions = [
| 'settings'
| 'isUnique'
| 'isLabelSyncedWithName'
| 'isUIEditable'
| 'universalSettings'
>
>,
@@ -52,6 +53,8 @@ type Assertions = [
| 'imageIdentifierFieldMetadataId'
| 'imageIdentifierFieldMetadataUniversalIdentifier'
| 'isSearchable'
| 'isUIEditable'
| 'isUICreatable'
>
>,
@@ -36,7 +36,7 @@ export const getFlatFieldMetadataMock = <T extends FieldMetadataType>(
label: 'flat field metadata label',
isNullable: true,
isUnique: false,
isUIReadOnly: false,
isUIEditable: true,
isLabelSyncedWithName: false,
isSystem: false,
standardOverrides: null,
@@ -49,7 +49,7 @@ export const getRelationTargetFlatFieldMetadataMock = ({
label: 'flat field metadata label',
isNullable: true,
isUnique: false,
isUIReadOnly: false,
isUIEditable: true,
isLabelSyncedWithName: false,
isSystem: false,
standardOverrides: null,
@@ -127,7 +127,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isUIReadOnly": false,
"isUIEditable": true,
"isUnique": false,
"kanbanAggregateOperationViewUniversalIdentifiers": [],
"label": "newFieldLabel",
@@ -161,7 +161,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isUIReadOnly": false,
"isUIEditable": true,
"isUnique": false,
"kanbanAggregateOperationViewUniversalIdentifiers": [],
"label": "Pet",
@@ -197,7 +197,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isUIReadOnly": false,
"isUIEditable": true,
"isUnique": false,
"kanbanAggregateOperationViewUniversalIdentifiers": [],
"label": "newFieldLabel",
@@ -231,7 +231,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"isLabelSyncedWithName": false,
"isNullable": true,
"isSystem": false,
"isUIReadOnly": false,
"isUIEditable": true,
"isUnique": false,
"kanbanAggregateOperationViewUniversalIdentifiers": [],
"label": "Company",
@@ -25,7 +25,7 @@ export const fromFlatFieldMetadataToFieldMetadataDto = (
isActive,
isLabelSyncedWithName,
isSystem,
isUIReadOnly,
isUIEditable,
options,
morphId,
applicationId,
@@ -44,7 +44,8 @@ export const fromFlatFieldMetadataToFieldMetadataDto = (
isCustom: !belongsToTwentyStandardApp(flatFieldMetadata),
isLabelSyncedWithName,
isSystem,
isUIReadOnly,
isUIEditable,
isUIReadOnly: !isUIEditable,
options,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
@@ -1,4 +1,7 @@
import { extractAndSanitizeObjectStringFields } from 'twenty-shared/utils';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
@@ -53,7 +56,13 @@ export const getDefaultFlatFieldMetadata = ({
: resolvedDefaultValue,
createdAt,
updatedAt: createdAt,
isUIReadOnly: createFieldInput.isUIReadOnly ?? false,
// isUIReadOnly is the deprecated alias of isUIEditable (inverted
// polarity), kept for one release; isUIEditable wins when both are set.
isUIEditable:
createFieldInput.isUIEditable ??
(isDefined(createFieldInput.isUIReadOnly)
? !createFieldInput.isUIReadOnly
: true),
morphId: null,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
objectMetadataUniversalIdentifier,
@@ -34,7 +34,8 @@ export const getFlatObjectMetadataMock = (
isRemote: false,
isSearchable: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
labelIdentifierFieldMetadataId,
labelPlural: 'default flat object metadata label plural',
labelSingular: 'default flat object metadata label singular',
@@ -78,7 +78,8 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
isLabelSyncedWithName: createObjectInput.isLabelSyncedWithName ?? false,
isRemote: createObjectInput.isRemote ?? false,
isSearchable: true,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
isSystem: false,
labelPlural: capitalize(createObjectInput.labelPlural),
labelSingular: capitalize(createObjectInput.labelSingular),
@@ -21,7 +21,8 @@ export const fromFlatObjectMetadataToObjectMetadataDto = (
isRemote,
isSearchable,
isSystem,
isUIReadOnly,
isUIEditable,
isUICreatable,
labelPlural,
labelSingular,
namePlural,
@@ -41,7 +42,9 @@ export const fromFlatObjectMetadataToObjectMetadataDto = (
isRemote,
isSearchable,
isSystem,
isUIReadOnly,
isUIEditable,
isUICreatable,
isUIReadOnly: !isUIEditable,
labelPlural,
labelSingular,
namePlural,
@@ -23,7 +23,7 @@ const PARTIAL_ID_FIELD = {
isUnique: true,
isActive: true,
isSystem: true,
isUIReadOnly: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: 'uuid',
options: null,
@@ -51,7 +51,7 @@ const PARTIAL_CREATED_AT_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isUIReadOnly: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: 'now',
options: null,
@@ -79,7 +79,7 @@ const PARTIAL_UPDATED_AT_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isUIReadOnly: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: 'now',
options: null,
@@ -107,7 +107,7 @@ const PARTIAL_DELETED_AT_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isUIReadOnly: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: null,
options: null,
@@ -135,7 +135,7 @@ const PARTIAL_CREATED_BY_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isUIReadOnly: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: { name: "''", source: "'MANUAL'" },
options: null,
@@ -163,7 +163,7 @@ const PARTIAL_UPDATED_BY_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isUIReadOnly: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: { name: "''", source: "'MANUAL'" },
options: null,
@@ -191,7 +191,7 @@ const PARTIAL_POSITION_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isUIReadOnly: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: 0,
options: null,
@@ -219,7 +219,7 @@ const PARTIAL_SEARCH_VECTOR_FIELD = {
isUnique: false,
isActive: true,
isSystem: true,
isUIReadOnly: true,
isUIEditable: false,
isLabelSyncedWithName: false,
defaultValue: null,
options: null,
@@ -0,0 +1,8 @@
// Step name of the 2.13 fast instance command that renames isUIReadOnly to
// isUIEditable (inverted polarity) and adds isUICreatable. Referenced by the
// upgrade-aware entity decorators on objectMetadata and fieldMetadata so that
// cross-version upgrades from pre-2.13 schemas keep working: before this step
// is applied, the new columns are hidden from TypeORM and the legacy
// isUIReadOnly column is still written.
export const RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME =
'2.13.0_RenameIsUiReadOnlyToIsUiEditableFastInstanceCommand_1781277453604';
@@ -78,6 +78,16 @@ export class ObjectMetadataDTO {
isSystem: boolean;
@FilterableField()
isUIEditable: boolean;
@FilterableField()
isUICreatable: boolean;
// Deprecated alias kept for one release: stays filterable so ObjectFilter
// keeps its isUIReadOnly member and external API consumers are not broken.
@FilterableField({
deprecationReason: 'Use isUIEditable',
})
isUIReadOnly: boolean;
@FilterableField()
@@ -16,7 +16,9 @@ import { type ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/obj
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { WasRemovedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-removed-in-upgrade.decorator';
import { RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME } from 'src/engine/metadata-modules/object-metadata/constants/rename-is-ui-read-only-to-is-ui-editable-upgrade-command-name.constant';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@@ -86,8 +88,26 @@ export class ObjectMetadataEntity
@Column({ default: false })
isSystem: boolean;
@Column({ default: false })
isUIReadOnly: boolean;
@WasIntroducedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
@Column({ default: true })
isUIEditable: boolean;
@WasRemovedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
@Column({ type: 'boolean', default: false })
isUIReadOnly: WasRemovedInUpgrade<boolean>;
@WasIntroducedInUpgrade({
upgradeCommandName:
RENAME_IS_UI_READ_ONLY_TO_IS_UI_EDITABLE_UPGRADE_COMMAND_NAME,
})
@Column({ default: true })
isUICreatable: boolean;
@Column({ default: true })
isAuditLogged: boolean;
@@ -63,6 +63,15 @@ export class ObjectMetadataResolver {
});
}
@ResolveField(() => Boolean, {
deprecationReason: 'Use isUIEditable',
})
async isUIReadOnly(
@Parent() objectMetadata: ObjectMetadataDTO,
): Promise<boolean> {
return !objectMetadata.isUIEditable;
}
@UseGuards(SettingsPermissionGuard(PermissionFlagType.DATA_MODEL))
@Query(() => [ObjectRecordCountDTO])
async objectRecordCounts(
@@ -22,7 +22,7 @@ const makeFieldMetadata = (
type: overrides.type,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: false,
@@ -134,7 +134,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
isNullable: true,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
defaultValue: null,
createdAt: now,
updatedAt: now,
@@ -21,7 +21,9 @@ export const fromObjectMetadataEntityToObjectMetadataDto = (
isRemote: entity.isRemote,
isActive: entity.isActive,
isSystem: entity.isSystem,
isUIReadOnly: entity.isUIReadOnly,
isUIEditable: entity.isUIEditable,
isUICreatable: entity.isUICreatable,
isUIReadOnly: !entity.isUIEditable,
isSearchable: entity.isSearchable,
isLabelSyncedWithName: entity.isLabelSyncedWithName,
workspaceId: entity.workspaceId,
@@ -120,7 +120,8 @@ describe('WorkspaceEntityManager', () => {
standardOverrides: null,
applicationId: 'test-application-id',
isLabelSyncedWithName: false,
isUIReadOnly: false,
isUIEditable: true,
isUICreatable: true,
duplicateCriteria: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -154,7 +155,7 @@ describe('WorkspaceEntityManager', () => {
icon: null,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isUIEditable: true,
isUnique: false,
options: null,
settings: null,
@@ -73,7 +73,7 @@ describe('WorkspaceRepository', () => {
isNullable: false,
isUnique: true,
isSystem: true,
isUIReadOnly: false,
isUIEditable: true,
isLabelSyncedWithName: false,
label: 'ID',
description: 'Record ID',
@@ -233,7 +233,7 @@ describe('computeRelationConnectQueryConfigs', () => {
shortcut: null,
description: null,
standardOverrides: null,
isUIReadOnly: false,
isUIEditable: true,
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
duplicateCriteria: null,

Some files were not shown because too many files have changed in this diff Show More