diff --git a/package-lock.json b/package-lock.json index 5b322c3..d8b92d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "bootstrap": "^5.2.3", + "file-saver": "^2.0.5", "gojs": "^2.3.6", "lodash": "^4.17.21", "react": "^18.2.0", @@ -21,6 +22,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/file-saver": "^2.0.5", "@types/jest": "^27.5.2", "@types/lodash": "^4.14.195", "@types/node": "^16.18.25", @@ -4209,6 +4211,12 @@ "@types/send": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", @@ -8561,6 +8569,11 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", diff --git a/package.json b/package.json index a1d4cd9..c990168 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "bootstrap": "^5.2.3", + "file-saver": "^2.0.5", "gojs": "^2.3.6", "lodash": "^4.17.21", "react": "^18.2.0", @@ -40,6 +41,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/file-saver": "^2.0.5", "@types/jest": "^27.5.2", "@types/lodash": "^4.14.195", "@types/node": "^16.18.25", diff --git a/src/components/App.tsx b/src/components/App.tsx index f43e543..51b3d51 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,6 +1,7 @@ import { Button, Container, Form, FormGroup, Input, Label } from 'reactstrap'; import { useMemo, useState } from 'react'; -import { stringify } from 'yaml'; +import { parse, stringify } from 'yaml'; +import { saveAs } from 'file-saver'; import { Person } from '../family.interface'; import AppContext from './AppContext'; import Family from './Family'; @@ -9,7 +10,12 @@ import ModalAddSpouse from './ModalAddSpouse'; import ModalAddTree from './ModalAddTree'; import ModalDeletePerson from './ModalDeletePerson'; import ModalEditYaml from './ModalEditYaml'; -import { deletePerson, enrichTreeData, treesToRecord } from '../family.util'; +import { + deletePerson, + enrichTreeData, + treesToRecord, + unrichTreeData, +} from '../family.util'; import { useCache } from '../useCache'; interface AppProps { @@ -51,7 +57,7 @@ function App(props: AppProps) { const [showModalEditYaml, setShowModalEditYaml] = useState(false); const toggleModalEditYaml = () => setShowModalEditYaml(!showModalEditYaml); const openModalEditYaml = () => { - setTreeYaml(stringify(trees)); + setTreeYaml(stringify(unrichTreeData(trees))); setShowModalEditYaml(true); }; @@ -63,6 +69,39 @@ function App(props: AppProps) { setShowModalDeletePerson(true); }; + const handleSave = () => { + try { + const unrichedTrees = unrichTreeData(trees); + const treeYaml = stringify(unrichedTrees as {}); + const blob = new Blob([treeYaml], { type: 'text/yaml;charset=utf-8' }); + saveAs(blob, 'family_data.yaml'); + } catch (error) { + console.error('Error saving YAML file:', error); + } + }; + + const handleLoad = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = () => { + try { + const treeYaml = reader.result as string; + // assert valid treeYaml + const rawFamilyData = parse(treeYaml); + const trees = enrichTreeData(rawFamilyData?.trees, rawFamilyData?.people); + + const unrichedTrees = unrichTreeData(trees); + setTreeYaml(stringify(unrichedTrees as {})); + setShowModalEditYaml(true); + } catch (error) { + console.error('Error loading YAML file:', error); + } + }; + reader.readAsText(file); + } + }; + const treeMap = useMemo(() => treesToRecord(trees), [trees]); const upsertPerson = (person: Person) => { @@ -146,6 +185,20 @@ function App(props: AppProps) { Delete person + + {' '} + + diff --git a/src/components/ModalEditYaml.tsx b/src/components/ModalEditYaml.tsx index 39e647b..7629724 100644 --- a/src/components/ModalEditYaml.tsx +++ b/src/components/ModalEditYaml.tsx @@ -7,6 +7,8 @@ import { } from 'react'; import { Button, + Card, + CardBody, FormFeedback, FormGroup, Input, @@ -16,9 +18,11 @@ import { ModalFooter, ModalHeader, } from 'reactstrap'; +import { Person } from '../family.interface'; import { enrichTreeData } from '../family.util'; import { parse } from 'yaml'; import AppContext from './AppContext'; +import FamilyDiagram from './FamilyDiagram'; interface ModalDeletePersonProps { treeYaml: string; @@ -30,7 +34,7 @@ interface ModalDeletePersonProps { const MIN_ROW = 3; const MAX_ROW = 20; -function ModalDeletePerson({ +function ModalEditYaml({ treeYaml, setTreeYaml, isOpen, @@ -38,28 +42,32 @@ function ModalDeletePerson({ }: ModalDeletePersonProps) { const { setTreesValue } = useContext(AppContext); const [yamlError, setYamlError] = useState(''); + const [trees, setTrees] = useState([] as Person[]); + const deferredTree = useDeferredValue(trees); const deferredTreeYaml = useDeferredValue(treeYaml); const loading = deferredTreeYaml !== treeYaml; const validForm = treeYaml && !yamlError; + const treesFromYaml = (yaml: string) => { + const rawFamilyData = parse(yaml); + return enrichTreeData(rawFamilyData?.trees, rawFamilyData?.people); + }; + useEffect(() => { try { // test parsing the yaml - enrichTreeData(parse(deferredTreeYaml), []); - if (yamlError !== '') { - setYamlError(''); - } + const trees = treesFromYaml(deferredTreeYaml); + setTrees(trees); + setYamlError(''); } catch (e: any) { setYamlError(e.message); } - }, [deferredTreeYaml, yamlError]); + }, [deferredTreeYaml]); const rows = useMemo(() => { const lines = treeYaml.split('\n').length; - if (lines < MIN_ROW) return MIN_ROW; - if (lines > MAX_ROW) return MAX_ROW; - return lines; + return Math.min(Math.max(lines, MIN_ROW), MAX_ROW); }, [treeYaml]); const handleTreeYamlChange = (event: React.ChangeEvent) => { @@ -70,7 +78,7 @@ function ModalDeletePerson({ const handleSubmit = () => { if (loading || !validForm) return; - const trees = parse(treeYaml); + const trees = treesFromYaml(treeYaml); setTreesValue(trees); toggle(); }; @@ -80,7 +88,15 @@ function ModalDeletePerson({ Edit tree - + + + + + + + + + { - const trees = enrichTreeData(familyData.trees, familyData.people); + const trees = enrichTreeData(familyData, enrichingPeople); + + it('each person has correct properties', () => { + expect(trees).toEqual(enrichedTree); + }); it('doesnt change the family data trees', () => { const oldTree = [{ @@ -95,66 +154,16 @@ describe('enrichTreeData', () => { id: 'hound', marriages: [{ spouse: { id: 'alpha' }, - children: [{ id: 'ryora' }] - }] - }] + children: [{ id: 'ryora' }], + }], + }], }, { spouse: { id: 'nala' }, - children: [{ id: 'mufasa' }] - }] + children: [{ id: 'mufasa' }], + }], }]; - expect(familyData.trees).toEqual(oldTree); - }); - - it('each person has correct properties', () => { - const tree = trees[0] as Person; - const people = familyData.people; - let person: Person = tree; - - expect(person.id).toEqual('satyr'); - expect(person.code).toEqual('1'); - expect(person.name).toEqual(people.satyr.name); - expect(person.birthdate).toEqual(people.satyr.birthdate); - expect(person.sex).toEqual(people.satyr.sex); - - person = tree.marriages[0].spouse; - expect(person.id).toEqual('surtr'); - expect(person.code).toEqual('1M1'); - expect(person.name).toEqual(people.surtr.name); - expect(person.birthdate).toEqual(people.surtr.birthdate); - expect(person.deathdate).toEqual(people.surtr.deathdate); - expect(person.sex).toEqual(people.surtr.sex); - - person = tree.marriages[0].children[0]; - expect(person.id).toEqual('hound'); - expect(person.code).toEqual('1.101'); - expect(person.name).toEqual(people.hound.name); - expect(person.birthdate).toEqual(people.hound.birthdate); - expect(person.sex).toEqual(people.hound.sex); - - person = tree.marriages[0].children[0].marriages[0].spouse; - expect(person.id).toEqual('alpha'); - expect(person.code).toEqual('1.101M'); - expect(person.name).toEqual(people.alpha.name); - expect(person.birthdate).toEqual(people.alpha.birthdate); - expect(person.sex).toEqual(people.alpha.sex); - - person = tree.marriages[0].children[0].marriages[0].children[0]; - expect(person.id).toEqual('ryora'); - expect(person.code).toEqual('1.101.01'); - expect(person.name).toEqual(people.ryora.name); - expect(person.birthdate).toEqual(people.ryora.birthdate); - expect(person.sex).toEqual(people.ryora.sex); - - person = tree.marriages[1].spouse; - expect(person.id).toEqual('nala'); - expect(person.code).toEqual('1M2'); - expect(person.sex).toEqual(people.nala.sex); - - person = tree.marriages[1].children[0]; - expect(person.id).toEqual('mufasa'); - expect(person.code).toEqual('1.201'); + expect(familyData).toEqual(oldTree); }); test('empty data returns empty array', () => { @@ -166,13 +175,13 @@ describe('enrichTreeData', () => { {}, { id: 'satyr', - marriages: [{}] + marriages: [{}], }, { id: 'hound', marriages: [{ - children: [{ id: 'ryora' }, { name: 'meta' }] - }] + children: [{ id: 'ryora' }, { name: 'meta' }], + }], }, ]; const trees = enrichTreeData(familyData, []); @@ -238,11 +247,60 @@ describe('enrichTreeData', () => { test('param is null', () => { expect(enrichTreeData(null, [])).toEqual([]); }); - }) + }); +}); + +describe('unrichTreeData', () => { + describe('single tree', () => { + const { trees, people } = unrichTreeData(enrichedTree); + + test('each person has correct properties', () => { + expect(trees).toEqual(familyData); + expect(people).toEqual({ + satyr: { + code: '1', + name: 'Muhammad Satyr', + sex: 'M', + birthdate: '1982-02-13', + }, + surtr: { + code: '1M1', + name: 'Amalia Surtrain', + sex: 'F', + birthdate: '1987-11-21', + deathdate: '2021-01-13', + }, + hound: { + code: '1.101', + name: 'Muhammad Hound', + sex: 'M', + birthdate: '2007-06-09', + }, + alpha: { + code: '1.101M', + name: 'Siti Alpha', + sex: 'F', + birthdate: '2008-06-19', + }, + ryora: { + code: '1.101.01', + name: 'Muhammad Ryora', + sex: 'M', + birthdate: '2030-03-12', + }, + nala: { code: '1M2', sex: 'F' }, + mufasa: { code: '1.201' }, + }); + }); + + test('should be reverted to rich tree data', () => { + expect(enrichTreeData(trees, people)).toEqual(enrichedTree); + }); + }); }); describe('treesToPersonNode', () => { - const trees = enrichTreeData(familyData.trees, familyData.people); + const trees = enrichTreeData(familyData, enrichingPeople); test('without depth', () => { const nodes = treesToPersonNode(trees); @@ -270,7 +328,7 @@ describe('treesToPersonNode', () => { describe('with double trees', () => { test('without depth', () => { - const trees = enrichTreeData(doubleFamilyData.trees, familyData.people); + const trees = enrichTreeData(doubleFamilyData.trees, enrichingPeople); const nodes = treesToPersonNode(trees); expect(nodes).toEqual([ { key: 'satyr', name: 'satyr', s: 'M', attributes: [], spouses: ['surtr', 'nala'] }, @@ -288,7 +346,7 @@ describe('treesToPersonNode', () => { }); describe('explodeTrees', () => { - const trees = enrichTreeData(familyData.trees, familyData.people); + const trees = enrichTreeData(familyData, enrichingPeople); test('without depth', () => { const people = explodeTrees(trees); @@ -304,7 +362,7 @@ describe('explodeTrees', () => { }); describe('treesToRecord', () => { - const trees = enrichTreeData(familyData.trees, familyData.people); + const trees = enrichTreeData(familyData, enrichingPeople); const record = treesToRecord(trees); it('has all of the people', () => { @@ -321,7 +379,7 @@ describe('treesToRecord', () => { }); describe('deletePerson', () => { - const trees = enrichTreeData(familyData.trees, []); + const trees = enrichTreeData(familyData, []); test('person is root', () => { expect(deletePerson(trees, 'satyr')).toEqual([]); @@ -333,11 +391,11 @@ describe('deletePerson', () => { code: '1', marriages: [{ spouse: { id: 'surtr', code: '1M1', marriages: [] }, - children: [] + children: [], }, { spouse: { id: 'nala', code: '1M2', marriages: [] }, - children: [{ id: 'mufasa', code: '1.201', marriages: [] }] - }] + children: [{ id: 'mufasa', code: '1.201', marriages: [] }], + }], }]); expect(deletePerson(trees, 'ryora')).toEqual([{ @@ -350,13 +408,13 @@ describe('deletePerson', () => { code: '1.101', marriages: [{ spouse: { id: 'alpha', code: '1.101M', marriages: [] }, - children: [] - }] - }] + children: [], + }], + }], }, { spouse: { id: 'nala', code: '1M2', marriages: [] }, - children: [{ id: 'mufasa', code: '1.201', marriages: [] }] - }] + children: [{ id: 'mufasa', code: '1.201', marriages: [] }], + }], }]); }); @@ -366,8 +424,8 @@ describe('deletePerson', () => { code: '1', marriages: [{ spouse: { id: 'nala', code: '1M2', marriages: [] }, - children: [{ id: 'mufasa', code: '1.201', marriages: [] }] - }] + children: [{ id: 'mufasa', code: '1.201', marriages: [] }], + }], }]); expect(deletePerson(trees, 'nala')).toEqual([{ @@ -380,10 +438,10 @@ describe('deletePerson', () => { code: '1.101', marriages: [{ spouse: { id: 'alpha', code: '1.101M', marriages: [] }, - children: [{ id: 'ryora', code: '1.101.01', marriages: [] }] - }] - }] - }] + children: [{ id: 'ryora', code: '1.101.01', marriages: [] }], + }], + }], + }], }]); }); }); diff --git a/src/family.util.tsx b/src/family.util.tsx index a70156f..94453f8 100644 --- a/src/family.util.tsx +++ b/src/family.util.tsx @@ -7,6 +7,67 @@ export function enrichTreeData( ): Person[] { if (!Array.isArray(trees)) return []; + // create and assign default required value for Person + const createPerson = (person: any): Person => { + return { + ...person, + marriages: + person.marriages?.filter((f: any) => !!Object.keys(f).length) || [], + }; + }; + + // Enrich the person from the people. Try to make person unchanged. + const enrichPerson = ( + person: any, + people: Record + ): Person | null => { + if (!person || typeof person.id !== 'string') return null; + + const p = createPerson({ + id: person.id, + code: person.code, + name: person.name, + sex: person.sex, + birthplace: person.birthplace, + birthdate: person.birthdate, + deathdate: person.deathdate, + phone: person.phone, + email: person.email, + ig: person.ig, + address: person.address, + marriages: person.marriages, + }); + const detail = people[p.id]; + if (detail) { + Object.assign(p, detail); + } + + p.marriages = p.marriages.map((marriage: Marriage, i: number) => { + const married = p.marriages.length > 1 ? String(i + 1) : ''; + const spouse = + enrichPerson(marriage.spouse, people) || + createPerson({ id: p.id + '__m' }); + + const children = (marriage.children || []) + .map((child: Person, i: number) => { + child = { ...child }; + child.code ||= + p.code + '.' + married + String(i + 1).padStart(2, '0'); + return enrichPerson(child, people); + }) + .flatMap(f => (!!f ? [f] : [])); + + return { + spouse: { + ...spouse, + code: spouse.code || p.code + 'M' + married, + }, + children, + } as Marriage; + }); + return p; + }; + const enrichedTrees = trees .map((person: any, i: number) => { if (!person || typeof person !== 'object') return null; @@ -37,65 +98,43 @@ export function enrichTreeData( return enrichedTrees; } -// Enrich the person from the people. Try to make person unchanged. -const enrichPerson = ( - person: any, - people: Record -): Person | null => { - if (!person || typeof person.id !== 'string') return null; - - const p = createPerson({ - id: person.id, - code: person.code, - name: person.name, - sex: person.sex, - birthplace: person.birthplace, - birthdate: person.birthdate, - deathdate: person.deathdate, - phone: person.phone, - email: person.email, - ig: person.ig, - address: person.address, - marriages: person.marriages, - }); - const detail = people[p.id]; - if (detail) { - Object.assign(p, detail); - } - - p.marriages = p.marriages.map((marriage: Marriage, i: number) => { - const married = p.marriages.length > 1 ? String(i + 1) : ''; - const spouse = - enrichPerson(marriage.spouse, people) || - createPerson({ id: p.id + '__m' }); - - const children = (marriage.children || []) - .map((child: Person, i: number) => { - child = { ...child }; - child.code ||= p.code + '.' + married + String(i + 1).padStart(2, '0'); - return enrichPerson(child, people); - }) - .flatMap(f => (!!f ? [f] : [])); +// Undo enrichTreeData, separate the enriched tree into simple trees and +// people details. Simple trees only contains id, marriage, and children +// while people contains any other people details including keys. +export function unrichTreeData(enrichedTrees: Person[]): { + trees: any[]; + people: Record; +} { + const people: Record = {}; + + const revertPersonData = (person: Person): any => { + if (!person || !person.id) return null; + + const { id, marriages, ...details } = person; + people[id] = { ...details }; + + const mm = marriages.map((marriage: Marriage) => { + return { + spouse: revertPersonData(marriage.spouse), + children: marriage.children.map(revertPersonData), + }; + }); + // return { id, marriages: mm }; + return { id, marriages: mm.length ? mm : undefined }; + }; - return { - spouse: { - ...spouse, - code: spouse.code || p.code + 'M' + married, - }, - children, - } as Marriage; - }); - return p; -}; + const trees = enrichedTrees.map(revertPersonData); + return { trees, people }; +} // Breakdown the person's family tree into array. export function explodeTrees(trees: Person[], depth: number = -1): Person[] { const people: Person[] = []; if (depth === 0) return people; - trees.forEach(function (person) { + trees.forEach(person => { people.push(person); - person.marriages.forEach(function (marriage: Marriage) { + person.marriages.forEach((marriage: Marriage) => { people.push(...explodeTrees([marriage.spouse], depth - 1)); people.push(...explodeTrees(marriage.children, depth - 1)); }); @@ -106,9 +145,9 @@ export function explodeTrees(trees: Person[], depth: number = -1): Person[] { // Convert person's family tree into Record export function treesToRecord(trees: Person[]): Record { const record: Record = {}; - trees.forEach(function (person) { + trees.forEach(person => { record[person.id] = person; - person.marriages.forEach(function (marriage) { + person.marriages.forEach(marriage => { Object.assign(record, treesToRecord([marriage.spouse])); Object.assign(record, treesToRecord(marriage.children)); }); @@ -136,68 +175,59 @@ export function deletePerson(trees: Person[], id: string): Person[] { return updatedTree; } -// create and assign default required value for Person -function createPerson(data: any): Person { - return { - ...data, - marriages: - data.marriages?.filter((f: any) => !!Object.keys(f).length) || [], - }; -} - // convert Person[] to PersonNode[] export function treesToPersonNode( trees: Person[], depth: number = 0 ): PersonNode[] { + const personToPersonNode = ( + nodes: Record, + person: Person, + parents: Person[], + level: number = 1, + depth: number = 0 + ) => { + if (!nodes[person.id]) { + nodes[person.id] = { + key: person.id, + name: person.id, + s: person.sex ?? 'M', + attributes: [], + spouses: [], + }; + } + const node: PersonNode = nodes[person.id]; + + if (parents.length === 2) { + if (parents[0].sex === 'F') { + node.mother = parents[0].id; + node.father = parents[1].id; + } else { + node.father = parents[0].id; + node.mother = parents[1].id; + } + } + + if (person.deathdate) { + node.attributes.push('S'); + } + + if (depth === 0 || level < depth) { + person.marriages.forEach(marriage => { + const spouse = marriage.spouse; + const parents = [person, spouse]; + node.spouses.push(spouse.id); + personToPersonNode(nodes, spouse, [], level, depth); + marriage.children.forEach(child => { + personToPersonNode(nodes, child, parents, level + 1, depth); + }); + }); + } + return nodes; + }; + const nodes: Record = {}; trees.forEach(person => personToPersonNode(nodes, person, [], 1, depth)); return Object.values(nodes); } - -function personToPersonNode( - nodes: Record, - person: Person, - parents: Person[], - level: number = 1, - depth: number = 0 -) { - if (!nodes[person.id]) { - nodes[person.id] = { - key: person.id, - name: person.id, - s: person.sex ?? 'M', - attributes: [], - spouses: [], - }; - } - const node: PersonNode = nodes[person.id]; - - if (parents.length === 2) { - if (parents[0].sex === 'F') { - node.mother = parents[0].id; - node.father = parents[1].id; - } else { - node.father = parents[0].id; - node.mother = parents[1].id; - } - } - - if (person.deathdate) { - node.attributes.push('S'); - } - - if (depth === 0 || level < depth) { - person.marriages.forEach(function (marriage) { - const spouse = marriage.spouse; - const parents = [person, spouse]; - node.spouses.push(spouse.id); - personToPersonNode(nodes, spouse, [], level, depth); - marriage.children.forEach(function (child) { - personToPersonNode(nodes, child, parents, level + 1, depth); - }); - }); - } - return nodes; -}