Repository: nextcloud-libraries/nextcloud-vue
Pull request #6823: refactor(NcSavingIndicatorIcon): migrate component to vue
Author: login: susnux
Labels: name: 3. to review
description: Waiting for reviews
name: refactor ♻️
description: Pull request that is neither a fix nor a feature
Created: 2025-04-21T18:16:14Z | Merged: 2025-04-22T17:38:48Z

### ☑️ Resolves

- For #841
- added tests to prevent regressions

### 🏁 Checklist

- [x] ⛑️ Tests are included or are not applicable
- [x] 📘 Component documentation has been extended, updated or is not applicable
- [x] 2️⃣ Backport to `stable8` for maintained Vue 2 version or not applicable



Diff:
diff --git a/src/components/NcSavingIndicatorIcon/NcSavingIndicatorIcon.vue b/src/components/NcSavingIndicatorIcon/NcSavingIndicatorIcon.vue
index e4bb2f1dcd..e52f64fa1a 100644
--- a/src/components/NcSavingIndicatorIcon/NcSavingIndicatorIcon.vue
+++ b/src/components/NcSavingIndicatorIcon/NcSavingIndicatorIcon.vue
@@ -50,13 +50,61 @@ export default {
 ```
 </docs>
 
+<script setup lang="ts">
+import { computed } from 'vue'
+
+const props = withDefaults(defineProps<{
+	/**
+	 * Specify the size of the saving icon.
+	 */
+	size?: number
+
+	/**
+	 * Specify what is saved.
+	 * If not set the element will be hidden from accessibility tree.
+	 */
+	name?: string
+
+	/**
+	 * Set to true when saving is in progress.
+	 */
+	saving?: boolean
+
+	/**
+	 * Set to true if an error occured while saving.
+	 */
+	error?: boolean
+}>(), {
+	error: false,
+	name: '',
+	saving: false,
+	size: 20,
+})
+
+defineEmits<{
+	/**
+	 * Click event on the icon.
+	 */
+	click: [MouseEvent]
+}>()
+
+const indicatorColor = computed(() => {
+	if (props.error) {
+		return 'var(--color-error)'
+	}
+	if (props.saving) {
+		return 'var(--color-primary-element)'
+	}
+	return 'none'
+})
+</script>
+
 <template>
 	<span :aria-label="name"
 		class="material-design-icon"
 		role="img"
 		@click="$emit('click', $event)">
-		<svg :fill="fillColor"
-			class="material-design-icon__svg"
+		<svg class="material-design-icon__svg"
 			:width="size"
 			:height="size"
 			viewBox="0 0 24 24">
@@ -67,49 +115,3 @@ export default {
 		</svg>
 	</span>
 </template>
-
-<script>
-import { defineComponent } from 'vue'
-
-export default defineComponent({
-	name: 'NcSavingIndicatorIcon',
-	props: {
-		/**
-		 * Specify the size of the saving icon.
-		 */
-		size: {
-			type: Number,
-			default: 20,
-		},
-		/**
-		 * Specify what is saved.
-		 */
-		name: {
-			type: String,
-			default: '',
-		},
-		/**
-		 * Set to true when saving is in progress.
-		 */
-		saving: {
-			type: Boolean,
-			default: false,
-			required: false,
-		},
-		/**
-		 * Set to true if an error occured while saving.
-		 */
-		error: {
-			type: Boolean,
-			default: false,
-			required: false,
-		},
-	},
-	emits: ['click'],
-	computed: {
-		indicatorColor() {
-			return this.error ? 'var(--color-error)' : (this.saving ? 'var(--color-primary-element)' : 'none')
-		},
-	},
-})
-</script>
diff --git a/src/components/NcSavingIndicatorIcon/index.js b/src/components/NcSavingIndicatorIcon/index.ts
similarity index 100%
rename from src/components/NcSavingIndicatorIcon/index.js
rename to src/components/NcSavingIndicatorIcon/index.ts
diff --git a/src/components/index.ts b/src/components/index.ts
index 946a47662d..79cc341d05 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -71,7 +71,7 @@ export { default as NcProgressBar } from './NcProgressBar/index.js'
 export { default as NcRelatedResourcesPanel } from './NcRelatedResourcesPanel/index.js'
 export { default as NcRichContenteditable, NcAutoCompleteResult, NcMentionBubble } from './NcRichContenteditable/index.js'
 export { default as NcRichText } from './NcRichText/index.js'
-export { default as NcSavingIndicatorIcon } from './NcSavingIndicatorIcon/index.js'
+export { default as NcSavingIndicatorIcon } from './NcSavingIndicatorIcon/index.ts'
 export { default as NcSelect } from './NcSelect/index.js'
 export { default as NcSelectTags } from './NcSelectTags/index.js'
 export { default as NcSettingsInputText } from './NcSettingsInputText/index.js'
diff --git a/tests/component/components/NcSavingIndicator.spec.ts b/tests/component/components/NcSavingIndicator.spec.ts
new file mode 100644
index 0000000000..80e9c9edf5
--- /dev/null
+++ b/tests/component/components/NcSavingIndicator.spec.ts
@@ -0,0 +1,83 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { expect, test } from '@playwright/experimental-ct-vue'
+import NcSavingIndicatorIcon from '../../../src/components/NcSavingIndicatorIcon/NcSavingIndicatorIcon.vue'
+
+test('Icon has accessible name', async ({ mount, page }) => {
+	const component = await mount(NcSavingIndicatorIcon, {
+		props: {
+			name: 'Saving something',
+		},
+	})
+
+	await expect(page.getByRole('img', { name: 'Saving something' })).toBeVisible()
+
+	// reactivity
+	await component.update({ props: { name: 'Saving nothing' } })
+	await expect(page.getByRole('img', { name: 'Saving nothing' })).toBeVisible()
+})
+
+test('Can specify size', async ({ mount }) => {
+	const component = await mount(NcSavingIndicatorIcon, {
+		props: {
+			size: 42,
+			name: 'Saving something',
+		},
+	})
+
+	const box = await component.getByRole('img').boundingBox()
+	expect(box?.height).toBe(42)
+	expect(box?.width).toBe(42)
+
+	// reactive
+	await component.update({
+		props: {
+			size: 24,
+		},
+	})
+	const boxSmall = await component.getByRole('img').boundingBox()
+	expect(boxSmall?.height).toBe(24)
+	expect(boxSmall?.width).toBe(24)
+})
+
+test('Has click event', async ({ mount }) => {
+	let clicked = 0
+	const component = await mount(NcSavingIndicatorIcon, {
+		on: {
+			click() {
+				clicked++
+			},
+		},
+	})
+
+	expect(clicked).toBe(0)
+	await component.click()
+	await component.click()
+	expect(clicked).toBe(2)
+})
+
+test.describe('Icon is rendered with correct color', { tag: '@visual' }, () => {
+	// visual tests only run on chromium
+	test.skip(({ browserName }) => browserName !== 'chromium')
+
+	// ensure screenshots look consistent
+	test.beforeEach(({ page }) => {
+		page.addStyleTag({ content: '#app-content { padding: 12px; }\n' })
+	})
+
+	for (const [error, saving] of [[false, false], [false, true], [true, false]]) {
+		test(`with error (${error}) and saving (${saving}) prop`, async ({ mount }) => {
+			const component = await mount(NcSavingIndicatorIcon, {
+				props: {
+					name: 'Saving something',
+					error,
+					saving,
+				},
+			})
+
+			await expect(component).toHaveScreenshot()
+		})
+	}
+})
diff --git a/tests/component/snapshots/components/NcSavingIndicator.spec.ts-snapshots/Icon-is-rendered-with-correct-color-with-error-false-and-saving-false-prop-1-chromium-linux.png b/tests/component/snapshots/components/NcSavingIndicator.spec.ts-snapshots/Icon-is-rendered-with-correct-color-with-error-false-and-saving-false-prop-1-chromium-linux.png
new file mode 100644
index 0000000000..ccb80cc69c
Binary files /dev/null and b/tests/component/snapshots/components/NcSavingIndicator.spec.ts-snapshots/Icon-is-rendered-with-correct-color-with-error-false-and-saving-false-prop-1-chromium-linux.png differ
diff --git a/tests/component/snapshots/components/NcSavingIndicator.spec.ts-snapshots/Icon-is-rendered-with-correct-color-with-error-false-and-saving-true-prop-1-chromium-linux.png b/tests/component/snapshots/components/NcSavingIndicator.spec.ts-snapshots/Icon-is-rendered-with-correct-color-with-error-false-and-saving-true-prop-1-chromium-linux.png
new file mode 100644
index 0000000000..a238ce8830
Binary files /dev/null and b/tests/component/snapshots/components/NcSavingIndicator.spec.ts-snapshots/Icon-is-rendered-with-correct-color-with-error-false-and-saving-true-prop-1-chromium-linux.png differ
diff --git a/tests/component/snapshots/components/NcSavingIndicator.spec.ts-snapshots/Icon-is-rendered-with-correct-color-with-error-true-and-saving-false-prop-1-chromium-linux.png b/tests/component/snapshots/components/NcSavingIndicator.spec.ts-snapshots/Icon-is-rendered-with-correct-color-with-error-true-and-saving-false-prop-1-chromium-linux.png
new file mode 100644
index 0000000000..08c03eeccb
Binary files /dev/null and b/tests/component/snapshots/components/NcSavingIndicator.spec.ts-snapshots/Icon-is-rendered-with-correct-color-with-error-true-and-saving-false-prop-1-chromium-linux.png differ


Reviews:
user: login: juliusknorr
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T06:44:40Z
user: login: ShGKme
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T16:01:47Z
user: login: susnux
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T17:38:43Z

Review comments:
diff_hunk: @@ -0,0 +1,83 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { expect, test } from '@playwright/experimental-ct-vue'
+import NcSavingIndicatorIcon from '../../../src/components/NcSavingIndicatorIcon/NcSavingIndicatorIcon.vue'
+
+test('Icon has accessible name', async ({ mount, page }) => {
+	const component = await mount(NcSavingIndicatorIcon, {
+		props: {
+			name: 'Saving something',
+		},
+	})
+
+	await expect(page.getByRole('img', { name: 'Saving something' })).toBeVisible()
+
+	// reactivity
+	await component.update({ props: { name: 'Saving nothing' } })
+	await expect(page.getByRole('img', { name: 'Saving nothing' })).toBeVisible()
+})
+
+test('Can specify size', async ({ mount }) => {
+	const component = await mount(NcSavingIndicatorIcon, {
+		props: {
+			size: 42,
+			name: 'Saving something',
+		},
+	})
+
+	const box = await component.getByRole('img').boundingBox()
+	expect(box?.height).toBe(42)
+	expect(box?.width).toBe(42)
+
+	// reactive
+	await component.update({
+		props: {
+			size: 24,
+		},
+	})
+	const boxSmall = await component.getByRole('img').boundingBox()
+	expect(boxSmall?.height).toBe(24)
+	expect(boxSmall?.width).toBe(24)
+})
+
+test('Has click event', async ({ mount }) => {
+	let clicked = 0
+	const component = await mount(NcSavingIndicatorIcon, {
+		on: {
+			click() {
+				clicked++
+			},
+		},
+	})
+
+	expect(clicked).toBe(0)
+	await component.click()
+	await component.click()
+	expect(clicked).toBe(2)
+})
+
+test.describe('Icon is rendered with correct color', { tag: '@visual' }, () => {
+	// visual tests only run on chromium
+	test.skip(({ browserName }) => browserName !== 'chromium')
+
+	// ensure screenshots look consistent
+	test.beforeEach(({ page }) => {
+		page.addStyleTag({ content: '#app-content { padding: 12px; }\n' })
+	})
path: tests/component/components/NcSavingIndicator.spec.ts
user: login: ShGKme
body: How does it change consistency (or consistency in what)?
created_at: 2025-04-22T16:01:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,83 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { expect, test } from '@playwright/experimental-ct-vue'
+import NcSavingIndicatorIcon from '../../../src/components/NcSavingIndicatorIcon/NcSavingIndicatorIcon.vue'
+
+test('Icon has accessible name', async ({ mount, page }) => {
+	const component = await mount(NcSavingIndicatorIcon, {
+		props: {
+			name: 'Saving something',
+		},
+	})
+
+	await expect(page.getByRole('img', { name: 'Saving something' })).toBeVisible()
+
+	// reactivity
+	await component.update({ props: { name: 'Saving nothing' } })
+	await expect(page.getByRole('img', { name: 'Saving nothing' })).toBeVisible()
+})
+
+test('Can specify size', async ({ mount }) => {
+	const component = await mount(NcSavingIndicatorIcon, {
+		props: {
+			size: 42,
+			name: 'Saving something',
+		},
+	})
+
+	const box = await component.getByRole('img').boundingBox()
+	expect(box?.height).toBe(42)
+	expect(box?.width).toBe(42)
+
+	// reactive
+	await component.update({
+		props: {
+			size: 24,
+		},
+	})
+	const boxSmall = await component.getByRole('img').boundingBox()
+	expect(boxSmall?.height).toBe(24)
+	expect(boxSmall?.width).toBe(24)
+})
+
+test('Has click event', async ({ mount }) => {
+	let clicked = 0
+	const component = await mount(NcSavingIndicatorIcon, {
+		on: {
+			click() {
+				clicked++
+			},
+		},
+	})
+
+	expect(clicked).toBe(0)
+	await component.click()
+	await component.click()
+	expect(clicked).toBe(2)
+})
+
+test.describe('Icon is rendered with correct color', { tag: '@visual' }, () => {
+	// visual tests only run on chromium
+	test.skip(({ browserName }) => browserName !== 'chromium')
+
+	// ensure screenshots look consistent
+	test.beforeEach(({ page }) => {
+		page.addStyleTag({ content: '#app-content { padding: 12px; }\n' })
+	})
path: tests/component/components/NcSavingIndicator.spec.ts
user: login: susnux
body: Consistent regardless of size due to border radius of app content 
created_at: 2025-04-22T17:38:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: susnux
created_at: 2025-04-21T18:48:57Z
body: /update-snapshots amend
author_association: CONTRIBUTOR