From 8248989beae31bd26bca057babba169e2214d27f Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Mon, 27 Jul 2026 14:42:39 +0300 Subject: [PATCH 1/3] increment: add list state management with Pinia store --- src/frontend/store/list-state.ts | 44 +++++++++++++++ src/frontend/store/model.ts | 5 ++ src/frontend/store/widgets.ts | 32 ++++------- tests/unit/listState.spec.ts | 48 ++++++++++++++++ tests/unit/model.spec.ts | 95 ++++++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 src/frontend/store/list-state.ts create mode 100644 tests/unit/listState.spec.ts diff --git a/src/frontend/store/list-state.ts b/src/frontend/store/list-state.ts new file mode 100644 index 00000000..5e46aea8 --- /dev/null +++ b/src/frontend/store/list-state.ts @@ -0,0 +1,44 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; + +export const useListStateStore = defineStore('list-state', () => { + const removedItems = ref>({}); + const listToggles = ref>({}); + + const clearListState = (): void => { + removedItems.value = {}; + listToggles.value = {}; + }; + + const setListToggles = (path: string, value: boolean[]): void => { + const fresh = { ...listToggles.value }; + fresh[path] = value; + listToggles.value = fresh; + }; + + const getListToggles = (path: string): boolean[] => listToggles.value[path] || []; + + const toggleRemovedIndex = (path: string, index: number): void => { + const fresh = { ...removedItems.value }; + fresh[path] = Array.from(removedItems.value[path] || []); + if (fresh[path].includes(index)) { + fresh[path].splice(fresh[path].indexOf(index), 1); + } else { + fresh[path].push(index); + } + removedItems.value = fresh; + }; + + const isInRemovedList = (path: string, index: number): boolean => + removedItems.value[path]?.includes(index) || false; + + return { + removedItems, + listToggles, + clearListState, + setListToggles, + getListToggles, + toggleRemovedIndex, + isInRemovedList, + }; +}); diff --git a/src/frontend/store/model.ts b/src/frontend/store/model.ts index 4a589fcb..532fe504 100644 --- a/src/frontend/store/model.ts +++ b/src/frontend/store/model.ts @@ -1,9 +1,11 @@ import { defineStore } from 'pinia'; import { ref } from 'vue'; import type { Scripture, SharedPageProps, DraftEditProps } from '../../types'; +import { useListStateStore } from './list-state'; export const useModelStore = defineStore('model', () => { const model = ref({}); + const listState = useListStateStore(); const resolvePath = ( object: Record, @@ -48,6 +50,7 @@ export const useModelStore = defineStore('model', () => { resolvePath(model.value, path, defaultValue); const setModel = (fresh: object) => { + listState.clearListState(); model.value = fresh; }; @@ -79,6 +82,8 @@ export const useModelStore = defineStore('model', () => { }; const setFromProps = (props: DraftEditProps & SharedPageProps) => { + listState.clearListState(); + if (props.bundle) { model.value = { ...props.bundle }; } diff --git a/src/frontend/store/widgets.ts b/src/frontend/store/widgets.ts index d33b87bc..53db31a3 100644 --- a/src/frontend/store/widgets.ts +++ b/src/frontend/store/widgets.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia'; import { ref } from 'vue'; import type { WidgetPicker, Providers } from '../../types'; import { widgetField } from '../fields/widget-fields'; +import { useListStateStore } from './list-state'; const defaultProviders: Providers = { s3: { @@ -29,6 +30,8 @@ const defaultProviders: Providers = { }; export const useWidgetsStore = defineStore('widgets', () => { + const listState = useListStateStore(); + // widget picker const standardPicker = (widget: string) => widgetField(widget); @@ -38,31 +41,20 @@ export const useWidgetsStore = defineStore('widgets', () => { picker.value = fresh; }; - // track folding in foldable lists + // track folding and removed items in flexible lists - const listToggles = ref>({}); + const getListToggles = (path: string): boolean[] => listState.getListToggles(path); const setListToggles = (path: string, value: boolean[]): void => { - const fresh = { ...listToggles.value }; - fresh[path] = value; - listToggles.value = fresh; + listState.setListToggles(path, value); }; - const getListToggles = (path: string): boolean[] => listToggles.value[path] || []; - - // track removed items in flexible lists - - const removedItems = ref>({}); const toggleRemovedIndex = (path: string, index: number): void => { - const fresh = { ...removedItems.value }; - fresh[path] = Array.from(removedItems.value[path] || []); - if (fresh[path].includes(index)) { - fresh[path].splice(fresh[path].indexOf(index), 1); - } else { - fresh[path].push(index); - } - removedItems.value = fresh; + listState.toggleRemovedIndex(path, index); }; const isInRemovedList = (path: string, index: number): boolean => - removedItems.value[path]?.includes(index) || false; + listState.isInRemovedList(path, index); + const clearListState = (): void => { + listState.clearListState(); + }; // providers @@ -90,9 +82,9 @@ export const useWidgetsStore = defineStore('widgets', () => { getListToggles, setListToggles, - toggleRemovedIndex, isInRemovedList, + clearListState, setProviders, providers, diff --git a/tests/unit/listState.spec.ts b/tests/unit/listState.spec.ts new file mode 100644 index 00000000..173ae0ac --- /dev/null +++ b/tests/unit/listState.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test'; +import { createPinia, setActivePinia } from 'pinia'; +import { useListStateStore } from '../../src/frontend/store/list-state'; + +test.describe('List state', () => { + test.beforeEach(async () => { + setActivePinia(createPinia()); + }); + + test('toggleRemovedIndex adds and removes indices', () => { + const store = useListStateStore(); + + store.toggleRemovedIndex('questions', 1); + expect(store.isInRemovedList('questions', 1)).toBe(true); + expect(store.isInRemovedList('questions', 0)).toBe(false); + + store.toggleRemovedIndex('questions', 1); + expect(store.isInRemovedList('questions', 1)).toBe(false); + }); + + test('clearListState clears removedItems and listToggles', () => { + const store = useListStateStore(); + + store.toggleRemovedIndex('questions', 1); + store.setListToggles('resources', [true, false]); + + store.clearListState(); + + expect(store.isInRemovedList('questions', 1)).toBe(false); + expect(store.getListToggles('resources')).toEqual([]); + }); + + test('list state is isolated between Pinia instances', () => { + const first = useListStateStore(createPinia()); + const second = useListStateStore(createPinia()); + + first.toggleRemovedIndex('questions', 1); + first.setListToggles('resources', [true, false]); + + expect(second.isInRemovedList('questions', 1)).toBe(false); + expect(second.getListToggles('resources')).toEqual([]); + + second.clearListState(); + + expect(first.isInRemovedList('questions', 1)).toBe(true); + expect(first.getListToggles('resources')).toEqual([true, false]); + }); +}); diff --git a/tests/unit/model.spec.ts b/tests/unit/model.spec.ts index 0bce58e2..a27d1401 100644 --- a/tests/unit/model.spec.ts +++ b/tests/unit/model.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from '@playwright/test'; import { setActivePinia, createPinia } from 'pinia'; import { useModelStore } from '../../src/frontend/store/model'; +import { useListStateStore } from '../../src/frontend/store/list-state'; +import type { DraftEditProps, SharedPageProps } from '../../src/types'; const fixture = { name: 'Jesse', @@ -12,6 +14,56 @@ const fixture = { }, }; +const draftEditProps: DraftEditProps & SharedPageProps = { + draft: { + id: 1, + number: 1, + status: 'started', + updatedAt: '2021-08-10T14:48:00.000000Z', + createdAt: '2021-08-09T10:00:00.000000Z', + }, + bundle: { title: 'Chapter 1', questions: [{ question: 'One' }] }, + source: { questions: [{ question: 'Source' }] }, + lastPublished: '', + providers: {}, + story: { + id: 1, + name: 'Test Story', + coverImage: 'https://example.com/cover.jpg', + storyType: 'Story', + chapterType: 'Chapter', + chapterLimit: 10, + visibility: 'public', + schemaVersion: 1, + isPublished: true, + fields: [], + sections: [], + }, + hasEditReview: false, + user: { + id: 1, + name: 'Test User', + isAdmin: true, + isManager: true, + role: 'admin', + }, + config: { + name: 'Test CMS', + logo: '', + helpUrl: '', + supportEmail: 'support@example.com', + hasAppPreview: false, + videoCollectionId: '', + languages: [], + subscriptions: [], + }, + language: { + language: 'English', + languageDirection: 'ltr', + locale: 'en', + }, +}; + test.describe('Model Store', () => { test.beforeEach(async () => { setActivePinia(createPinia()); @@ -40,4 +92,47 @@ test.describe('Model Store', () => { expect(store.isPopulated('address.hasCredit')).toBe(true); expect(store.isPopulated('address.isFavourite')).toBe(true); }); + + test('setModel clears flexible list state', () => { + const model = useModelStore(); + const listState = useListStateStore(); + + listState.toggleRemovedIndex('questions', 1); + listState.setListToggles('resources', [false, true]); + + model.setModel({ title: 'Fresh' }); + + expect(listState.isInRemovedList('questions', 1)).toBe(false); + expect(listState.getListToggles('resources')).toEqual([]); + expect(model.getField('title')).toBe('Fresh'); + }); + + test('setFromProps clears flexible list state', () => { + const model = useModelStore(); + const listState = useListStateStore(); + + listState.toggleRemovedIndex('questions', 2); + + model.setFromProps(draftEditProps); + + expect(listState.isInRemovedList('questions', 2)).toBe(false); + expect(model.getField('title')).toBe('Chapter 1'); + expect(model.getSourceField('questions.0.question')).toBe('Source'); + }); + + test('model resets only clear list state in the same Pinia instance', () => { + const firstPinia = createPinia(); + const firstModel = useModelStore(firstPinia); + const firstListState = useListStateStore(firstPinia); + const secondPinia = createPinia(); + const secondListState = useListStateStore(secondPinia); + + firstListState.toggleRemovedIndex('questions', 1); + secondListState.toggleRemovedIndex('questions', 2); + + firstModel.setModel({ title: 'Fresh' }); + + expect(firstListState.isInRemovedList('questions', 1)).toBe(false); + expect(secondListState.isInRemovedList('questions', 2)).toBe(true); + }); }); From 46dc60b543e7782edab327259955a503f640faa6 Mon Sep 17 00:00:00 2001 From: Timothy Koech Date: Mon, 27 Jul 2026 15:01:09 +0300 Subject: [PATCH 2/3] increment: add watch functionality to markdown field for source column visibility --- src/frontend/fields/markdown-field.vue | 13 +++++++++++- tests/e2e/translationIndex.spec.ts | 29 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/translationIndex.spec.ts diff --git a/src/frontend/fields/markdown-field.vue b/src/frontend/fields/markdown-field.vue index 8d155352..7f382696 100644 --- a/src/frontend/fields/markdown-field.vue +++ b/src/frontend/fields/markdown-field.vue @@ -36,7 +36,7 @@