feat(sdk): support viewSorts in app manifests (#19881)
## Summary
Today the SDK lets apps declare `filters` on a view but not `sorts`, so
any view installed via an app manifest can never have a default
ordering. This PR adds declarative view sorts end-to-end: SDK manifest
type, `defineView` validation, CLI scaffold, and the application
install/sync pipeline that converts the manifest into the universal flat
entity used by workspace migrations. The persistence layer
(`ViewSortEntity`, resolvers, action handlers, builders…) already
existed server-side; the missing piece was the manifest → universal-flat
converter and the relation wiring on `view`.
## Changes
**`twenty-shared`**
- Add `ViewSortDirection` enum (`ASC` | `DESC`) and re-export it from
`twenty-shared/types`.
- Add `ViewSortManifest` type and an optional `sorts?:
ViewSortManifest[]` on `ViewManifest`, exported from
`twenty-shared/application`.
**`twenty-sdk`**
- Validate `sorts` entries in `defineView` (`universalIdentifier`,
`fieldMetadataUniversalIdentifier`, `direction` ∈ `ASC`/`DESC`).
- Add a commented `// sorts: [ ... ]` example to the CLI view scaffold
template + matching snapshot assertion.
**`twenty-server`**
- Re-export `ViewSortDirection` from `twenty-shared/types` in
`view-sort/enums/view-sort-direction.ts` (single source of truth,
backward compatible for existing imports).
- New converter `fromViewSortManifestToUniversalFlatViewSort` (+ unit
tests for `ASC` and `DESC`).
- Wire the converter into
`computeApplicationManifestAllUniversalFlatEntityMaps` so
`viewManifest.sorts` are added to `flatViewSortMaps`, mirroring how
filters are processed.
- Replace the `// @ts-expect-error TODO migrate viewSort to v2 /
viewSorts: null` placeholder in `ALL_ONE_TO_MANY_METADATA_RELATIONS`
with the proper relation (`viewSortIds` /
`viewSortUniversalIdentifiers`).
- Update affected snapshots (`get-metadata-related-metadata-names`,
`all-universal-flat-entity-foreign-key-aggregator-properties`).
## Example usage
\`\`\`ts
defineView({
name: 'All issues',
objectUniversalIdentifier: 'issue',
sorts: [
{
universalIdentifier: 'all-issues__sort-created-at',
fieldMetadataUniversalIdentifier: 'createdAt',
direction: 'DESC',
},
],
});
\`\`\`
This commit is contained in:
+48
@@ -0,0 +1,48 @@
|
||||
import { ViewSortDirection } from 'twenty-shared/types';
|
||||
|
||||
import { fromViewSortManifestToUniversalFlatViewSort } from 'src/engine/core-modules/application/application-manifest/converters/from-view-sort-manifest-to-universal-flat-view-sort.util';
|
||||
|
||||
describe('fromViewSortManifestToUniversalFlatViewSort', () => {
|
||||
const now = '2026-01-01T00:00:00.000Z';
|
||||
const applicationUniversalIdentifier = 'app-uuid-1';
|
||||
const viewUniversalIdentifier = 'view-uuid-1';
|
||||
|
||||
it('should convert a view sort manifest with ASC direction', () => {
|
||||
const result = fromViewSortManifestToUniversalFlatViewSort({
|
||||
viewSortManifest: {
|
||||
universalIdentifier: 'vsort-uuid-1',
|
||||
fieldMetadataUniversalIdentifier: 'field-uuid-1',
|
||||
direction: ViewSortDirection.ASC,
|
||||
},
|
||||
viewUniversalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.universalIdentifier).toBe('vsort-uuid-1');
|
||||
expect(result.fieldMetadataUniversalIdentifier).toBe('field-uuid-1');
|
||||
expect(result.viewUniversalIdentifier).toBe(viewUniversalIdentifier);
|
||||
expect(result.applicationUniversalIdentifier).toBe(
|
||||
applicationUniversalIdentifier,
|
||||
);
|
||||
expect(result.direction).toBe(ViewSortDirection.ASC);
|
||||
expect(result.createdAt).toBe(now);
|
||||
expect(result.updatedAt).toBe(now);
|
||||
expect(result.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should convert a view sort manifest with DESC direction', () => {
|
||||
const result = fromViewSortManifestToUniversalFlatViewSort({
|
||||
viewSortManifest: {
|
||||
universalIdentifier: 'vsort-uuid-2',
|
||||
fieldMetadataUniversalIdentifier: 'field-uuid-2',
|
||||
direction: ViewSortDirection.DESC,
|
||||
},
|
||||
viewUniversalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.direction).toBe(ViewSortDirection.DESC);
|
||||
});
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type ViewSortManifest } from 'twenty-shared/application';
|
||||
|
||||
import { type UniversalFlatViewSort } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-sort.type';
|
||||
|
||||
export const fromViewSortManifestToUniversalFlatViewSort = ({
|
||||
viewSortManifest,
|
||||
viewUniversalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
}: {
|
||||
viewSortManifest: ViewSortManifest;
|
||||
viewUniversalIdentifier: string;
|
||||
applicationUniversalIdentifier: string;
|
||||
now: string;
|
||||
}): UniversalFlatViewSort => {
|
||||
return {
|
||||
universalIdentifier: viewSortManifest.universalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
viewSortManifest.fieldMetadataUniversalIdentifier,
|
||||
viewUniversalIdentifier,
|
||||
direction: viewSortManifest.direction,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
};
|
||||
};
|
||||
+14
@@ -25,6 +25,7 @@ import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/e
|
||||
import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-manifest-to-universal-flat-view-filter.util';
|
||||
import { fromViewGroupManifestToUniversalFlatViewGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-group-manifest-to-universal-flat-view-group.util';
|
||||
import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/application-manifest/converters/from-view-manifest-to-universal-flat-view.util';
|
||||
import { fromViewSortManifestToUniversalFlatViewSort } from 'src/engine/core-modules/application/application-manifest/converters/from-view-sort-manifest-to-universal-flat-view-sort.util';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { fromAgentManifestToUniversalFlatAgent } from 'src/engine/core-modules/application/utils/from-agent-manifest-to-universal-flat-agent.util';
|
||||
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
|
||||
@@ -325,6 +326,19 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
|
||||
allUniversalFlatEntityMaps.flatViewGroupMaps,
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewSortManifest of viewManifest.sorts ?? []) {
|
||||
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
|
||||
universalFlatEntity: fromViewSortManifestToUniversalFlatViewSort({
|
||||
viewSortManifest,
|
||||
viewUniversalIdentifier: viewManifest.universalIdentifier,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
}),
|
||||
universalFlatEntityMapsToMutate:
|
||||
allUniversalFlatEntityMaps.flatViewSortMaps,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const navigationMenuItemManifest of manifest.navigationMenuItems ?? []) {
|
||||
|
||||
Reference in New Issue
Block a user