Skip to content
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<img src="public/Logo-Large.png" alt="Solution Inventory" width="180" />
</p>

Vue 3 + Vuetify application for documenting solution questionnaires across multiple projects. Available as both a Progressive Web App (PWA) and an Electron desktop application. It uses a project tree for navigation, questionnaire tabs for editing, and a configuration editor in a dialog.
Vue 3 + Vuetify application for documenting solution questionnaires across multiple projects. Available as both a Progressive Web App (PWA) and an Electron desktop application for **Windows** and **Linux**. The Electron app stores all data locally on the device (no cloud sync). The web version (hosted via GitHub Pages) stores all data exclusively in the browser's Local Storage (no server-side storage or sync). It uses a project tree for navigation, questionnaire tabs for editing, and a configuration editor in a dialog.

## Features
- Project tree with create/rename/delete and drag-and-drop (move and reorder questionnaires)
Expand Down Expand Up @@ -41,7 +41,8 @@ npm install
npm run dev
```

### Development (Electron)

### Development (Electron, Windows & Linux)
```bash
npm run electron:dev
```
Expand All @@ -51,7 +52,8 @@ npm run electron:dev
npm run build
```

### Production Build (Electron)

### Production Build (Electron, Windows & Linux)
```bash
npm run electron:build
```
Expand All @@ -61,7 +63,8 @@ npm run electron:build
npm run preview
```

### Preview Build (Electron)

### Preview Build (Electron, Windows & Linux)
```bash
npm run electron:preview
```
Expand Down Expand Up @@ -161,12 +164,19 @@ Use the menu (⋮) in the toolbar to:
- Export as ThoughtWorks Build-Your-Own-Radar JSON format
- Download radar visualization as PNG image


## Project Import/Export
- Export a single project from the project menu.
- Import a project JSON using the import dialog in the project tree header.

## Data Storage

- **Electron App (Windows & Linux):** All data is stored locally on your device. No data is sent to any server or cloud service.
- **Web App (GitHub Pages):** All data is stored exclusively in your browser's Local Storage. There is no server-side storage or synchronization. Clearing your browser data will remove all projects and questionnaires.


## GitHub Pages Deployment
GitHub Actions builds and deploys on push to main. Pushes and PRs to dev build but do not deploy.
GitHub Actions builds and deploys on push to main. The web version is hosted at GitHub Pages and stores all data only in the browser's Local Storage. Pushes and PRs to dev build but do not deploy.

## Dependencies
- Vue 3
Expand Down
1 change: 1 addition & 0 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function createWindow() {
width: 1200,
height: 800,
show: false,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
Expand Down
8 changes: 7 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

<v-spacer />

<v-btn class="mr-2" icon variant="text" size="small" @click="workspaceConfigOpen = true">
<v-btn v-if="!isElectron" class="mr-2" icon variant="text" size="small" @click="workspaceConfigOpen = true">
<v-icon size="small">mdi-database-cog</v-icon>
<v-tooltip activator="parent" location="bottom">Manage workspace</v-tooltip>
</v-btn>
Expand Down Expand Up @@ -190,6 +190,12 @@ export default {
/* small global styles */
body { font-family: Roboto, Arial, sans-serif; }

/* Show scrollbar only when content overflows */
html, body { overflow-y: auto !important; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.35); }

.main-container {
max-width: 1800px;
margin: 0 auto;
Expand Down
154 changes: 143 additions & 11 deletions src/components/questionaire/Questionnaire.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@
<div class="d-flex align-center justify-space-between w-100">
<div class="d-flex align-center gap-2">
<h2>{{ currentCategory.title }}</h2>
<v-chip
v-if="currentCategoryHiddenCount > 0"
size="x-small"
variant="text"
class="hidden-entries-chip"
@click.stop="showAllEntries"
>
<v-icon start size="12">mdi-eye-off-outline</v-icon>
{{ currentCategoryHiddenCount }} hidden
</v-chip>
<v-btn
v-if="currentCategory.isMetadata"
icon
Expand Down Expand Up @@ -166,11 +176,49 @@

<!-- Regular entries for other categories -->
<div v-else>
<div class="d-flex align-center mb-4">
<v-text-field
v-model="entrySearch"
prepend-inner-icon="mdi-magnify"
label="Search questions"
density="compact"
variant="outlined"
hide-details
clearable
class="flex-grow-1"
/>
<v-btn
:color="entrySort ? 'primary' : 'default'"
variant="text"
size="small"
class="ml-3"
@click="cycleSort"
>
<v-icon>{{ entrySort === 'asc' ? 'mdi-sort-alphabetical-ascending' : entrySort === 'desc' ? 'mdi-sort-alphabetical-descending' : 'mdi-sort-variant' }}</v-icon>
<v-tooltip activator="parent" location="bottom">{{ entrySort === 'asc' ? 'Sorted A→Z (click for Z→A)' : entrySort === 'desc' ? 'Sorted Z→A (click to reset)' : 'Sort alphabetically' }}</v-tooltip>
</v-btn>
</div>
<div v-for="entry in visibleEntries" :key="entry.id" :data-entry-id="entry.id" class="mb-6">
<v-sheet class="pa-3" elevation="1">
<div class="d-flex justify-space-between align-start">
<div class="flex-grow-1">
<div class="text-h6 font-weight-bold">{{ entry.aspect }}</div>
<div class="d-flex align-center entry-title-row">
<span class="text-h6 font-weight-bold">{{ entry.aspect }}</span>
<v-tooltip text="Hide this entry" location="top">
<template #activator="{ props: hideTipProps }">
<v-btn
v-bind="hideTipProps"
size="x-small"
variant="text"
icon
class="entry-hide-btn ml-1"
@click.stop="hideEntry(entry.id)"
>
<v-icon size="14">mdi-eye-off-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</div>
<div v-if="entry.description" class="text-body-2 mt-1" v-html="renderTextWithLinks(entry.description)"></div>
<div v-if="getExampleItems(entry.examples).length" class="text--secondary text-sm mt-1">
<strong>Examples: </strong>
Expand Down Expand Up @@ -365,6 +413,29 @@ export default {
const architecturalRoleValue = computed(() => metadataValue.value?.architecturalRole || '')
const activeCategoryId = ref('')
const applicabilityFilter = ref('all')
const entrySearch = ref('')
const entrySort = ref('')

const hiddenEntries = computed(() =>
props.questionnaireId ? store.getQuestionnaireHiddenEntries(props.questionnaireId) : new Set()
)

function hideEntry(entryId) {
if (!props.questionnaireId) return
const updated = new Set([...hiddenEntries.value, entryId])
store.setQuestionnaireHiddenEntries(props.questionnaireId, updated)
}

function showAllEntries() {
if (!props.questionnaireId) return
store.setQuestionnaireHiddenEntries(props.questionnaireId, new Set())
}

function cycleSort() {
if (entrySort.value === '') entrySort.value = 'asc'
else if (entrySort.value === 'asc') entrySort.value = 'desc'
else entrySort.value = ''
}

function toArray(value) {
if (!value) return []
Expand Down Expand Up @@ -417,16 +488,39 @@ export default {

const visibleEntries = computed(() => {
const entries = Array.isArray(currentCategory.value.entries) ? currentCategory.value.entries : []
const filteredByMetadata = entries.filter((entry) => appliesToMatches(entry.appliesTo, metadataValue.value))

if (applicabilityFilter.value === 'all') {
return filteredByMetadata
let result = entries.filter((entry) => appliesToMatches(entry.appliesTo, metadataValue.value))

if (applicabilityFilter.value !== 'all') {
result = result.filter((entry) => {
const entryApplicability = entry.applicability || 'applicable'
return entryApplicability === applicabilityFilter.value
})
}

return filteredByMetadata.filter((entry) => {
const entryApplicability = entry.applicability || 'applicable'
return entryApplicability === applicabilityFilter.value
})

if (entrySearch.value && entrySearch.value.trim()) {
const q = entrySearch.value.trim().toLowerCase()
result = result.filter((entry) =>
(entry.aspect || '').toLowerCase().includes(q) ||
(entry.description || '').toLowerCase().includes(q)
)
}

if (entrySort.value === 'asc') {
result = [...result].sort((a, b) => (a.aspect || '').localeCompare(b.aspect || ''))
} else if (entrySort.value === 'desc') {
result = [...result].sort((a, b) => (b.aspect || '').localeCompare(a.aspect || ''))
}

return result.filter((entry) => !hiddenEntries.value.has(entry.id))
})

const currentCategoryHiddenCount = computed(() => {
const entries = Array.isArray(currentCategory.value.entries) ? currentCategory.value.entries : []
let result = entries.filter((entry) => appliesToMatches(entry.appliesTo, metadataValue.value))
if (applicabilityFilter.value !== 'all') {
result = result.filter((entry) => (entry.applicability || 'applicable') === applicabilityFilter.value)
}
return result.filter((entry) => hiddenEntries.value.has(entry.id)).length
})

const applicabilityFilterOptions = computed(() => {
Expand Down Expand Up @@ -460,6 +554,8 @@ export default {

function selectCategory(id) {
activeCategoryId.value = id
entrySearch.value = ''
entrySort.value = ''
scrollToTop()
}

Expand Down Expand Up @@ -495,6 +591,8 @@ export default {
const idx = visibleCategories.value.findIndex((category) => category.id === activeCategoryId.value)
if (idx < visibleCategories.value.length - 1) {
activeCategoryId.value = visibleCategories.value[idx + 1].id
entrySearch.value = ''
entrySort.value = ''
scrollToTop()
}
}
Expand All @@ -503,6 +601,8 @@ export default {
const idx = visibleCategories.value.findIndex((category) => category.id === activeCategoryId.value)
if (idx > 0) {
activeCategoryId.value = visibleCategories.value[idx - 1].id
entrySearch.value = ''
entrySort.value = ''
scrollToTop()
}
}
Expand Down Expand Up @@ -595,7 +695,13 @@ export default {
answerTypeOptions,
isReference,
parentProject,
toggleReference
toggleReference,
entrySearch,
entrySort,
cycleSort,
hideEntry,
showAllEntries,
currentCategoryHiddenCount
}
}
}
Expand All @@ -612,6 +718,32 @@ export default {
resize: vertical;
}

.entry-title-row {
flex-wrap: nowrap;
}

.entry-hide-btn {
opacity: 0;
transition: opacity 0.12s;
flex-shrink: 0;
}
.entry-title-row:hover .entry-hide-btn {
opacity: 0.5;
}
.entry-hide-btn:hover {
opacity: 1 !important;
}

.hidden-entries-chip {
cursor: pointer;
opacity: 0.4;
transition: opacity 0.15s;
font-size: 11px !important;
}
.hidden-entries-chip:hover {
opacity: 0.85;
}

.entry-highlighted {
animation: entry-flash 2s ease-out;
border-radius: 4px;
Expand Down
23 changes: 20 additions & 3 deletions src/stores/workspaceStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
const autoSaveStarted = ref(false)
const pendingNavigation = ref(null) // { questionnaireId, categoryId, entryId } | null
const workspaceDirNeeded = ref(false)
const questionnaireHiddenEntries = ref({}) // Record<questionnaireId, string[]>

const activeQuestionnaire = computed(() => {
return workspace.value.questionnaires.find((item) => item.id === activeQuestionnaireId.value) || null
Expand Down Expand Up @@ -94,6 +95,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
openQuestionnaireIds.value = []
activeWorkspaceTabId.value = ''
openProjectSummaryIds.value = []
questionnaireHiddenEntries.value = data.questionnaireHiddenEntries || {}
hydrateLastSaved(data.timestamp)
return true
}
Expand All @@ -104,6 +106,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
openQuestionnaireIds.value = []
activeWorkspaceTabId.value = ''
openProjectSummaryIds.value = []
questionnaireHiddenEntries.value = {}
hydrateLastSaved(data.timestamp)
return true
}
Expand Down Expand Up @@ -170,7 +173,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
if (autoSaveStarted.value) return
autoSaveStarted.value = true
watch(
() => [workspace.value, activeQuestionnaireId.value, openQuestionnaireIds.value, activeWorkspaceTabId.value, openProjectSummaryIds.value],
() => [workspace.value, activeQuestionnaireId.value, openQuestionnaireIds.value, activeWorkspaceTabId.value, openProjectSummaryIds.value, questionnaireHiddenEntries.value],
() => {
clearTimeout(persistDebounceTimer)
persistDebounceTimer = setTimeout(() => persist(), 500)
Expand All @@ -187,7 +190,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
activeQuestionnaireId: activeQuestionnaireId.value,
openQuestionnaireIds: openQuestionnaireIds.value,
activeWorkspaceTabId: activeWorkspaceTabId.value,
openProjectSummaryIds: openProjectSummaryIds.value
openProjectSummaryIds: openProjectSummaryIds.value,
questionnaireHiddenEntries: questionnaireHiddenEntries.value
}

if (window.electronAPI) {
Expand Down Expand Up @@ -246,6 +250,17 @@ export const useWorkspaceStore = defineStore('workspace', () => {
pendingNavigation.value = null
}

function getQuestionnaireHiddenEntries(questionnaireId) {
return new Set(questionnaireHiddenEntries.value[questionnaireId] || [])
}

function setQuestionnaireHiddenEntries(questionnaireId, entryIdSet) {
questionnaireHiddenEntries.value = {
...questionnaireHiddenEntries.value,
[questionnaireId]: [...entryIdSet]
}
}

function openProjectSummary(projectId) {
const project = workspace.value.projects.find((item) => item.id === projectId)
if (!project) return
Expand Down Expand Up @@ -853,6 +868,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
setReferenceQuestionnaire,
pendingNavigation,
navigateToEntry,
clearPendingNavigation
clearPendingNavigation,
getQuestionnaireHiddenEntries,
setQuestionnaireHiddenEntries
}
})