Skip to content

Commit

Permalink
Frontend for Portfolio Groups (#85)
Browse files Browse the repository at this point in the history
  • Loading branch information
gbdubs authored Dec 19, 2023
1 parent 3aab8bf commit a399391
Show file tree
Hide file tree
Showing 32 changed files with 1,290 additions and 189 deletions.
2 changes: 1 addition & 1 deletion frontend/components/form/EditorField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const slots = useSlots()
const helpTextSlotExists = computed(() => slots['help-text'] !== undefined)
const valid = computed(() => isValid(props.editorField, props.editorValue))
const hasValidation = computed(() => (props.editorField.validation ?? []).length > 0)
const loadingLabel = computed(() => props.editorField.loadingLabel ?? tt('Loading...'))
const loadingLabel = computed(() => props.editorField.loadingLabel ?? tt('Loading'))
const invalidLabel = computed(() => props.editorField.invalidLabel ?? tt('Needs Attention'))
const validLabel = computed(() => props.editorField.validLabel ?? '')
const startHelpTextExpanded = computed(() => props.editorField.startHelpTextExpanded ?? false)
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/form/Field.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ const props = withDefaults(defineProps<Props>(), {
helpText: '',
startHelpTextExpanded: true,
isLoading: false,
loadingLabel: 'Loading...',
loadingLabel: undefined,
hasValidation: false,
isValid: false,
invalidLabel: 'Needs Attention',
invalidLabel: undefined,
validLabel: '',
})
Expand Down
74 changes: 53 additions & 21 deletions frontend/components/modal/MissingTranslations.vue
Original file line number Diff line number Diff line change
@@ -1,33 +1,37 @@
<script setup lang="ts">
const { $axios, $missingTranslations } = useNuxtApp()
const { missingTranslations: { missingTranslationsVisible } } = useModal()
const { loading: { withLoading }, missingTranslations: { missingTranslationsVisible } } = useModal()
const { t } = useI18n()
const prefix = 'components/modal/MissingTranslations'
const existing = useState<Map<string, string>>(`${prefix}.existing`, () => new Map())
const languages = ['en', 'es', 'fr', 'de']
const tt = (key: string) => t(`${prefix}.${key}`)
onMounted(async () => {
for (const lang of languages) {
const langData = await $axios({
method: 'get',
url: `/_nuxt/lang/${lang}.json`,
transformResponse: (res) => res,
responseType: 'json',
}).then((r) => {
return r.data
})
existing.value.set(lang, langData)
}
})
const onOpen = async () => {
await withLoading(() => Promise.all(languages.map((lang) => $axios({
method: 'get',
url: `/_nuxt/lang/${lang}.json`,
transformResponse: (res) => res,
responseType: 'json',
}).then((r) => {
existing.value.set(lang, r.data)
}))), `${prefix}.onOpen`)
}
const constructIdealMap = (lang: string): Map<string, Map<string, string>> => {
const constructIdealMap = (lang: string): { map: Map<string, Map<string, string>>, errors: string[], missing: Set<string> } => {
const errors: string[] = []
const ideal = new Map<string, Map<string, string>>()
const e = existing.value.get(lang)
if (e) {
const asObj = JSON.parse(e) as Map<string, Map<string, string>>
let asObj: Map<string, Map<string, string>>
try {
asObj = JSON.parse(e) as Map<string, Map<string, string>>
} catch (e: any) {
errors.push(`Error parsing ${lang} JSON: ${e}`)
return { map: ideal, errors, missing: new Set<string>() }
}
for (const [prefix, values] of Object.entries(asObj)) {
let m = ideal.get(prefix)
if (!m) {
Expand All @@ -36,7 +40,7 @@ const constructIdealMap = (lang: string): Map<string, Map<string, string>> => {
}
for (const [key, value] of Object.entries(values as Map<string, string>)) {
if (m.has(key)) {
throw new Error(`Duplicate key ${key} in ${prefix}`)
errors.push(`Duplicate key ${key} in ${prefix}`)
}
m.set(key, value)
}
Expand All @@ -46,7 +50,7 @@ const constructIdealMap = (lang: string): Map<string, Map<string, string>> => {
for (const key of missing) {
const splits = key.split('.')
if (splits.length < 2) {
throw new Error(`Invalid key structure '${key}'`)
errors.push(`Invalid key structure '${key}'`)
}
const file = splits[0]
const actualKey = splits.slice(1).join('.')
Expand All @@ -56,12 +60,12 @@ const constructIdealMap = (lang: string): Map<string, Map<string, string>> => {
ideal.set(file, m)
}
if (m.has(actualKey)) {
throw new Error(`Duplicate key ${actualKey} in ${file}`)
errors.push(`Duplicate key ${actualKey} in ${file}`)
}
ideal.set(file, m.set(actualKey, `TODO - ${actualKey}`))
}
return ideal
return { map: ideal, errors, missing }
}
const mapToJson = (map: Map<string, Map<string, string>>): string => {
const obj = Object.fromEntries(
Expand All @@ -84,14 +88,19 @@ interface TabValue {
language: string
ideal: string
numMissing: number
errors: string[]
missing: Set<string>
}
const tabs = computed<TabValue[]>(() => {
const result: TabValue[] = []
for (const lang of languages) {
const { map, errors, missing } = constructIdealMap(lang)
result.push({
language: lang,
ideal: mapToJson(constructIdealMap(lang)),
ideal: mapToJson(map),
numMissing: ($missingTranslations.values.value.get(lang) ?? new Set()).size,
errors,
missing,
})
}
return result
Expand All @@ -103,6 +112,7 @@ const tabs = computed<TabValue[]>(() => {
v-model:visible="missingTranslationsVisible"
header="Missing Translations"
sub-header="A set of tools to help you find and fix missing translations"
@opened="onOpen"
>
<PVTabView>
<PVTabPanel
Expand All @@ -122,12 +132,34 @@ const tabs = computed<TabValue[]>(() => {
:file-name="`${tab.language}-${new Date().getTime()}.json`"
/>
</div>
<StandardDebug
:label="`Errors (${tab.errors.length})`"
:value="tab.errors"
always
/>
<StandardDebug
:label="`Missing ${tab.numMissing} Translation Strings`"
:value="Array.from(tab.missing).sort()"
always
/>
<div class="code">
{{ tab.ideal }}
</div>
</div>
</PVTabPanel>
</PVTabView>
<StandardDebug
:value="languages"
label="Languages"
/>
<StandardDebug
:value="constructIdealMap('en')"
label="EN Ideal"
/>
<StandardDebug
:value="tabs"
label="Tabs"
/>
</StandardModal>
</template>

Expand Down
224 changes: 224 additions & 0 deletions frontend/components/portfolio/ListView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { portfolioEditor } from '@/lib/editor'
import { type Portfolio, type PortfolioGroup } from '@/openapi/generated/pacta'
const {
humanReadableTimeFromStandardString,
humanReadableDateFromStandardString,
} = useTime()
const pactaClient = usePACTA()
const { loading: { withLoading } } = useModal()
const i18n = useI18n()
const { t } = i18n
interface Props {
portfolios: Portfolio[]
portfolioGroups: PortfolioGroup[]
selectedPortfolioIds: string[]
selectedPortfolioGroupIds: string[]
}
const props = defineProps<Props>()
interface Emits {
(e: 'update:selectedPortfolioIds', value: string[]): void
(e: 'update:selectedPortfolioGroupIds', value: string[]): void
(e: 'refresh'): void
}
const emit = defineEmits<Emits>()
const refresh = () => { emit('refresh') }
const selectedPortfolioIDs = computed({
get: () => props.selectedPortfolioIds ?? [],
set: (value: string[]) => { emit('update:selectedPortfolioIds', value) },
})
interface EditorObject extends ReturnType<typeof portfolioEditor> {
id: string
}
const prefix = 'components/portfolio/ListView'
const tt = (s: string) => t(`${prefix}.${s}`)
const expandedRows = useState<EditorObject[]>(`${prefix}.expandedRows`, () => [])
const selectedRows = computed<EditorObject[]>({
get: () => {
const ids = selectedPortfolioIDs.value
return editorObjects.value.filter((editorObject) => ids.includes(editorObject.id))
},
set: (value: EditorObject[]) => {
const ids = value.map((row) => row.id)
ids.sort()
selectedPortfolioIDs.value = ids
},
})
const editorObjects = computed<EditorObject[]>(() => props.portfolios.map((item) => ({ ...portfolioEditor(item, i18n), id: item.id })))
const selectedPortfolios = computed<Portfolio[]>(() => selectedRows.value.map((row) => row.currentValue.value))
const saveChanges = (id: string) => {
const index = editorObjects.value.findIndex((editorObject) => editorObject.id === id)
const eo = presentOrFileBug(editorObjects.value[index])
return withLoading(
() => pactaClient.updatePortfolio(id, eo.changes.value).then(refresh),
`${prefix}.saveChanges`,
)
}
const deletePortfolio = (id: string) => withLoading(
() => pactaClient.deletePortfolio(id),
`${prefix}.deletePortfolio`,
)
const deleteSelected = () => Promise.all([selectedRows.value.map((row) => deletePortfolio(row.id))]).then(refresh)
</script>

<template>
<div class="flex flex-column gap-3">
<div class="flex gap-2 flex-wrap">
<PVButton
icon="pi pi-refresh"
class="p-button-outlined p-button-secondary"
:label="tt('Refresh')"
@click="refresh"
/>
<PortfolioGroupMembershipMenuButton
:selected-portfolios="selectedPortfolios"
:portfolio-groups="props.portfolioGroups"
@changed-memberships="refresh"
@changed-groups="refresh"
/>
<PVButton
v-if="selectedRows && selectedRows.length > 0"
icon="pi pi-trash"
class="p-button-outlined p-button-danger"
:label="`${tt('Delete')} (${selectedRows.length})`"
@click="deleteSelected"
/>
</div>
<PVDataTable
v-model:selection="selectedRows"
v-model:expanded-rows="expandedRows"
:value="editorObjects"
data-key="id"
size="small"
sort-field="editorValues.value.createdAt.originalValue"
:sort-order="-1"
>
<PVColumn selection-mode="multiple" />
<PVColumn
field="editorValues.value.createdAt.originalValue"
:header="tt('Created At')"
sortable
>
<template #body="slotProps">
{{ humanReadableTimeFromStandardString(slotProps.data.editorValues.value.createdAt.originalValue).value }}
</template>
</PVColumn>
<PVColumn
field="editorValues.value.name.originalValue"
sortable
:header="tt('Name')"
/>
<PVColumn
:header="tt('Memberships')"
>
<template #body="slotProps">
<div class="flex flex-column gap-2">
<div class="flex gap-1 align-items-center flex-wrap">
<span>{{ tt('Groups') }}:</span>
<span
v-for="membership in slotProps.data.editorValues.value.groups.originalValue"
:key="membership.portfolioGroup.id"
class="p-tag p-tag-rounded"
>
{{ membership.portfolioGroup.name }}
</span>
</div>
<div class="flex gap-2 align-items-center flex-wrap">
<span>{{ tt('Initiatives') }}:</span>
<span
v-for="membership in slotProps.data.editorValues.value.memberships"
:key="membership"
class="p-tag p-tag-rounded"
>
{{ membership }}
</span>
</div>
</div>
</template>
</PVColumn>
<PVColumn
expander
:header="tt('Details')"
/>
<template
#expansion="slotProps"
>
<div class="surface-100 p-3">
<h2 class="mt-0">
{{ tt('Metadata') }}
</h2>
<div class="flex flex-column gap-2 w-fit">
<div class="flex gap-2 justify-content-between">
<span>{{ tt('Created At') }}</span>
<b>{{ humanReadableTimeFromStandardString(slotProps.data.editorValues.value.createdAt.originalValue).value }}</b>
</div>
<div class="flex gap-2 justify-content-between">
<span>{{ tt('Number of Rows') }}</span>
<b>{{ slotProps.data.editorValues.value.numberOfRows.originalValue }}</b>
</div>
<div class="flex gap-2 justify-content-between">
<span>{{ tt('Holdings Date') }}</span>
<b>{{ humanReadableDateFromStandardString(slotProps.data.editorValues.value.holdingsDate.originalValue.time).value }}</b>
</div>
</div>
<h2 class="mt-5">
{{ tt('Editable Properties') }}
</h2>
<PortfolioEditor
v-model:editor-values="slotProps.data.editorValues.value"
:editor-fields="slotProps.data.editorFields.value"
/>
<div class="flex gap-3 justify-content-between">
<PVButton
icon="pi pi-trash"
class="p-button-danger p-button-outlined"
:label="tt('Delete')"
@click="() => deletePortfolio(slotProps.data.id)"
/>
<div v-tooltip.bottom="slotProps.data.saveTooltip">
<PVButton
:disabled="!slotProps.data.canSave.value"
:label="tt('Save Changes')"
icon="pi pi-save"
icon-pos="right"
@click="() => saveChanges(slotProps.data.id)"
/>
</div>
</div>
</div>
</template>
</PVDataTable>
<div class="flex flex-wrap gap-3 w-full justify-content-between">
<LinkButton
class="p-button-outlined"
icon="pi pi-arrow-left"
to="/upload"
:label="tt('Upload New Portfolios')"
/>
<!-- TODO(grady) Hook this up to something. -->
<PVButton
class="p-button-outlined"
:label="tt('How To Run a Report')"
icon="pi pi-question-circle"
icon-pos="right"
/>
</div>
<StandardDebug
:value="selectedPortfolios"
label="Selected Portfolios"
/>
<StandardDebug
:value="props.portfolios"
label="All Portfolios"
/>
</div>
</template>
Loading

0 comments on commit a399391

Please sign in to comment.