From fb4a22c9d6a3d2141838c7a8c9fc24bae051407a Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 7 Sep 2026 12:56:28 -0400 Subject: [PATCH 01/11] ci: bump PHP to 8.3 to match Nextcloud master requirement Signed-off-by: YvesCesar --- .github/workflows/behat-sqlite.yml | 6 +++--- .github/workflows/playwright.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/behat-sqlite.yml b/.github/workflows/behat-sqlite.yml index fe76835b..eb41f9e7 100644 --- a/.github/workflows/behat-sqlite.yml +++ b/.github/workflows/behat-sqlite.yml @@ -57,7 +57,7 @@ jobs: - 1025:1025 - 8025:8025 - name: SQLite PHP 8.2 Nextcloud master + name: SQLite PHP 8.3 Nextcloud master steps: - name: Checkout server @@ -74,10 +74,10 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} - - name: Set up php 8.2 + - name: Set up php 8.3 uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: - php-version: '8.2' + php-version: '8.3' extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sockets, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib coverage: none ini-file: development diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 4912adf0..5f4d1812 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -74,10 +74,10 @@ jobs: persist-credentials: false path: apps/${{ env.APP_NAME }} - - name: Set up php 8.2 + - name: Set up php 8.3 uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 with: - php-version: '8.2' + php-version: '8.3' extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, sqlite, pdo_sqlite, xmlreader, xmlwriter, zip, zlib coverage: none ini-file: development From 05108a0057ae3b1141eda125b875feb68e546358 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 7 Sep 2026 13:22:13 -0400 Subject: [PATCH 02/11] fix(test): assert field reorder by rendered order instead of geometry Signed-off-by: YvesCesar --- playwright/e2e/profile-fields.spec.ts | 33 ++++++++++----------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/playwright/e2e/profile-fields.spec.ts b/playwright/e2e/profile-fields.spec.ts index a6018f0a..a41e5f3e 100644 --- a/playwright/e2e/profile-fields.spec.ts +++ b/playwright/e2e/profile-fields.spec.ts @@ -187,33 +187,24 @@ test('admin can reorder field definitions by dragging the list handles', async ( const firstHandle = page.getByTestId(`profile-fields-admin-definition-handle-${firstFieldKey}`) const secondHandle = page.getByTestId(`profile-fields-admin-definition-handle-${secondFieldKey}`) - const verticalOrder = async() => { - const firstBox = await firstHandle.boundingBox() - const secondBox = await secondHandle.boundingBox() - expect(firstBox).not.toBeNull() - expect(secondBox).not.toBeNull() - return { - firstY: firstBox!.y, - secondY: secondBox!.y, - } - } + const draggedOrder = async() => (await page + .locator('.profile-fields-admin__list .profile-fields-admin__list-item-subname') + .allTextContents()) + .filter((listedKey) => listedKey === firstFieldKey || listedKey === secondFieldKey) - let order = await verticalOrder() - expect(order.firstY).toBeLessThan(order.secondY) + await expect.poll(draggedOrder).toEqual([firstFieldKey, secondFieldKey]) - await secondHandle.dragTo(firstHandle) + await secondHandle.hover() + await page.mouse.down() + await firstHandle.hover() + await firstHandle.hover() + await page.mouse.up() - await expect.poll(verticalOrder).toEqual(expect.objectContaining({ - firstY: expect.any(Number), - secondY: expect.any(Number), - })) - order = await verticalOrder() - expect(order.secondY).toBeLessThan(order.firstY) + await expect.poll(draggedOrder).toEqual([secondFieldKey, firstFieldKey]) await page.reload() await expect(page.getByTestId('profile-fields-admin')).toBeVisible() - order = await verticalOrder() - expect(order.secondY).toBeLessThan(order.firstY) + await expect.poll(draggedOrder).toEqual([secondFieldKey, firstFieldKey]) } finally { await deleteDefinitionByFieldKey(page.request, firstFieldKey) await deleteDefinitionByFieldKey(page.request, secondFieldKey) From b95928d77cd1ee1e9153648a04053f9ede3086f4 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 7 Sep 2026 13:38:00 -0400 Subject: [PATCH 03/11] fix(migration): set column attributes instead of the removed changeColumn Signed-off-by: YvesCesar --- .../Version1001Date20260404010000.php | 6 +- .../Version1001Date20260404010000Test.php | 118 ++++++++++++++++++ 2 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 tests/php/Unit/Migration/Version1001Date20260404010000Test.php diff --git a/lib/Migration/Version1001Date20260404010000.php b/lib/Migration/Version1001Date20260404010000.php index fb219a19..6d8637c1 100644 --- a/lib/Migration/Version1001Date20260404010000.php +++ b/lib/Migration/Version1001Date20260404010000.php @@ -39,10 +39,8 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt return null; } - $table->changeColumn('active', [ - 'notnull' => false, - 'default' => true, - ]); + $activeColumn->setNotnull(false); + $activeColumn->setDefault(true); return $schema; } diff --git a/tests/php/Unit/Migration/Version1001Date20260404010000Test.php b/tests/php/Unit/Migration/Version1001Date20260404010000Test.php new file mode 100644 index 00000000..5ff4e0bf --- /dev/null +++ b/tests/php/Unit/Migration/Version1001Date20260404010000Test.php @@ -0,0 +1,118 @@ +schema->hasTable($this->prefix . $tableName); + } + + public function createTable($tableName): Table { + return $this->schema->createTable($this->prefix . $tableName); + } + + public function getTable($tableName): Table { + return $this->schema->getTable($this->prefix . $tableName); + } + + public function dropTable($tableName): Schema { + return $this->schema->dropTable($this->prefix . $tableName); + } + + public function getTables(): array { + return $this->schema->getTables(); + } + + public function getTableNames(): array { + return $this->schema->getTableNames(); + } + + public function getTableNamesWithoutPrefix(): array { + return array_map( + fn (string $name) => substr($name, strlen($this->prefix)), + $this->schema->getTableNames(), + ); + } + + public function getDatabasePlatform(): AbstractPlatform { + throw new \RuntimeException('not implemented in test'); + } + + public function dropAutoincrementColumn(string $table, string $column): void { + throw new \RuntimeException('not implemented in test'); + } + }; + } + + private function runMigration(Schema $schema): ?ISchemaWrapper { + $wrapper = $this->buildSchemaWrapper($schema); + $migration = new Version1001Date20260404010000(); + + return $migration->changeSchema( + $this->createMock(IOutput::class), + fn () => $wrapper, + ['tablePrefix' => self::PREFIX], + ); + } + + private function schemaWithActiveColumn(bool $notnull): Schema { + $schema = new Schema(); + $table = $schema->createTable(self::PREFIX . self::TABLE); + $table->addColumn('active', 'boolean', ['notnull' => $notnull, 'default' => true]); + + return $schema; + } + + public function testMakesTheActiveColumnNullableKeepingTheDefault(): void { + $schema = $this->schemaWithActiveColumn(true); + + $this->assertNotNull($this->runMigration($schema)); + + $column = $schema->getTable(self::PREFIX . self::TABLE)->getColumn('active'); + $this->assertFalse($column->getNotnull()); + $this->assertTrue($column->getDefault()); + } + + public function testSkipsWhenTheTableIsMissing(): void { + $this->assertNull($this->runMigration(new Schema())); + } + + public function testSkipsWhenTheColumnIsMissing(): void { + $schema = new Schema(); + $schema->createTable(self::PREFIX . self::TABLE)->addColumn('id', 'integer'); + + $this->assertNull($this->runMigration($schema)); + } + + public function testSkipsWhenTheColumnIsAlreadyNullable(): void { + $schema = $this->schemaWithActiveColumn(false); + + $this->assertNull($this->runMigration($schema)); + } +} From e2954b924a970981aa4a5ed9423e7f7ebf4b56e8 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 7 Sep 2026 13:38:27 -0400 Subject: [PATCH 04/11] ci: keep the Doctrine docblock suppression working against every supported branch Signed-off-by: YvesCesar --- psalm.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/psalm.xml b/psalm.xml index 22e35d0e..fae68114 100644 --- a/psalm.xml +++ b/psalm.xml @@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later errorLevel="5" findUnusedBaselineEntry="true" findUnusedCode="false" + findUnusedIssueHandlerSuppression="false" resolveFromConfigFile="true" phpVersion="8.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" From 0707525c80a0c8186c25836d397e11e0ec9b12aa Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 7 Sep 2026 13:49:54 -0400 Subject: [PATCH 05/11] test(migration): build the schema wrapper from the installed ISchemaWrapper Signed-off-by: YvesCesar --- .../Unit/Migration/BuildsSchemaWrapper.php | 135 ++++++++++++++++++ .../Version1000Date20260309120000Test.php | 54 +------ .../Version1001Date20260404010000Test.php | 56 +------- 3 files changed, 140 insertions(+), 105 deletions(-) create mode 100644 tests/php/Unit/Migration/BuildsSchemaWrapper.php diff --git a/tests/php/Unit/Migration/BuildsSchemaWrapper.php b/tests/php/Unit/Migration/BuildsSchemaWrapper.php new file mode 100644 index 00000000..c3451fe3 --- /dev/null +++ b/tests/php/Unit/Migration/BuildsSchemaWrapper.php @@ -0,0 +1,135 @@ + */ + private array $wrappedTables = []; + + /** @var array */ + private array $doctrineTables = []; + + private function usesTypedSchemaApi(): bool { + return interface_exists(ITable::class); + } + + private function buildSchemaWrapper(Schema $schema, string $prefix): ISchemaWrapper { + $wrapper = $this->createMock(ISchemaWrapper::class); + $wrapper->method('hasTable') + ->willReturnCallback(fn (string $tableName): bool => $schema->hasTable($prefix . $tableName)); + $wrapper->method('createTable') + ->willReturnCallback(fn (string $tableName) => $this->wrapTable($schema->createTable($prefix . $tableName))); + $wrapper->method('getTable') + ->willReturnCallback(fn (string $tableName) => $this->wrapTable($schema->getTable($prefix . $tableName))); + + return $wrapper; + } + + private function wrapTable(Table $table): mixed { + if (!$this->usesTypedSchemaApi()) { + return $table; + } + + $tableName = $table->getName(); + if (isset($this->wrappedTables[$tableName])) { + return $this->wrappedTables[$tableName]; + } + + $wrapped = $this->createMock(ITable::class); + $this->wrappedTables[$tableName] = $wrapped; + $this->doctrineTables[spl_object_id($wrapped)] = $table; + + $wrapped->method('getName') + ->willReturnCallback(fn (): string => $table->getName()); + $wrapped->method('hasColumn') + ->willReturnCallback(fn (string $name): bool => $table->hasColumn($name)); + $wrapped->method('getColumn') + ->willReturnCallback(fn (string $name) => $this->wrapColumn($table->getColumn($name))); + $wrapped->method('addColumn') + ->willReturnCallback(function (string $name, $typeName, array $options = []) use ($table) { + return $this->wrapColumn($table->addColumn($name, is_string($typeName) ? $typeName : $typeName->value, $options)); + }); + $wrapped->method('setPrimaryKey') + ->willReturnCallback(function (array $columnNames, string|false $indexName = false) use ($table, $wrapped) { + $table->setPrimaryKey($columnNames, $indexName); + + return $wrapped; + }); + $wrapped->method('addIndex') + ->willReturnCallback(function (array $columnNames, ?string $indexName = null, array $flags = [], array $options = []) use ($table, $wrapped) { + $table->addIndex($columnNames, $indexName, $flags, $options); + + return $wrapped; + }); + $wrapped->method('addUniqueIndex') + ->willReturnCallback(function (array $columnNames, ?string $indexName = null, array $options = []) use ($table, $wrapped) { + $table->addUniqueIndex($columnNames, $indexName, $options); + + return $wrapped; + }); + $wrapped->method('addForeignKeyConstraint') + ->willReturnCallback(function ($foreignTable, array $localColumnNames, array $foreignColumnNames, array $options = [], ?string $name = null) use ($table, $wrapped) { + $table->addForeignKeyConstraint( + is_object($foreignTable) ? ($this->doctrineTables[spl_object_id($foreignTable)] ?? $foreignTable) : $foreignTable, + $localColumnNames, + $foreignColumnNames, + $options, + $name, + ); + + return $wrapped; + }); + + return $wrapped; + } + + private function wrapColumn(Column $column): mixed { + if (!$this->usesTypedSchemaApi()) { + return $column; + } + + $wrapped = $this->createMock(IColumn::class); + $wrapped->method('getName') + ->willReturnCallback(fn (): string => $column->getName()); + $wrapped->method('getNotnull') + ->willReturnCallback(fn (): bool => $column->getNotnull()); + $wrapped->method('getDefault') + ->willReturnCallback(fn () => $column->getDefault()); + $wrapped->method('setNotnull') + ->willReturnCallback(function (bool $notnull) use ($column, $wrapped) { + $column->setNotnull($notnull); + + return $wrapped; + }); + $wrapped->method('setDefault') + ->willReturnCallback(function ($default) use ($column, $wrapped) { + $column->setDefault($default); + + return $wrapped; + }); + + return $wrapped; + } +} diff --git a/tests/php/Unit/Migration/Version1000Date20260309120000Test.php b/tests/php/Unit/Migration/Version1000Date20260309120000Test.php index 982a3cae..943d2c15 100644 --- a/tests/php/Unit/Migration/Version1000Date20260309120000Test.php +++ b/tests/php/Unit/Migration/Version1000Date20260309120000Test.php @@ -9,11 +9,8 @@ namespace OCA\ProfileFields\Tests\Unit\Migration; -use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\Schema; -use Doctrine\DBAL\Schema\Table; use OCA\ProfileFields\Migration\Version1000Date20260309120000; -use OCP\DB\ISchemaWrapper; use OCP\Migration\IOutput; use PHPUnit\Framework\TestCase; @@ -29,6 +26,8 @@ * otherwise explicit name must be <= 30 chars */ class Version1000Date20260309120000Test extends TestCase { + use BuildsSchemaWrapper; + private const PREFIX = 'oc_'; private const ORACLE_MAX_NAME = 30; private const ORACLE_MAX_TABLE = 27; @@ -42,55 +41,6 @@ protected function setUp(): void { $this->schema = new Schema(); } - private function buildSchemaWrapper(Schema $schema, string $prefix): ISchemaWrapper { - return new class($schema, $prefix) implements ISchemaWrapper { - public function __construct( - private Schema $schema, - private string $prefix, - ) { - } - - public function hasTable($tableName): bool { - return $this->schema->hasTable($this->prefix . $tableName); - } - - public function createTable($tableName): Table { - return $this->schema->createTable($this->prefix . $tableName); - } - - public function getTable($tableName): Table { - return $this->schema->getTable($this->prefix . $tableName); - } - - public function dropTable($tableName): Schema { - return $this->schema->dropTable($this->prefix . $tableName); - } - - public function getTables(): array { - return $this->schema->getTables(); - } - - public function getTableNames(): array { - return $this->schema->getTableNames(); - } - - public function getTableNamesWithoutPrefix(): array { - return array_map( - fn (string $n) => substr($n, strlen($this->prefix)), - $this->schema->getTableNames(), - ); - } - - public function getDatabasePlatform(): AbstractPlatform { - throw new \RuntimeException('not implemented in test'); - } - - public function dropAutoincrementColumn(string $table, string $column): void { - throw new \RuntimeException('not implemented in test'); - } - }; - } - public function testAllNamesPassOracleConstraints(): void { $wrapper = $this->buildSchemaWrapper($this->schema, self::PREFIX); $output = $this->createMock(IOutput::class); diff --git a/tests/php/Unit/Migration/Version1001Date20260404010000Test.php b/tests/php/Unit/Migration/Version1001Date20260404010000Test.php index 5ff4e0bf..ddd6a872 100644 --- a/tests/php/Unit/Migration/Version1001Date20260404010000Test.php +++ b/tests/php/Unit/Migration/Version1001Date20260404010000Test.php @@ -9,74 +9,24 @@ namespace OCA\ProfileFields\Tests\Unit\Migration; -use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\Schema; -use Doctrine\DBAL\Schema\Table; use OCA\ProfileFields\Migration\Version1001Date20260404010000; use OCP\DB\ISchemaWrapper; use OCP\Migration\IOutput; use PHPUnit\Framework\TestCase; class Version1001Date20260404010000Test extends TestCase { + use BuildsSchemaWrapper; + private const PREFIX = 'oc_'; private const TABLE = 'profile_fields_definitions'; - private function buildSchemaWrapper(Schema $schema): ISchemaWrapper { - return new class($schema, self::PREFIX) implements ISchemaWrapper { - public function __construct( - private Schema $schema, - private string $prefix, - ) { - } - - public function hasTable($tableName): bool { - return $this->schema->hasTable($this->prefix . $tableName); - } - - public function createTable($tableName): Table { - return $this->schema->createTable($this->prefix . $tableName); - } - - public function getTable($tableName): Table { - return $this->schema->getTable($this->prefix . $tableName); - } - - public function dropTable($tableName): Schema { - return $this->schema->dropTable($this->prefix . $tableName); - } - - public function getTables(): array { - return $this->schema->getTables(); - } - - public function getTableNames(): array { - return $this->schema->getTableNames(); - } - - public function getTableNamesWithoutPrefix(): array { - return array_map( - fn (string $name) => substr($name, strlen($this->prefix)), - $this->schema->getTableNames(), - ); - } - - public function getDatabasePlatform(): AbstractPlatform { - throw new \RuntimeException('not implemented in test'); - } - - public function dropAutoincrementColumn(string $table, string $column): void { - throw new \RuntimeException('not implemented in test'); - } - }; - } - private function runMigration(Schema $schema): ?ISchemaWrapper { - $wrapper = $this->buildSchemaWrapper($schema); $migration = new Version1001Date20260404010000(); return $migration->changeSchema( $this->createMock(IOutput::class), - fn () => $wrapper, + fn () => $this->buildSchemaWrapper($schema, self::PREFIX), ['tablePrefix' => self::PREFIX], ); } From c942d21e160a63ba996e13030fbbd6bd3e6f2b4b Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 7 Sep 2026 14:12:32 -0400 Subject: [PATCH 06/11] fix(settings): follow the core personal section renamed in Nextcloud 35 Signed-off-by: YvesCesar --- playwright/e2e/profile-fields.spec.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/playwright/e2e/profile-fields.spec.ts b/playwright/e2e/profile-fields.spec.ts index a41e5f3e..27c4db00 100644 --- a/playwright/e2e/profile-fields.spec.ts +++ b/playwright/e2e/profile-fields.spec.ts @@ -10,6 +10,21 @@ const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' const optionInput = (page, index: number) => page.getByTestId(`profile-fields-admin-option-row-${index}`).locator('input') +const openPersonalSettings = async(page) => { + for (const section of ['profile-contact', 'personal-info']) { + await page.goto(`./settings/user/${section}`) + try { + await page.getByTestId('profile-fields-personal').waitFor({ state: 'visible', timeout: 10_000 }) + + return + } catch { + continue + } + } + + throw new Error('Could not find the personal profile fields section') +} + const chooseFieldType = async(page, label: 'Text' | 'Number' | 'Select') => { await page.getByTestId('profile-fields-admin-type-select').click() await page.getByRole('option', { name: label, exact: true }).click() @@ -30,8 +45,8 @@ const collectEmbeddedLayoutMetrics = async(page, fieldKey: string) => { const customInput = page.getByTestId(`profile-fields-personal-input-${fieldKey}`) const embeddedShell = page.locator('#profile-fields-personal-info-shell') - await aboutInput.scrollIntoViewIfNeeded() - await customInput.scrollIntoViewIfNeeded() + await aboutInput.scrollIntoViewIfNeeded({ timeout: 10_000 }) + await customInput.scrollIntoViewIfNeeded({ timeout: 10_000 }) const aboutBox = await aboutInput.boundingBox() const customFieldBox = await customField.boundingBox() @@ -470,7 +485,7 @@ test('embedded personal settings autosave a user-visible field', async ({ page } }) try { - await page.goto('./settings/user/personal-info') + await openPersonalSettings(page) const fieldCard = page.getByTestId(`profile-fields-personal-field-${fieldKey}`) const fieldInput = page.getByTestId(`profile-fields-personal-input-${fieldKey}`) const visibilityPanel = page.getByTestId('profile-fields-personal-visibility-panel') From e51c7b4052f38879f74a783f6d6de5e12ef38928 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 7 Sep 2026 14:18:07 -0400 Subject: [PATCH 07/11] fix(settings): follow the core personal section renamed in Nextcloud 35 Signed-off-by: YvesCesar --- lib/Settings/PersonalInfo.php | 8 +++++++- tests/php/Unit/Settings/PersonalTest.php | 25 ++++++++++++++++++++---- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/Settings/PersonalInfo.php b/lib/Settings/PersonalInfo.php index ce093fc0..d11a6e14 100644 --- a/lib/Settings/PersonalInfo.php +++ b/lib/Settings/PersonalInfo.php @@ -14,6 +14,8 @@ use OCP\Settings\ISettings; class PersonalInfo implements ISettings { + private const RENAMED_CORE_SECTION = 'OCA\\Settings\\Sections\\Personal\\ProfileContact'; + #[\Override] public function getForm(): TemplateResponse { return new TemplateResponse(Application::APP_ID, 'settings-personal-info'); @@ -21,11 +23,15 @@ public function getForm(): TemplateResponse { #[\Override] public function getSection(): string { - return 'personal-info'; + return $this->coreSectionExists(self::RENAMED_CORE_SECTION) ? 'profile-contact' : 'personal-info'; } #[\Override] public function getPriority(): int { return 80; } + + protected function coreSectionExists(string $className): bool { + return class_exists($className); + } } diff --git a/tests/php/Unit/Settings/PersonalTest.php b/tests/php/Unit/Settings/PersonalTest.php index 6b861961..0fa0165e 100644 --- a/tests/php/Unit/Settings/PersonalTest.php +++ b/tests/php/Unit/Settings/PersonalTest.php @@ -15,8 +15,22 @@ use PHPUnit\Framework\TestCase; class PersonalTest extends TestCase { + private function buildSettings(bool $coreSectionRenamed): PersonalInfo { + return new class($coreSectionRenamed) extends PersonalInfo { + public function __construct( + private bool $coreSectionRenamed, + ) { + } + + #[\Override] + protected function coreSectionExists(string $className): bool { + return $this->coreSectionRenamed; + } + }; + } + public function testGetFormReturnsPersonalTemplate(): void { - $settings = new PersonalInfo(); + $settings = $this->buildSettings(true); $response = $settings->getForm(); @@ -26,9 +40,12 @@ public function testGetFormReturnsPersonalTemplate(): void { } public function testSettingsMetadataMatchesPersonalSection(): void { - $settings = new PersonalInfo(); + $this->assertSame('personal-info', $this->buildSettings(false)->getSection()); + $this->assertSame(80, $this->buildSettings(false)->getPriority()); + } - $this->assertSame('personal-info', $settings->getSection()); - $this->assertSame(80, $settings->getPriority()); + public function testSectionFollowsTheCoreSectionRename(): void { + $this->assertSame('personal-info', $this->buildSettings(false)->getSection()); + $this->assertSame('profile-contact', $this->buildSettings(true)->getSection()); } } From 7f575f391950bb0e904d8c2204199aea9dfb03c3 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 8 Sep 2026 15:26:14 -0400 Subject: [PATCH 08/11] test(settings): drop the personal section metadata test duplicated in PersonalTest Signed-off-by: YvesCesar --- .../php/Unit/Settings/PersonalSectionTest.php | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 tests/php/Unit/Settings/PersonalSectionTest.php diff --git a/tests/php/Unit/Settings/PersonalSectionTest.php b/tests/php/Unit/Settings/PersonalSectionTest.php deleted file mode 100644 index 3293fc20..00000000 --- a/tests/php/Unit/Settings/PersonalSectionTest.php +++ /dev/null @@ -1,22 +0,0 @@ -assertSame('personal-info', $section->getSection()); - $this->assertSame(80, $section->getPriority()); - } -} From ea9806955989a32bda0f59d77e94cab95b33e21f Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 8 Sep 2026 15:26:14 -0400 Subject: [PATCH 09/11] fix(settings): fit the embedded fields into the personal section renamed in Nextcloud 35 Signed-off-by: YvesCesar --- playwright/e2e/profile-fields.spec.ts | 13 +- src/settings-personal.ts | 117 +------------ .../utils/embeddedShellPlacement.spec.ts | 92 ++++++++++ src/utils/embeddedShellPlacement.ts | 165 ++++++++++++++++++ src/views/PersonalSettings.vue | 28 +++ 5 files changed, 294 insertions(+), 121 deletions(-) create mode 100644 src/tests/utils/embeddedShellPlacement.spec.ts create mode 100644 src/utils/embeddedShellPlacement.ts diff --git a/playwright/e2e/profile-fields.spec.ts b/playwright/e2e/profile-fields.spec.ts index 27c4db00..2a110343 100644 --- a/playwright/e2e/profile-fields.spec.ts +++ b/playwright/e2e/profile-fields.spec.ts @@ -16,7 +16,7 @@ const openPersonalSettings = async(page) => { try { await page.getByTestId('profile-fields-personal').waitFor({ state: 'visible', timeout: 10_000 }) - return + return section } catch { continue } @@ -85,6 +85,7 @@ const expectEmbeddedLayoutAtWidth = async( expect(Math.abs(aboutBox.width - customInputBox.width)).toBeLessThanOrEqual(6) expect(customFieldBox.y).toBeGreaterThanOrEqual(aboutBottom - 1) + expect(embeddedShellBox.width).toBeGreaterThanOrEqual(aboutBox.width - 1) if (expectExpandedShell) { expect(embeddedShellBox.width).toBeGreaterThan(aboutBox.width + 100) } @@ -485,7 +486,7 @@ test('embedded personal settings autosave a user-visible field', async ({ page } }) try { - await openPersonalSettings(page) + const section = await openPersonalSettings(page) const fieldCard = page.getByTestId(`profile-fields-personal-field-${fieldKey}`) const fieldInput = page.getByTestId(`profile-fields-personal-input-${fieldKey}`) const visibilityPanel = page.getByTestId('profile-fields-personal-visibility-panel') @@ -509,10 +510,12 @@ test('embedded personal settings autosave a user-visible field', async ({ page } await expect(page.getByTestId(`profile-fields-personal-visibility-${fieldKey}`)).toBeVisible() await expect(page.getByTestId(`profile-fields-personal-visibility-${fieldKey}`)).toContainText('Hide') + const spansTheGrid = section === 'personal-info' + for (const viewport of [ - { width: 1400, expectExpandedShell: true }, - { width: 1200, expectExpandedShell: true }, - { width: 900, expectExpandedShell: true }, + { width: 1400, expectExpandedShell: spansTheGrid }, + { width: 1200, expectExpandedShell: spansTheGrid }, + { width: 900, expectExpandedShell: spansTheGrid }, { width: 640, expectExpandedShell: false }, ]) { await expectEmbeddedLayoutAtWidth(page, fieldKey, viewport) diff --git a/src/settings-personal.ts b/src/settings-personal.ts index 1f9e5139..c8f81e99 100644 --- a/src/settings-personal.ts +++ b/src/settings-personal.ts @@ -5,125 +5,10 @@ import 'vite/modulepreload-polyfill' import { translate as t } from '@nextcloud/l10n' import { createApp } from 'vue' +import { prepareEmbeddedPersonalInfoShell } from './utils/embeddedShellPlacement' import PersonalSettings from './views/PersonalSettings.vue' const PERSONAL_INFO_SELECTOR = '#profile-fields-personal-info-settings' -const EMBEDDED_VISIBILITY_ANCHOR_ID = 'profile-fields-personal-visibility-anchor' - -let embeddedShellPlacementFrame = 0 -let embeddedShellResizeDebounceTimer = 0 -let embeddedVisibilityAnchorObserver: MutationObserver | null = null -let observedProfileVisibilityRoot: HTMLElement | null = null - -const personalSettingsUsesMultipleColumns = (personalSettings: HTMLElement) => { - const gridTemplateColumns = window.getComputedStyle(personalSettings).gridTemplateColumns - if (gridTemplateColumns === '') { - return personalSettings.getBoundingClientRect().width >= 900 - } - - return gridTemplateColumns - .split(' ') - .map((value) => value.trim()) - .filter(Boolean) - .length > 1 -} - -const ensureEmbeddedVisibilityAnchor = () => { - const profileVisibilityRoot = document.querySelector('#profile-visibility') - if (profileVisibilityRoot === null) { - return - } - - if (observedProfileVisibilityRoot !== profileVisibilityRoot) { - embeddedVisibilityAnchorObserver?.disconnect() - embeddedVisibilityAnchorObserver = new MutationObserver(() => { - ensureEmbeddedVisibilityAnchor() - }) - embeddedVisibilityAnchorObserver.observe(profileVisibilityRoot, { childList: true }) - observedProfileVisibilityRoot = profileVisibilityRoot - } - - let anchor = profileVisibilityRoot.querySelector(`#${EMBEDDED_VISIBILITY_ANCHOR_ID}`) - if (anchor !== null) { - return - } - - anchor = document.createElement('div') - anchor.id = EMBEDDED_VISIBILITY_ANCHOR_ID - anchor.className = 'profile-fields-personal-info-visibility-anchor' - const insertionTarget = profileVisibilityRoot.querySelector('.visibility-dropdowns') - if (insertionTarget !== null) { - insertionTarget.appendChild(anchor) - } else { - profileVisibilityRoot.appendChild(anchor) - } - window.dispatchEvent(new CustomEvent('profile-fields:embedded-visibility-anchor-ready')) -} - -const scheduleEmbeddedPersonalInfoShellPlacement = () => { - if (embeddedShellPlacementFrame !== 0) { - window.cancelAnimationFrame(embeddedShellPlacementFrame) - } - - embeddedShellPlacementFrame = window.requestAnimationFrame(() => { - embeddedShellPlacementFrame = 0 - syncEmbeddedPersonalInfoShellPlacement() - }) -} - -const scheduleEmbeddedPersonalInfoShellPlacementAfterResize = () => { - if (embeddedShellResizeDebounceTimer !== 0) { - window.clearTimeout(embeddedShellResizeDebounceTimer) - } - - embeddedShellResizeDebounceTimer = window.setTimeout(() => { - embeddedShellResizeDebounceTimer = 0 - scheduleEmbeddedPersonalInfoShellPlacement() - }, 140) -} - -const syncEmbeddedPersonalInfoShellPlacement = () => { - const shell = document.querySelector('#profile-fields-personal-info-shell') - const personalSettings = document.querySelector('#personal-settings') - const profileVisibilitySection = document.querySelector('#profile-visibility')?.closest('.personal-settings-section') as HTMLElement | null - if (shell === null || personalSettings === null) { - return - } - - ensureEmbeddedVisibilityAnchor() - - const useGridPlacement = personalSettingsUsesMultipleColumns(personalSettings) - personalSettings.classList.toggle('profile-fields-personal-info-grid', useGridPlacement) - personalSettings.classList.toggle('profile-fields-personal-info-stacked', !useGridPlacement) - shell.classList.toggle('personal-settings-setting-box', useGridPlacement) - shell.classList.toggle('personal-settings-section', !useGridPlacement) - shell.classList.toggle('profile-fields-personal-info-box--stacked', !useGridPlacement) - - if (useGridPlacement) { - const insertionTarget = personalSettings.querySelector('#personal-settings-group-container') - ?? personalSettings.querySelector('.msg') - - if (insertionTarget !== null) { - personalSettings.insertBefore(shell, insertionTarget) - } else { - personalSettings.appendChild(shell) - } - } else if (profileVisibilitySection !== null && profileVisibilitySection.parentNode !== null) { - profileVisibilitySection.parentNode.insertBefore(shell, profileVisibilitySection) - } else if (personalSettings.parentNode !== null) { - personalSettings.parentNode.insertBefore(shell, personalSettings.nextSibling) - } - - shell.style.marginTop = '' - - shell.dataset.profileFieldsEmbeddedReady = 'true' -} - -const prepareEmbeddedPersonalInfoShell = () => { - scheduleEmbeddedPersonalInfoShellPlacement() - window.addEventListener('resize', scheduleEmbeddedPersonalInfoShellPlacementAfterResize, { passive: true }) - window.addEventListener('load', scheduleEmbeddedPersonalInfoShellPlacement, { once: true, passive: true }) -} const ensureEmbeddedProfileAnchor = () => { const profileSection = document.querySelector('#vue-profile-section section') diff --git a/src/tests/utils/embeddedShellPlacement.spec.ts b/src/tests/utils/embeddedShellPlacement.spec.ts new file mode 100644 index 00000000..832e2f65 --- /dev/null +++ b/src/tests/utils/embeddedShellPlacement.spec.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const flushPlacement = async() => { + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +const shell = () => document.querySelector('#profile-fields-personal-info-shell') + +const renderShell = (layout: string) => { + document.body.innerHTML = ` + ${layout} +
+
+
+ ` +} + +const prepare = async() => { + const { prepareEmbeddedPersonalInfoShell } = await import('../../utils/embeddedShellPlacement') + + prepareEmbeddedPersonalInfoShell() + await flushPlacement() +} + +describe('embeddedShellPlacement', () => { + beforeEach(() => { + vi.resetModules() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0) as unknown as number) + vi.stubGlobal('cancelAnimationFrame', (handle: number) => clearTimeout(handle)) + }) + + afterEach(() => { + vi.unstubAllGlobals() + document.body.innerHTML = '' + }) + + it('joins the single column section introduced in Nextcloud 35', async() => { + renderShell('
') + + await prepare() + + const section = document.querySelector('.settings-column .settings-section') + expect(shell()?.parentElement).toBe(section) + expect(shell()).toBe(section?.lastElementChild) + expect(shell()?.classList.contains('profile-fields-personal-info-box--column')).toBe(true) + expect(shell()?.classList.contains('personal-settings-setting-box')).toBe(false) + }) + + it('waits for the core settings column to be rendered', async() => { + renderShell('
') + + await prepare() + + expect(shell()?.parentElement).toBe(document.body) + + const mountPoint = document.querySelector('#profile-contact-settings') as HTMLElement + mountPoint.outerHTML = '
' + await flushPlacement() + + expect(shell()?.parentElement).toBe(document.querySelector('.settings-section')) + }) + + it('joins the grid of the legacy personal info section', async() => { + vi.stubGlobal('getComputedStyle', () => ({ gridTemplateColumns: '1fr 1fr' })) + renderShell('
') + + await prepare() + + expect(shell()?.parentElement).toBe(document.querySelector('#personal-settings')) + expect(shell()?.nextElementSibling).toBe(document.querySelector('#personal-settings-group-container')) + expect(shell()?.classList.contains('personal-settings-setting-box')).toBe(true) + expect(shell()?.classList.contains('profile-fields-personal-info-box--column')).toBe(false) + }) + + it('stacks above the visibility section of the legacy narrow layout', async() => { + renderShell(` +
+
+ `) + + await prepare() + + const visibilitySection = document.querySelector('#profile-visibility')?.closest('.personal-settings-section') + expect(shell()?.nextElementSibling).toBe(visibilitySection) + expect(shell()?.classList.contains('profile-fields-personal-info-box--stacked')).toBe(true) + expect(visibilitySection?.querySelector('#profile-fields-personal-visibility-anchor')).not.toBeNull() + }) +}) diff --git a/src/utils/embeddedShellPlacement.ts b/src/utils/embeddedShellPlacement.ts new file mode 100644 index 00000000..3784dfde --- /dev/null +++ b/src/utils/embeddedShellPlacement.ts @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +const EMBEDDED_VISIBILITY_ANCHOR_ID = 'profile-fields-personal-visibility-anchor' + +let embeddedShellPlacementFrame = 0 +let embeddedShellResizeDebounceTimer = 0 +let embeddedVisibilityAnchorObserver: MutationObserver | null = null +let observedProfileVisibilityRoot: HTMLElement | null = null +let coreLayoutObserver: MutationObserver | null = null + +const personalSettingsUsesMultipleColumns = (personalSettings: HTMLElement) => { + const gridTemplateColumns = window.getComputedStyle(personalSettings).gridTemplateColumns + if (gridTemplateColumns === '') { + return personalSettings.getBoundingClientRect().width >= 900 + } + + return gridTemplateColumns + .split(' ') + .map((value) => value.trim()) + .filter(Boolean) + .length > 1 +} + +const ensureEmbeddedVisibilityAnchor = () => { + const profileVisibilityRoot = document.querySelector('#profile-visibility') + if (profileVisibilityRoot === null) { + return + } + + if (observedProfileVisibilityRoot !== profileVisibilityRoot) { + embeddedVisibilityAnchorObserver?.disconnect() + embeddedVisibilityAnchorObserver = new MutationObserver(() => { + ensureEmbeddedVisibilityAnchor() + }) + embeddedVisibilityAnchorObserver.observe(profileVisibilityRoot, { childList: true }) + observedProfileVisibilityRoot = profileVisibilityRoot + } + + let anchor = profileVisibilityRoot.querySelector(`#${EMBEDDED_VISIBILITY_ANCHOR_ID}`) + if (anchor !== null) { + return + } + + anchor = document.createElement('div') + anchor.id = EMBEDDED_VISIBILITY_ANCHOR_ID + anchor.className = 'profile-fields-personal-info-visibility-anchor' + const insertionTarget = profileVisibilityRoot.querySelector('.visibility-dropdowns') + if (insertionTarget !== null) { + insertionTarget.appendChild(anchor) + } else { + profileVisibilityRoot.appendChild(anchor) + } + window.dispatchEvent(new CustomEvent('profile-fields:embedded-visibility-anchor-ready')) +} + +const scheduleEmbeddedPersonalInfoShellPlacement = () => { + if (embeddedShellPlacementFrame !== 0) { + window.cancelAnimationFrame(embeddedShellPlacementFrame) + } + + embeddedShellPlacementFrame = window.requestAnimationFrame(() => { + embeddedShellPlacementFrame = 0 + syncEmbeddedPersonalInfoShellPlacement() + }) +} + +const scheduleEmbeddedPersonalInfoShellPlacementAfterResize = () => { + if (embeddedShellResizeDebounceTimer !== 0) { + window.clearTimeout(embeddedShellResizeDebounceTimer) + } + + embeddedShellResizeDebounceTimer = window.setTimeout(() => { + embeddedShellResizeDebounceTimer = 0 + scheduleEmbeddedPersonalInfoShellPlacement() + }, 140) +} + +const observeCoreLayout = () => { + if (coreLayoutObserver !== null) { + return + } + + coreLayoutObserver = new MutationObserver(() => { + scheduleEmbeddedPersonalInfoShellPlacement() + }) + coreLayoutObserver.observe(document.body ?? document.documentElement, { childList: true, subtree: true }) +} + +const stopObservingCoreLayout = () => { + coreLayoutObserver?.disconnect() + coreLayoutObserver = null +} + +const syncEmbeddedPersonalInfoShellPlacementInColumn = (shell: HTMLElement, settingsColumn: HTMLElement) => { + shell.classList.remove('personal-settings-setting-box', 'personal-settings-section', 'profile-fields-personal-info-box--stacked') + shell.classList.add('profile-fields-personal-info-box--column') + + const insertionTarget = settingsColumn.querySelector('.settings-section') ?? settingsColumn + if (shell.parentElement !== insertionTarget || shell.nextElementSibling !== null) { + insertionTarget.appendChild(shell) + } + + stopObservingCoreLayout() + + shell.style.marginTop = '' + + shell.dataset.profileFieldsEmbeddedReady = 'true' +} + +const syncEmbeddedPersonalInfoShellPlacement = () => { + const shell = document.querySelector('#profile-fields-personal-info-shell') + if (shell === null) { + return + } + + const settingsColumn = document.querySelector('.settings-column') + if (settingsColumn !== null) { + syncEmbeddedPersonalInfoShellPlacementInColumn(shell, settingsColumn) + + return + } + + const personalSettings = document.querySelector('#personal-settings') + const profileVisibilitySection = document.querySelector('#profile-visibility')?.closest('.personal-settings-section') ?? null + if (personalSettings === null) { + return + } + + stopObservingCoreLayout() + ensureEmbeddedVisibilityAnchor() + + const useGridPlacement = personalSettingsUsesMultipleColumns(personalSettings) + personalSettings.classList.toggle('profile-fields-personal-info-grid', useGridPlacement) + personalSettings.classList.toggle('profile-fields-personal-info-stacked', !useGridPlacement) + shell.classList.toggle('personal-settings-setting-box', useGridPlacement) + shell.classList.toggle('personal-settings-section', !useGridPlacement) + shell.classList.toggle('profile-fields-personal-info-box--stacked', !useGridPlacement) + + if (useGridPlacement) { + const insertionTarget = personalSettings.querySelector('#personal-settings-group-container') + ?? personalSettings.querySelector('.msg') + + if (insertionTarget !== null) { + personalSettings.insertBefore(shell, insertionTarget) + } else { + personalSettings.appendChild(shell) + } + } else if (profileVisibilitySection !== null && profileVisibilitySection.parentNode !== null) { + profileVisibilitySection.parentNode.insertBefore(shell, profileVisibilitySection) + } else if (personalSettings.parentNode !== null) { + personalSettings.parentNode.insertBefore(shell, personalSettings.nextSibling) + } + + shell.style.marginTop = '' + + shell.dataset.profileFieldsEmbeddedReady = 'true' +} + +export const prepareEmbeddedPersonalInfoShell = () => { + observeCoreLayout() + scheduleEmbeddedPersonalInfoShellPlacement() + window.addEventListener('resize', scheduleEmbeddedPersonalInfoShellPlacementAfterResize, { passive: true }) + window.addEventListener('load', scheduleEmbeddedPersonalInfoShellPlacement, { once: true, passive: true }) +} diff --git a/src/views/PersonalSettings.vue b/src/views/PersonalSettings.vue index 64e8891f..163a5d4e 100644 --- a/src/views/PersonalSettings.vue +++ b/src/views/PersonalSettings.vue @@ -811,6 +811,34 @@ onBeforeUnmount(() => { max-inline-size: none; } +.profile-fields-personal-info-box--column { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + padding: 0; + box-sizing: border-box; +} + +.profile-fields-personal-info-box--column #profile-fields-personal-info-settings > .profile-fields-personal { + display: block; +} + +.profile-fields-personal-info-box--column #profile-fields-personal-info-settings > .profile-fields-personal .profile-fields-personal__grid--embedded { + display: grid; + grid-template-columns: 1fr; + gap: 0; +} + +.profile-fields-personal-info-box--column #profile-fields-personal-info-settings > .profile-fields-personal .profile-fields-personal__card--embedded { + padding: 6px 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + max-inline-size: none; +} +