OpenVAA / voting-advice-application

An open-source platform for creating Voting Advice Applications (VAAs)
https://demo.openvaa.org/
GNU General Public License v3.0
8 stars 0 forks source link

refactor: Svelte state management #536

Open kaljarv opened 1 month ago

kaljarv commented 1 month ago

Also, consider whether to prefer stores over contexts, e.g. in temporary preferences regarding questions and categories. (I'm doing this atm.)

Things to consider

Requirements

[^1] This is currently implemented in a possibly anti-pattern-y way such that the +layout/page.server.ts: load functions load data from the getData api and return it as part of the page data. The stores on CSR then access this data by subscribing to the page store, which holds all data thus loaded in page.data, and sometimes process this data a bit.

Another possibility might be to still load the data with the +layout.server.ts files but then always accompany them with a universal +layout.ts file to process the data – or use only the latter (which would require creating making the data API universally available but it's most likely done anyway). See Sveltekit docs.

anolpe commented 3 weeks ago

It seems like we can’t use only stores when using SSR (docs, this discussion). In the candidate app all stores are contained in one context that is set on the root layout of the candidate app. So maybe one solution would be to make something similar in the voter app.

With very temporary user data in addition to variables or stores, maybe snapshots (https://kit.svelte.dev/docs/snapshots) could be used, at least in some situations.

kaljarv commented 3 weeks ago

interface VoterContext {
  candidates: Writable<CandidateProps[] | undefined>;
  candidateData: Promise<CandidateDatum[]>;
}

candidateData.then(d => candidates.set(convertPOJOtoProps(d)));

// In Svelte 5, this we'll possibly refactor this to
candidateData: Promise<CandidateDatum[]> | undefined = undefined;
candidates = $derived.by(async () => {
  if (!candidateData) return Promise.resolve(undefined);
  return candidateData.then(d => convertPOJOtoProps(d));
});

// In +layout.server.ts

load() {
  return {candidateData: DataProvider.getCandidates()};
}

// In +layout.ts / +layout.svelte

export let data; // Or await parent if +layout.ts
ctx.candidateData = data.candidateData;

// In .svelte

{#if ctx.candidates}
  Render
{:else}
  Loading
{/if}
kaljarv commented 3 weeks ago

To do

kaljarv commented 2 weeks ago

Notes

<form action="/api/feedback" use:enhance={() => async ({results}) => await applyAction(results)}> // ...

kaljarv commented 2 weeks ago

The app functions

A quick list of the main functionalities of the app so those can also be divided into contexts or such.

Options for DataProvider server-dependence

Scenarios:

Implementation:

Universal fetching with dynamic API

In final consumers: components, pages, derived stores

// EntityDetails.svelte
<script type="ts">
export let content: MaybeRanked<Entity>;
const { entity, match } = parseWrapped(content);
</script>

<h1>{ entity.name }</h1>

{#if match}
  <Match {match}/>
{/if}

<EntityOpinions {entity}/>

// EntityOpinions.svelte
<script type="ts">
import { getContext } from 'svelte';

export let entity: Entity;

const { questionCategories } = getContext<VAADataContext>('vaa-data').data;
const voter = getContext<VoterContext>('voter').voter;
</script>

// There should be filters for showing questions, e.g. related to only a specific election
{#each $questionCategories as category}
  <h2><CategoryTag {category}/></h2>
  {#each category.questions as question}
    <Question 
      variant="variant"
      {question}
      {entity}
      referenceEntity={$voter}/>
  {/each}
{/each}

// Question.svelte
<script type="ts">
export let variant: QuestionVariant = 'voterAnswer';
export let question: Question;
export let entity: Entity | Voter; // Voter may be also a subtype of Entity
export let referenceEntity: Entity | Voter;
</script>

<h1>{ question.text }</h1>
<AnsweringButtons
  mode={variant === 'voterAnswer' ? 'answer' : 'display'}
  selected="entity.getAnswer(question)"
  reference="referenceEntity.getAnswer(question)"/>
// $voter/context.ts

export interface VoterContext {
  /** A reference to the data root from VAADataContext, supplied when the context is created. */
  vaaData: DataRoot;
  matchingResults: ?;
  voter: VoterStore;
}

export interface VoterStore {
  getAnswer: (question: Question) => AnswerValue;

}

Common to both types:

// +layout.svelte OR derived store
import { getContext } from 'svelte';

const ctx = getContext<GlobalContext>('global');

export let data: LayoutData;

const { candidatesData } = data;

ctx.data.provideCandidates(candidatesData);

// +layout.ts
import { dataProvider } from '$lib/api';

export async function load({fetch}) {
  const dp = await dataProvider;
  dp.fetch = fetch;
  return { candidatesData: dp.getCandidates() };
}

Server-independent:

// $lib/api/dataProvider/strapi/strapiDataProvider.ts
export async function getCandidates(options?): Promise<CandidateDatum[]>  {
  return dataProvider.fetch(
    PUBLIC_API + '/api/candidates'
  ).then(d => convertStrapiToVAAData(d));
}

Server-dependent:

// Generic interface for server-dependent data providers
// $lib/server/api/dataProvider/serverDependentDataProvider.ts
export async function getCandidates(options?): Promise<CandidateDatum[]> {
  return dataProvider.fetch(
    '/api/data/candidates'
  );
}

// Generic api route for server-dependent data providers
// /routes/api/data/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { serverDataProvider } from '$server/api';

export const GET: RequestHandler = async ({ url }) => { 
  const collection = url.searchParams.get('collection');
  if (collection === 'candidates') {
    const data = await serverDataProvider.then({getCandidates} => getCandidates());
  }
  return new json(data);
}

// Data provider implementation
// $lib/server/api/dataProvider/local/localServerDataProvider.ts
import { read } from '$app/server';

export async function getCandidates(options?): Promise<CandidateDatum[]> {
  return read(DATA_FOLDER + 'candidates.json')
    .json().then(d => convertLocalPOJOToVAAData(d));
}

How to only load one entity's data when linking directly?

Hard to do if on /results/canditate/5: results layout loads all candidates and 5 just picks one from them.

Possible option:

  1. The user route is /results/canditate/5 or even /constituency/1/results/canditate/5 and this uses a layout to load all entities.
  2. A sharing button (and SE index) uses /link/canditate/5 or similar, which only loads the necessary candidate. This way we also bypass problems with including constituency and other details in the url.

How to store user selections that affect the data to be loaded?

Selections:

Options: