diff --git a/pkgs/clan-app/process-compose-2d.yaml b/pkgs/clan-app/process-compose-2d.yaml new file mode 100644 index 000000000..21bf4f224 --- /dev/null +++ b/pkgs/clan-app/process-compose-2d.yaml @@ -0,0 +1,39 @@ +version: "0.5" + +processes: + # App Dev + + clan-app-ui: + namespace: "app" + command: | + cd $(git rev-parse --show-toplevel)/pkgs/clan-app/ui-2d + npm install + vite + ready_log_line: "VITE" + + clan-app: + namespace: "app" + command: | + cd $(git rev-parse --show-toplevel)/pkgs/clan-app + ./bin/clan-app --debug --content-uri http://localhost:3000 + depends_on: + clan-app-ui: + condition: "process_log_ready" + is_foreground: true + ready_log_line: "Debug mode enabled" + + # Storybook Dev + + storybook: + namespace: "storybook" + command: | + cd $(git rev-parse --show-toplevel)/pkgs/clan-app/ui-2d + npm run storybook-dev -- --ci + ready_log_line: "started" + + luakit: + namespace: "storybook" + command: "luakit http://localhost:6006" + depends_on: + storybook: + condition: "process_log_ready" diff --git a/pkgs/clan-app/ui-2d/.fonts b/pkgs/clan-app/ui-2d/.fonts new file mode 120000 index 000000000..1622910be --- /dev/null +++ b/pkgs/clan-app/ui-2d/.fonts @@ -0,0 +1 @@ +../ui/.fonts \ No newline at end of file diff --git a/pkgs/clan-app/ui-2d/.gitignore b/pkgs/clan-app/ui-2d/.gitignore new file mode 100644 index 000000000..eed390273 --- /dev/null +++ b/pkgs/clan-app/ui-2d/.gitignore @@ -0,0 +1,5 @@ +app/api +app/.fonts + +.vite +storybook-static \ No newline at end of file diff --git a/pkgs/clan-app/ui-2d/.storybook b/pkgs/clan-app/ui-2d/.storybook new file mode 120000 index 000000000..ab1c8fa2a --- /dev/null +++ b/pkgs/clan-app/ui-2d/.storybook @@ -0,0 +1 @@ +../ui/.storybook \ No newline at end of file diff --git a/pkgs/clan-app/ui-2d/.vscode/settings.json b/pkgs/clan-app/ui-2d/.vscode/settings.json new file mode 100644 index 000000000..f9c1a6bc2 --- /dev/null +++ b/pkgs/clan-app/ui-2d/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "tailwindCSS.experimental.classRegex": [ + ["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"] + ], + "editor.wordWrap": "on" +} diff --git a/pkgs/clan-app/ui-2d/README.md b/pkgs/clan-app/ui-2d/README.md new file mode 100644 index 000000000..cd52c6e45 --- /dev/null +++ b/pkgs/clan-app/ui-2d/README.md @@ -0,0 +1,43 @@ +## Usage + +Those templates dependencies are maintained via [pnpm](https://pnpm.io) via +`pnpm up -Lri`. + +This is the reason you see a `pnpm-lock.yaml`. That being said, any package +manager will work. This file can be safely be removed once you clone a template. + +```bash +$ npm install # or pnpm install or yarn install +``` + +### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) + +## Available Scripts + +In the project directory, you can run: + +### `npm run dev` or `npm start` + +Runs the app in the development mode.
Open +[http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+ +### `npm run build` + +Builds the app for production to the `dist` folder.
It correctly bundles +Solid in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
Your app is +ready to be deployed! + +### `npm run storybook` + +Starts an instance of [storybook](https://storybook.js.org/). + +For more info on how to write stories, please [see here](https://storybook.js.org/docs). + +## Deployment + +You can deploy the `dist` folder to any static host provider (netlify, surge, +now, etc.) diff --git a/pkgs/clan-app/ui-2d/api b/pkgs/clan-app/ui-2d/api new file mode 120000 index 000000000..75dd0c9cd --- /dev/null +++ b/pkgs/clan-app/ui-2d/api @@ -0,0 +1 @@ +../ui/api \ No newline at end of file diff --git a/pkgs/clan-app/ui-2d/eslint.config.mjs b/pkgs/clan-app/ui-2d/eslint.config.mjs new file mode 100644 index 000000000..44b2bae56 --- /dev/null +++ b/pkgs/clan-app/ui-2d/eslint.config.mjs @@ -0,0 +1,37 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import tailwind from "eslint-plugin-tailwindcss"; +import pluginQuery from "@tanstack/eslint-plugin-query"; +import { globalIgnores } from "eslint/config"; + +const config = tseslint.config( + eslint.configs.recommended, + ...pluginQuery.configs["flat/recommended"], + ...tseslint.configs.strict, + ...tseslint.configs.stylistic, + ...tailwind.configs["flat/recommended"], + globalIgnores(["src/types/index.d.ts"]), + { + rules: { + "tailwindcss/no-contradicting-classname": [ + "error", + { + callees: ["cx"], + }, + ], + "tailwindcss/no-custom-classname": [ + "error", + { + callees: ["cx"], + whitelist: ["material-icons"], + }, + ], + // TODO: make this more strict by removing later + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-non-null-assertion": "off", + }, + }, +); + +export default config; diff --git a/pkgs/clan-app/ui-2d/gtk.webview.js b/pkgs/clan-app/ui-2d/gtk.webview.js new file mode 100644 index 000000000..e75eecc12 --- /dev/null +++ b/pkgs/clan-app/ui-2d/gtk.webview.js @@ -0,0 +1,97 @@ +/** + * This script generates a custom index.html file for the webview UI. + * It reads the manifest.json file generated by Vite and uses it to generate the HTML file. + * It also processes the CSS files to rewrite the URLs in the CSS files to match the new location of the assets. + * The script is run after the Vite build is complete. + * + * This is necessary because the webview UI is loaded from the local file system and the URLs in the CSS files need to be rewritten to match the new location of the assets. + * The generated index.html file is then used as the entry point for the webview UI. + */ +import fs from "node:fs"; +import postcss from "postcss"; +import path from "node:path"; +import css_url from "postcss-url"; + +const distPath = path.resolve("dist"); +const manifestPath = path.join(distPath, ".vite/manifest.json"); +const outputPath = path.join(distPath, "index.html"); + +fs.readFile(manifestPath, { encoding: "utf8" }, (err, data) => { + if (err) { + return console.error("Failed to read manifest:", err); + } + + const manifest = JSON.parse(data); + /** @type {{ file: string; name: string; src: string; isEntry: bool; css: string[]; } []} */ + const assets = Object.values(manifest); + + console.log(`Generate custom index.html from ${manifestPath} ...`); + // Start with a basic HTML structure + let htmlContent = ` + + + + + Webview UI`; + + // Add linked stylesheets + assets.forEach((asset) => { + // console.log(asset); + if (asset.src === "index.html") { + asset.css.forEach((cssEntry) => { + // css to be processed + + const css = fs.readFileSync(`dist/${cssEntry}`, "utf8"); + + // process css + postcss() + .use( + css_url({ + url: (asset, dir) => { + const res = path.basename(asset.url); + console.log(`Rewriting CSS url(): ${asset.url} to ${res}`); + return res; + }, + }), + ) + .process(css, { + from: `dist/${cssEntry}`, + to: `dist/${cssEntry}`, + }) + .then((result) => { + fs.writeFileSync(`dist/${cssEntry}`, result.css, "utf8"); + }); + + // Extend the HTML content with the linked stylesheet + console.log(`Relinking html css stylesheet: ${cssEntry}`); + htmlContent += `\n `; + }); + } + }); + + htmlContent += ` + + +
+ `; + // Add scripts + assets.forEach((asset) => { + if (asset.file.endsWith(".js")) { + console.log(`Relinking js script: ${asset.file}`); + htmlContent += `\n `; + } + }); + + htmlContent += ` + +`; + + // Write the HTML file + fs.writeFile(outputPath, htmlContent, (err) => { + if (err) { + console.error("Failed to write custom index.html:", err); + } else { + console.log("Custom index.html generated successfully!"); + } + }); +}); diff --git a/pkgs/clan-app/ui-2d/icons b/pkgs/clan-app/ui-2d/icons new file mode 120000 index 000000000..3aea88b08 --- /dev/null +++ b/pkgs/clan-app/ui-2d/icons @@ -0,0 +1 @@ +../ui/icons \ No newline at end of file diff --git a/pkgs/clan-app/ui-2d/index.html b/pkgs/clan-app/ui-2d/index.html new file mode 100644 index 000000000..0c649c3fb --- /dev/null +++ b/pkgs/clan-app/ui-2d/index.html @@ -0,0 +1,14 @@ + + + + Solid App + + + + + +
+ + + + diff --git a/pkgs/clan-app/ui-2d/postcss.config.js b/pkgs/clan-app/ui-2d/postcss.config.js new file mode 100644 index 000000000..a982c6414 --- /dev/null +++ b/pkgs/clan-app/ui-2d/postcss.config.js @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/pkgs/clan-app/ui-2d/prettier.config.js b/pkgs/clan-app/ui-2d/prettier.config.js new file mode 100644 index 000000000..2aa318a4a --- /dev/null +++ b/pkgs/clan-app/ui-2d/prettier.config.js @@ -0,0 +1,9 @@ +/** + * @see https://prettier.io/docs/en/configuration.html + * @type {import("prettier").Config} + */ +const config = { + trailingComma: "all", +}; + +export default config; diff --git a/pkgs/clan-app/ui-2d/src/Form/base/index.tsx b/pkgs/clan-app/ui-2d/src/Form/base/index.tsx new file mode 100644 index 000000000..ba62cd2c9 --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/Form/base/index.tsx @@ -0,0 +1,127 @@ +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import type { + ComputePositionConfig, + ComputePositionReturn, + ReferenceElement, +} from "@floating-ui/dom"; +import { computePosition } from "@floating-ui/dom"; + +export interface UseFloatingOptions< + R extends ReferenceElement, + F extends HTMLElement, +> extends Partial { + whileElementsMounted?: ( + reference: R, + floating: F, + update: () => void, + ) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + void | (() => void); +} + +interface UseFloatingState extends Omit { + x?: number | null; + y?: number | null; +} + +export interface UseFloatingResult extends UseFloatingState { + update(): void; +} + +export function useFloating( + reference: () => R | undefined | null, + floating: () => F | undefined | null, + options?: UseFloatingOptions, +): UseFloatingResult { + const placement = () => options?.placement ?? "bottom"; + const strategy = () => options?.strategy ?? "absolute"; + + const [data, setData] = createSignal({ + x: null, + y: null, + placement: placement(), + strategy: strategy(), + middlewareData: {}, + }); + + const [error, setError] = createSignal<{ value: unknown } | undefined>(); + + createEffect(() => { + const currentError = error(); + if (currentError) { + throw currentError.value; + } + }); + + const version = createMemo(() => { + reference(); + floating(); + return {}; + }); + + function update() { + const currentReference = reference(); + const currentFloating = floating(); + + if (currentReference && currentFloating) { + const capturedVersion = version(); + computePosition(currentReference, currentFloating, { + middleware: options?.middleware, + placement: placement(), + strategy: strategy(), + }).then( + (currentData) => { + // Check if it's still valid + if (capturedVersion === version()) { + setData(currentData); + } + }, + (err) => { + setError(err); + }, + ); + } + } + + createEffect(() => { + const currentReference = reference(); + const currentFloating = floating(); + + placement(); + strategy(); + + if (currentReference && currentFloating) { + if (options?.whileElementsMounted) { + const cleanup = options.whileElementsMounted( + currentReference, + currentFloating, + update, + ); + + if (cleanup) { + onCleanup(cleanup); + } + } else { + update(); + } + } + }); + + return { + get x() { + return data().x; + }, + get y() { + return data().y; + }, + get placement() { + return data().placement; + }, + get strategy() { + return data().strategy; + }, + get middlewareData() { + return data().middlewareData; + }, + update, + }; +} diff --git a/pkgs/clan-app/ui-2d/src/Form/base/label.tsx b/pkgs/clan-app/ui-2d/src/Form/base/label.tsx new file mode 100644 index 000000000..03873cced --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/Form/base/label.tsx @@ -0,0 +1,15 @@ +import type { JSX } from "solid-js"; +interface LabelProps { + label: JSX.Element; + required?: boolean; +} +export const Label = (props: LabelProps) => ( + + {props.label} + +); diff --git a/pkgs/clan-app/ui-2d/src/Form/fields/FormSection.tsx b/pkgs/clan-app/ui-2d/src/Form/fields/FormSection.tsx new file mode 100644 index 000000000..d3633f0b2 --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/Form/fields/FormSection.tsx @@ -0,0 +1,8 @@ +import { JSX } from "solid-js"; + +interface FormSectionProps { + children: JSX.Element; +} +export const FormSection = (props: FormSectionProps) => { + return
{props.children}
; +}; diff --git a/pkgs/clan-app/ui-2d/src/Form/fields/Select.tsx b/pkgs/clan-app/ui-2d/src/Form/fields/Select.tsx new file mode 100644 index 000000000..47a048f83 --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/Form/fields/Select.tsx @@ -0,0 +1,274 @@ +import { + createUniqueId, + createSignal, + Show, + type JSX, + For, + createMemo, + Accessor, +} from "solid-js"; +import { Portal } from "solid-js/web"; +import { useFloating } from "../base"; +import { autoUpdate, flip, hide, offset, shift, size } from "@floating-ui/dom"; +import { Button } from "../../components/Button/Button"; +import { + InputBase, + InputError, + InputLabel, + InputLabelProps, +} from "@/src/components/inputBase"; +import { FieldLayout } from "./layout"; +import Icon from "@/src/components/icon"; +import { useContext } from "corvu/dialog"; + +export interface Option { + value: string; + label: string; + disabled?: boolean; +} + +interface SelectInputpProps { + value: string[] | string; + selectProps?: JSX.InputHTMLAttributes; + options: Option[]; + label: JSX.Element; + labelProps?: InputLabelProps; + helperText?: JSX.Element; + error?: string; + required?: boolean; + type?: string; + inlineLabel?: JSX.Element; + class?: string; + adornment?: { + position: "start" | "end"; + content: JSX.Element; + }; + disabled?: boolean; + placeholder?: string; + multiple?: boolean; + loading?: boolean; + portalRef?: Accessor; +} + +export function SelectInput(props: SelectInputpProps) { + const dialogContext = (dialogContextId?: string) => + useContext(dialogContextId); + + const _id = createUniqueId(); + + const [reference, setReference] = createSignal(); + const [floating, setFloating] = createSignal(); + + // `position` is a reactive object. + const position = useFloating(reference, floating, { + placement: "bottom-start", + + // pass options. Ensure the cleanup function is returned. + whileElementsMounted: (reference, floating, update) => + autoUpdate(reference, floating, update, { + animationFrame: true, + }), + middleware: [ + size({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + minWidth: `${rects.reference.width}px`, + }); + }, + }), + offset({ mainAxis: 2 }), + shift(), + flip(), + hide({ + strategy: "referenceHidden", + }), + ], + }); + + // Create values list + const getValues = createMemo(() => { + return Array.isArray(props.value) + ? (props.value as string[]) + : typeof props.value === "string" + ? [props.value] + : []; + }); + + // const getSingleValue = createMemo(() => { + // const values = getValues(); + // return values.length > 0 ? values[0] : ""; + // }); + + const handleClickOption = (opt: Option) => { + if (!props.multiple) { + // @ts-expect-error: fieldName is not known ahead of time + props.selectProps.onInput({ + currentTarget: { + value: opt.value, + }, + }); + return; + } + let currValues = getValues(); + + if (currValues.includes(opt.value)) { + currValues = currValues.filter((o) => o !== opt.value); + } else { + currValues.push(opt.value); + } + // @ts-expect-error: fieldName is not known ahead of time + props.selectProps.onInput({ + currentTarget: { + options: currValues.map((value) => ({ + value, + selected: true, + disabled: false, + })), + }, + }); + }; + + return ( + <> + } + label={ + + {props.label} + + } + field={ + { + const popover = document.getElementById(_id); + if (popover) { + popover.togglePopover(); // Show or hide the popover + } + }} + type="button" + class="flex w-full items-center gap-2" + formnovalidate + // TODO: Use native popover once Webkit supports it within
+ // popovertarget={_id} + // popovertargetaction="toggle" + > + + {props.adornment?.content} + + {props.inlineLabel} +
+ + + {(item) => ( +
+ {item} + + + +
+ )} +
+
+
+ + {props.adornment?.content} + + + + } + /> + } + /> + + +
+
    + + + {(opt) => ( + <> +
  • + +
  • + + )} +
    +
    +
+
+
+ + ); +} diff --git a/pkgs/clan-app/ui-2d/src/Form/fields/TextInput.tsx b/pkgs/clan-app/ui-2d/src/Form/fields/TextInput.tsx new file mode 100644 index 000000000..24f3ca357 --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/Form/fields/TextInput.tsx @@ -0,0 +1,58 @@ +import { splitProps, type JSX } from "solid-js"; +import { + InputBase, + InputError, + InputLabel, + InputVariant, +} from "@/src/components/inputBase"; +import { Typography } from "@/src/components/Typography"; +import { FieldLayout } from "./layout"; + +interface TextInputProps { + // Common + error?: string; + required?: boolean; + disabled?: boolean; + // Passed to input + value: string; + inputProps?: JSX.InputHTMLAttributes; + placeholder?: string; + variant?: InputVariant; + // Passed to label + label: JSX.Element; + help?: string; + // Passed to layout + class?: string; +} + +export function TextInput(props: TextInputProps) { + const [layoutProps, rest] = splitProps(props, ["class"]); + return ( + + {props.label} + + } + field={ + + } + error={props.error && } + {...layoutProps} + /> + ); +} diff --git a/pkgs/clan-app/ui-2d/src/Form/fields/index.ts b/pkgs/clan-app/ui-2d/src/Form/fields/index.ts new file mode 100644 index 000000000..46efa5eec --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/Form/fields/index.ts @@ -0,0 +1,2 @@ +export * from "./FormSection"; +export * from "./TextInput"; diff --git a/pkgs/clan-app/ui-2d/src/Form/fields/layout.tsx b/pkgs/clan-app/ui-2d/src/Form/fields/layout.tsx new file mode 100644 index 000000000..32d4a8888 --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/Form/fields/layout.tsx @@ -0,0 +1,26 @@ +import { JSX, splitProps } from "solid-js"; +import cx from "classnames"; + +interface LayoutProps extends JSX.HTMLAttributes { + field?: JSX.Element; + label?: JSX.Element; + error?: JSX.Element; +} +export const FieldLayout = (props: LayoutProps) => { + const [intern, divProps] = splitProps(props, [ + "field", + "label", + "error", + "class", + ]); + return ( +
+
{props.label}
+
{props.field}
+ {props.error && {props.error}} +
+ ); +}; diff --git a/pkgs/clan-app/ui-2d/src/Form/fieldset/index.tsx b/pkgs/clan-app/ui-2d/src/Form/fieldset/index.tsx new file mode 100644 index 000000000..fa5ee953a --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/Form/fieldset/index.tsx @@ -0,0 +1,32 @@ +import { JSX } from "solid-js"; + +import { Typography } from "@/src/components/Typography"; + +interface FieldsetProps { + legend?: string; + children: JSX.Element; + class?: string; +} + +export default function Fieldset(props: FieldsetProps) { + return ( +
+ {props.legend && ( +
+ + {props.legend} + +
+ )} +
+ {props.children} +
+
+ ); +} diff --git a/pkgs/clan-app/ui-2d/src/Form/form/index.tsx b/pkgs/clan-app/ui-2d/src/Form/form/index.tsx new file mode 100644 index 000000000..f4e1b038c --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/Form/form/index.tsx @@ -0,0 +1,928 @@ +import { + createForm, + Field, + FieldArray, + FieldValues, + FormStore, + pattern, + ResponseData, + setValue, + getValues, + insert, + SubmitHandler, + reset, + remove, + move, +} from "@modular-forms/solid"; +import { JSONSchema7, JSONSchema7Type } from "json-schema"; +import { TextInput } from "../fields/TextInput"; +import { createEffect, For, JSX, Match, Show, Switch } from "solid-js"; +import cx from "classnames"; +import { Label } from "../base/label"; +import { SelectInput } from "../fields/Select"; +import { Button } from "../../components/Button/Button"; +import Icon from "@/src/components/icon"; + +function generateDefaults(schema: JSONSchema7): unknown { + switch (schema.type) { + case "string": + return ""; // Default value for string + + case "number": + case "integer": + return 0; // Default value for number/integer + + case "boolean": + return false; // Default value for boolean + + case "array": + return []; // Default empty array if no items schema or items is true/false + + case "object": { + const obj: Record = {}; + if (schema.properties) { + Object.entries(schema.properties).forEach(([key, propSchema]) => { + if (typeof propSchema === "boolean") { + obj[key] = false; + } else { + // if (schema.required schema.required.includes(key)) + obj[key] = generateDefaults(propSchema); + } + }); + } + return obj; + } + + default: + return null; // Default for unknown types or nulls + } +} + +interface FormProps { + schema: JSONSchema7; + initialValues?: NonNullable; + handleSubmit?: SubmitHandler>; + initialPath?: string[]; + components?: { + before?: JSX.Element; + after?: JSX.Element; + }; + readonly?: boolean; + formProps?: JSX.InputHTMLAttributes; + errorContext?: string; + resetOnSubmit?: boolean; +} +export const DynForm = (props: FormProps) => { + const [formStore, { Field, Form: ModuleForm }] = createForm({ + initialValues: props.initialValues, + }); + + const handleSubmit: SubmitHandler> = async ( + values, + event, + ) => { + console.log("Submitting form values", values, props.errorContext); + props.handleSubmit?.(values, event); + // setValue(formStore, "root", null); + if (props.resetOnSubmit) { + console.log("Resetting form", values, props.initialValues); + reset(formStore); + } + }; + + createEffect(() => { + console.log("FormStore", formStore); + }); + + return ( + <> + {/* @ts-expect-error: This happened after solidjs upgrade. TOOD: fixme */} + + {props.components?.before} + + {props.components?.after} + + + ); +}; + +interface UnsupportedProps { + schema: JSONSchema7; + error?: string; +} + +const Unsupported = (props: UnsupportedProps) => ( +
+ {props.error &&
{props.error}
} + + Invalid or unsupported schema entry of type:{" "} + {JSON.stringify(props.schema.type)} + +
+      {JSON.stringify(props.schema, null, 2)}
+    
+
+); + +interface SchemaFieldsProps { + formStore: FormStore; + Field: typeof Field; + schema: JSONSchema7; + path: string[]; + readonly: boolean; + parent: JSONSchema7; +} +export function SchemaFields( + props: SchemaFieldsProps, +) { + return ( + }> + {/* Simple types */} + bool + + + + + + + + + + + {/* Composed types */} + + + + + + + {/* Empty / Null */} + + Dont know how to rendner InputType null + + + + ); +} + +export function StringField( + props: SchemaFieldsProps, +) { + if ( + props.schema.type !== "string" && + props.schema.type !== "number" && + props.schema.type !== "integer" + ) { + return ( + + Error cannot render the following as String input. + + + ); + } + const { Field } = props; + + const validate = props.schema.pattern + ? pattern( + new RegExp(props.schema.pattern), + `String should follow pattern ${props.schema.pattern}`, + ) + : undefined; + + const commonProps = { + label: props.schema.title || props.path.join("."), + required: + props.parent.required && + props.parent.required.some( + (r) => r === props.path[props.path.length - 1], + ), + }; + const readonly = !!props.readonly; + return ( + }> + + {(s) => ( + + {(field, fieldProps) => ( + <> + + + )} + + )} + + + {(_enumSchemas) => ( + + {(field, fieldProps) => ( + + {(options) => ( + ({ + value: o, + label: o, + }))} + selectProps={fieldProps} + required={!!props.schema.minItems} + /> + )} + + )} + + )} + + + {(s) => ( + + {(field, fieldProps) => ( + + )} + + )} + + {/* TODO: when is it a normal string input? */} + + {(s) => ( + + {(field, fieldProps) => ( + + // + // + // } + // required + // altLabel="Leave empty to accept the default" + // helperText="Configure how dude connects" + // error="Something is wrong now" + /> + )} + + )} + + + ); +} + +interface OptionSchemaProps { + itemSpec: JSONSchema7Type; +} +export function OptionSchema(props: OptionSchemaProps) { + return ( + Item spec unhandled} + > + + {(o) => } + + + ); +} + +interface ValueDisplayProps + extends SchemaFieldsProps { + children: JSX.Element; + listFieldName: string; + idx: number; + of: number; +} +export function ListValueDisplay( + props: ValueDisplayProps, +) { + const removeItem = (e: Event) => { + e.preventDefault(); + remove( + props.formStore, + // @ts-expect-error: listFieldName is not known ahead of time + props.listFieldName, + { at: props.idx }, + ); + }; + const moveItemBy = (dir: number) => (e: Event) => { + e.preventDefault(); + move( + props.formStore, + // @ts-expect-error: listFieldName is not known ahead of time + props.listFieldName, + { from: props.idx, to: props.idx + dir }, + ); + }; + const topMost = () => props.idx === props.of - 1; + const bottomMost = () => props.idx === 0; + + return ( +
+
+ {props.children} +
+ + + +
+
+
+ ); +} + +const findDuplicates = (arr: unknown[]) => { + const seen = new Set(); + const duplicates: number[] = []; + + arr.forEach((obj, idx) => { + const serializedObj = JSON.stringify(obj); + + if (seen.has(serializedObj)) { + duplicates.push(idx); + } else { + seen.add(serializedObj); + } + }); + + return duplicates; +}; + +interface OnlyStringItems { + children: (items: string[]) => JSX.Element; + itemspec: JSONSchema7; +} +const OnlyStringItems = (props: OnlyStringItems) => { + return ( + + } + > + {props.children(props.itemspec.enum as string[])} + + ); +}; + +export function ArrayFields( + props: SchemaFieldsProps, +) { + if (props.schema.type !== "array") { + return ( + + Error cannot render the following as array. + + + ); + } + const { Field } = props; + + const listFieldName = props.path.join("."); + + return ( + <> + }> + + {(itemsSchema) => ( + <> + }> + + + + + { + let error = ""; + const values: unknown[] = getValues( + props.formStore, + // @ts-expect-error: listFieldName is not known ahead of time + listFieldName, + // @ts-expect-error: assumption based on the behavior of selectInput + )?.strings?.selection; + console.log("vali", { values }); + if (props.schema.uniqueItems) { + const duplicates = findDuplicates(values); + if (duplicates.length) { + error = `Duplicate entries are not allowed. Please make sure each entry is unique.`; + } + } + if ( + props.schema.maxItems && + values.length > props.schema.maxItems + ) { + error = `You can only select up to ${props.schema.maxItems} items`; + } + if ( + props.schema.minItems && + values.length < props.schema.minItems + ) { + error = `Please select at least ${props.schema.minItems} items.`; + } + return error; + }} + > + {(field, fieldProps) => ( + + {(options) => ( + ({ + value: o, + label: o, + }))} + selectProps={fieldProps} + required={!!props.schema.minItems} + /> + )} + + )} + + + + {/* !Important: Register the parent field to gain access to array items*/} + { + let error = ""; + // @ts-expect-error: listFieldName is not known ahead of time + const values: unknown[] = getValues( + props.formStore, + // @ts-expect-error: listFieldName is not known ahead of time + listFieldName, + ); + if (props.schema.uniqueItems) { + const duplicates = findDuplicates(values); + if (duplicates.length) { + error = `Duplicate entries are not allowed. Please make sure each entry is unique.`; + } + } + if ( + props.schema.maxItems && + values.length > props.schema.maxItems + ) { + error = `You can only add up to ${props.schema.maxItems} items`; + } + if ( + props.schema.minItems && + values.length < props.schema.minItems + ) { + error = `Please add at least ${props.schema.minItems} items.`; + } + + return error; + }} + > + {(fieldArray) => ( + <> + {/* Render existing items */} + + No {itemsSchema().title || "entries"} yet. + + } + > + {(item, idx) => ( + + + {(f, fp) => ( + <> + + + )} + + + )} + + + + {fieldArray.error} + + + + {/* Add new item */} + + + + ), + }} + // Add the new item to the FieldArray + handleSubmit={(values, event) => { + // @ts-expect-error: listFieldName is not known ahead of time + const prev: unknown[] = getValues( + props.formStore, + + // @ts-expect-error: listFieldName is not known ahead of time + listFieldName, + ); + if (itemsSchema().type === "object") { + const newIdx = prev.length; + setValue( + props.formStore, + + // @ts-expect-error: listFieldName is not known ahead of time + `${listFieldName}.${newIdx}`, + + // @ts-expect-error: listFieldName is not known ahead of time + values.root, + ); + } + + // @ts-expect-error: listFieldName is not known ahead of time + insert(props.formStore, listFieldName, { + // @ts-expect-error: listFieldName is not known ahead of time + value: values.root, + }); + }} + /> + + )} + + + + + )} + + + + ); +} + +interface ObjectFieldPropertyLabelProps { + schema: JSONSchema7; + fallback: JSX.Element; +} +export function ObjectFieldPropertyLabel(props: ObjectFieldPropertyLabelProps) { + return ( + + {/* @ts-expect-error: $exportedModuleInfo should exist since we export it */} + + {(path) => path()[path().length - 1]} + + + ); +} + +export function ObjectFields( + props: SchemaFieldsProps, +) { + if (props.schema.type !== "object") { + return ( + + Error cannot render the following as Object + + + ); + } + + const fieldName = props.path.join("."); + const { Field } = props; + + return ( + + } + > + + {(properties) => ( + + {([propName, propSchema]) => ( +
+
+ )} +
+ )} +
+ {/* Objects where people can define their own keys + - Trivial Key-value pairs. Where the value is a string a number or a list of strings (trivial select). + - Non-trivial Key-value pairs. Where the value is an object or a list + */} + + {(additionalPropertiesSchema) => ( + + } + > + {/* Non-trivival cases */} + + {(itemSchema) => ( + + {(objectField, fp) => ( + <> + + + + } + each={Object.entries(objectField.value || {})} + > + {([key, relatedValue]) => ( + + {(f, fp) => ( +
+ + + {key} + + +
+ ), + }} + /> + + )} +
+ )} +
+ {/* Replace this with a normal input ?*/} + { + setValue( + props.formStore, + // @ts-expect-error: fieldName is not known ahead of time + `${fieldName}`, + // @ts-expect-error: fieldName is not known ahead of time + { ...objectField.value, [values[""]]: {} }, + ); + }} + /> + + )} +
+ )} +
+ + {(itemSchema) => ( + + )} + + {/* TODO: Trivial cases */} +
+ )} +
+
+ ); +} diff --git a/pkgs/clan-app/ui-2d/src/api/index.tsx b/pkgs/clan-app/ui-2d/src/api/index.tsx new file mode 100644 index 000000000..531448f53 --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/api/index.tsx @@ -0,0 +1,189 @@ +import schema from "@/api/API.json" with { type: "json" }; +import { API, Error as ApiError } from "@/api/API"; +import { nanoid } from "nanoid"; +import { Schema as Inventory } from "@/api/Inventory"; +import { toast, Toast } from "solid-toast"; +import { + ErrorToastComponent, + CancelToastComponent, +} from "@/src/components/toast"; +export type OperationNames = keyof API; +export type OperationArgs = API[T]["arguments"]; +export type OperationResponse = API[T]["return"]; + +export type ApiEnvelope = + | { + status: "success"; + data: T; + op_key: string; + } + | ApiError; + +export type Services = NonNullable; +export type ServiceNames = keyof Services; +export type ClanService = Services[T]; +export type ClanServiceInstance = NonNullable< + Services[T] +>[string]; + +export type SuccessQuery = Extract< + OperationResponse, + { status: "success" } +>; +export type SuccessData = SuccessQuery["data"]; + +export type ErrorQuery = Extract< + OperationResponse, + { status: "error" } +>; +export type ErrorData = ErrorQuery["errors"]; + +export type ClanOperations = Record void>; + +export interface GtkResponse { + result: T; + op_key: string; +} +const _callApi = ( + method: K, + args: OperationArgs, +): { promise: Promise>; op_key: string } => { + // if window[method] does not exist, throw an error + if (!(method in window)) { + console.error(`Method ${method} not found on window object`); + // return a rejected promise + return { + promise: Promise.resolve({ + status: "error", + errors: [ + { + message: `Method ${method} not found on window object`, + code: "method_not_found", + }, + ], + op_key: "noop", + }), + op_key: "noop", + }; + } + + const promise = ( + window as unknown as Record< + OperationNames, + ( + args: OperationArgs, + ) => Promise> + > + )[method](args) as Promise>; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const op_key = (promise as any)._webviewMessageId as string; + + return { promise, op_key }; +}; + +const handleCancel = async ( + ops_key: string, + orig_task: Promise>, +) => { + console.log("Canceling operation: ", ops_key); + const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key }); + promise.catch((error) => { + toast.custom( + (t) => ( + + ), + { + duration: 5000, + }, + ); + console.error("Unhandled promise rejection in callApi:", error); + }); + const resp = await promise; + + if (resp.status === "error") { + toast.custom( + (t) => ( + + ), + { + duration: 5000, + }, + ); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (orig_task as any).cancelled = true; + } + console.log("Cancel response: ", resp); +}; + +export const callApi = ( + method: K, + args: OperationArgs, +): { promise: Promise>; op_key: string } => { + console.log("Calling API", method, args); + + const { promise, op_key } = _callApi(method, args); + promise.catch((error) => { + toast.custom( + (t) => ( + + ), + { + duration: 5000, + }, + ); + console.error("Unhandled promise rejection in callApi:", error); + }); + + const toastId = toast.custom( + ( + t, // t is the Toast object, t.id is the id of THIS toast instance + ) => ( + + ), + { + duration: Infinity, + }, + ); + + const new_promise = promise.then((response) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cancelled = (promise as any).cancelled; + if (cancelled) { + console.log("Not printing toast because operation was cancelled"); + } + + if (response.status === "error" && !cancelled) { + toast.remove(toastId); + toast.custom( + (t) => ( + + ), + { + duration: Infinity, + }, + ); + } else { + toast.remove(toastId); + } + return response; + }); + return { promise: new_promise, op_key: op_key }; +}; diff --git a/pkgs/clan-app/ui-2d/src/api/wifi.ts b/pkgs/clan-app/ui-2d/src/api/wifi.ts new file mode 100644 index 000000000..8e84776e2 --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/api/wifi.ts @@ -0,0 +1,20 @@ +import { callApi } from "."; +import { Schema as Inventory } from "@/api/Inventory"; + +export const instance_name = (machine_name: string) => + `${machine_name}_wifi_0` as const; + +export async function get_iwd_service(base_path: string, machine_name: string) { + const r = await callApi("get_inventory", { + flake: { identifier: base_path }, + }).promise; + if (r.status == "error") { + return null; + } + // @FIXME: Clean this up once we implement the feature + // @ts-expect-error: This doesn't check currently + const inventory: Inventory = r.data; + + const instance_key = instance_name(machine_name); + return inventory.services?.iwd?.[instance_key] || null; +} diff --git a/pkgs/clan-app/ui-2d/src/api_test.tsx b/pkgs/clan-app/ui-2d/src/api_test.tsx new file mode 100644 index 000000000..49599f059 --- /dev/null +++ b/pkgs/clan-app/ui-2d/src/api_test.tsx @@ -0,0 +1,188 @@ +import { + createForm, + FieldValues, + getValues, + setValue, + SubmitHandler, +} from "@modular-forms/solid"; +import { TextInput } from "@/src/Form/fields/TextInput"; +import { Button } from "./components/Button/Button"; +import { callApi } from "./api"; +import { API } from "@/api/API"; +import { createSignal, Match, Switch, For, Show } from "solid-js"; +import { Typography } from "./components/Typography"; +import { useQuery } from "@tanstack/solid-query"; +import { makePersisted } from "@solid-primitives/storage"; +import jsonSchema from "@/api/API.json"; + +interface APITesterForm extends FieldValues { + endpoint: string; + payload: string; +} + +const ACTUAL_API_ENDPOINT_NAMES: (keyof API)[] = jsonSchema.required.map( + (key) => key as keyof API, +); + +export const ApiTester = () => { + const [persistedTestData, setPersistedTestData] = makePersisted( + createSignal(), + { + name: "_test_data", + storage: localStorage, + }, + ); + + const [formStore, { Form, Field }] = createForm({ + initialValues: persistedTestData() || { endpoint: "", payload: "" }, + }); + + const [endpointSearchTerm, setEndpointSearchTerm] = createSignal( + getValues(formStore).endpoint || "", + ); + const [showSuggestions, setShowSuggestions] = createSignal(false); + + const filteredEndpoints = () => { + const term = endpointSearchTerm().toLowerCase(); + if (!term) return ACTUAL_API_ENDPOINT_NAMES; + return ACTUAL_API_ENDPOINT_NAMES.filter((ep) => + ep.toLowerCase().includes(term), + ); + }; + + const query = useQuery(() => { + const currentEndpoint = getValues(formStore).endpoint; + const currentPayload = getValues(formStore).payload; + const values = getValues(formStore); + + return { + queryKey: ["api-tester", currentEndpoint, currentPayload], + queryFn: async () => { + return await callApi( + values.endpoint as keyof API, + JSON.parse(values.payload || "{}"), + ); + }, + staleTime: Infinity, + enabled: false, + }; + }); + + const handleSubmit: SubmitHandler = (values) => { + console.log(values); + setPersistedTestData(values); + setEndpointSearchTerm(values.endpoint); + query.refetch(); + + const v = getValues(formStore); + console.log(v); + }; + return ( +
+

API Tester

+ +
+ + {(field, fieldProps) => ( +
+ { + if (fieldProps.onInput) { + (fieldProps.onInput as (ev: Event) => void)(e); + } + setEndpointSearchTerm( + (e.currentTarget as HTMLInputElement).value, + ); + setShowSuggestions(true); + }, + onBlur: (e: FocusEvent) => { + if (fieldProps.onBlur) { + (fieldProps.onBlur as (ev: FocusEvent) => void)(e); + } + setTimeout(() => setShowSuggestions(false), 150); + }, + onFocus: (e: FocusEvent) => { + setEndpointSearchTerm(field.value || ""); + setShowSuggestions(true); + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === "Escape") { + setShowSuggestions(false); + } + }, + }} + /> + 0} + > +
    + + {(ep) => ( +
  • { + e.preventDefault(); + setValue(formStore, "endpoint", ep); + setEndpointSearchTerm(ep); + setShowSuggestions(false); + }} + > + {ep} +
  • + )} +
    +
+
+
+ )} +
+ + {(field, fieldProps) => ( +
+ +