diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec4465130..f63fa8e74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the npx supabase gen types --lang=typescript \ --project-id "$PROJECT_REF" \ --schema public \ + --schema grida_g11n \ --schema grida_x_supabase \ --schema grida_sites \ --schema grida_commerce \ diff --git a/apps/forms/.gitignore b/apps/forms/.gitignore index 85a07e127..3827696a5 100644 --- a/apps/forms/.gitignore +++ b/apps/forms/.gitignore @@ -32,7 +32,7 @@ yarn-error.log* .vercel # supabase -supabase/.temp +.temp # typescript *.tsbuildinfo diff --git a/apps/forms/app/(api)/v1/[id]/route.ts b/apps/forms/app/(api)/v1/[id]/route.ts index cbe9fbd45..18c21b190 100644 --- a/apps/forms/app/(api)/v1/[id]/route.ts +++ b/apps/forms/app/(api)/v1/[id]/route.ts @@ -38,7 +38,7 @@ import type { FormMethod, FormDocument, Option, - FormsPageLanguage, + LanguageCode, } from "@/types"; import { Features } from "@/lib/features/scheduling"; import { requesterurl, resolverurl } from "@/services/form/session-storage"; @@ -201,7 +201,7 @@ export async function GET( store_connection, } = data; - const lang: FormsPageLanguage = + const lang: LanguageCode = (default_page as unknown as FormDocument | null)?.lang ?? "en"; const is_powered_by_branding_enabled = (default_page as unknown as FormDocument | null) diff --git a/apps/forms/app/(workbench)/[org]/[proj]/[id]/design/page.tsx b/apps/forms/app/(workbench)/[org]/[proj]/[id]/design/page.tsx index d275f4449..551349005 100644 --- a/apps/forms/app/(workbench)/[org]/[proj]/[id]/design/page.tsx +++ b/apps/forms/app/(workbench)/[org]/[proj]/[id]/design/page.tsx @@ -55,7 +55,6 @@ function CurrentPageCanvas() { const [state, dispatch] = useEditorState(); const { - theme: { lang }, document: { selected_page_id }, } = state; diff --git a/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/edit/page.tsx b/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/edit/page.tsx index 87f4eb79e..17a94a8f8 100644 --- a/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/edit/page.tsx +++ b/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/edit/page.tsx @@ -53,7 +53,6 @@ function CurrentPageCanvas() { const [state, dispatch] = useEditorState(); const { - theme: { lang }, document: { selected_page_id }, } = state; diff --git a/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/g11n/page.tsx b/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/g11n/page.tsx new file mode 100644 index 000000000..5e615c33c --- /dev/null +++ b/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/g11n/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import Invalid from "@/components/invalid"; +import { useEditorState } from "@/scaffolds/editor"; +import { I18nEditor } from "@/scaffolds/i18n-editor"; + +export default function FormI18nPage() { + const [state] = useEditorState(); + const { lang, lang_default, langs } = state.document.g11n; + + if (langs.length <= 1) { + return ; + } + + return ( +
+ +
+ ); +} diff --git a/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/page.tsx b/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/page.tsx index c6cd647a0..c31404ede 100644 --- a/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/page.tsx +++ b/apps/forms/app/(workbench)/[org]/[proj]/[id]/form/page.tsx @@ -24,11 +24,7 @@ import { useEditorState } from "@/scaffolds/editor"; export default function FormDashboard() { const [state, dispatch] = useEditorState(); - const { - form, - theme: { lang }, - document: { selected_page_id }, - } = state; + const { form } = state; return (
diff --git a/apps/forms/app/(workbench)/[org]/[proj]/[id]/layout.tsx b/apps/forms/app/(workbench)/[org]/[proj]/[id]/layout.tsx index 2227947c9..631508870 100644 --- a/apps/forms/app/(workbench)/[org]/[proj]/[id]/layout.tsx +++ b/apps/forms/app/(workbench)/[org]/[proj]/[id]/layout.tsx @@ -5,6 +5,7 @@ import { cookies } from "next/headers"; import { createRouteHandlerXSBClient, createServerComponentClient, + createServerComponentG11nClient, createServerComponentWorkspaceClient, grida_xsupabase_client, } from "@/lib/supabase/server"; @@ -39,6 +40,7 @@ import React from "react"; import { PlayActions } from "@/scaffolds/workbench/play-actions"; import { DontCastJsonProperties } from "@/types/supabase-ext"; import { SupabasePostgRESTOpenApi } from "@/lib/supabase-postgrest"; +import EditorRouterProvider from "./router"; export const revalidate = 0; @@ -75,8 +77,10 @@ export default async function Layout({ params: GDocEditorRouteParams; }>) { const cookieStore = cookies(); - const supabase = createServerComponentClient(cookieStore); + const formsclient = createServerComponentClient(cookieStore); const wsclient = createServerComponentWorkspaceClient(cookieStore); + const g11nclient = createServerComponentG11nClient(cookieStore); + const { id, org, proj } = params; const { data: project_ref, error: project_ref_err } = await wsclient @@ -114,7 +118,7 @@ export default async function Layout({ switch (masterdoc_ref.doctype) { case "v0_form": { - const { data, error } = await supabase + const { data, error } = await formsclient .from("form_document") .select( ` @@ -144,7 +148,7 @@ export default async function Layout({ const appearance = (data.stylesheet as FormStyleSheetV1Schema)?.appearance ?? "system"; - const client = new GridaXSupabaseService(); + const xsbservice = new GridaXSupabaseService(); const { form: _form } = data; assert(_form); @@ -155,9 +159,33 @@ export default async function Layout({ }; const supabase_connection_state = form.supabase_connection - ? await client.getXSBMainTableConnectionState(form.supabase_connection) + ? await xsbservice.getXSBMainTableConnectionState( + form.supabase_connection + ) : null; + if (data.g11n_manifest_id) { + const { data: g11n, error: g11n_err } = await g11nclient + .from("manifest") + .select( + ` + *, + locales:locale!manifest_id(*), + default_locale:locale(*), + keys:key(*) + ` + ) + .eq("id", data.g11n_manifest_id) + .single(); + + // g11n. + + if (g11n_err) { + // report and ignore + console.error("g11n_err", g11n_err); + } + } + return ( {children} + diff --git a/apps/forms/app/(workbench)/[org]/[proj]/[id]/router.tsx b/apps/forms/app/(workbench)/[org]/[proj]/[id]/router.tsx new file mode 100644 index 000000000..f156b5a5b --- /dev/null +++ b/apps/forms/app/(workbench)/[org]/[proj]/[id]/router.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEditorState } from "@/scaffolds/editor"; +import { usePathname } from "next/navigation"; +import { useEffect, useMemo } from "react"; + +export default function EditorRouterProvider() { + const [state, dispatch] = useEditorState(); + const pathname = usePathname(); + + const { params, workbenchpath } = useMemo(() => { + const [org, proj, docid, ...params] = pathname.split("/").filter(Boolean); + const workbenchpath = params.join("/"); + return { + org, + proj, + docid, + params, + workbenchpath, + }; + }, [pathname]); + + useEffect(() => { + dispatch({ type: "workbench/path", path: workbenchpath }); + }, [workbenchpath, dispatch]); + + return <>; +} diff --git a/apps/forms/components/delete-confirmation-dialog/index.tsx b/apps/forms/components/delete-confirmation-dialog/index.tsx index d728eae9e..075ad4257 100644 --- a/apps/forms/components/delete-confirmation-dialog/index.tsx +++ b/apps/forms/components/delete-confirmation-dialog/index.tsx @@ -11,8 +11,42 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Spinner } from "../spinner"; +import { useDialogState } from "../hooks/use-dialog-state"; -export function DeleteConfirmationAlertDialog({ +type UseDeleteConfirmationAlertDialogStateProps = { + id: ID; + match: string; + title: string; + description?: string; +}; + +export function useDeleteConfirmationAlertDialogState< + ID extends string = string, +>(name = "alertdialog", config?: { refreshkey?: boolean }) { + const { open, setOpen, openDialog, closeDialog, data, setData, refreshkey } = + useDialogState>( + name, + config + ); + + return { + refreshkey, + open, + setOpen, + onOpenChange: setOpen, + openDialog, + closeDialog, + data: { + id: data?.id as ID, + }, + setData, + title: data?.title, + description: data?.description, + match: data?.match, + }; +} + +export function DeleteConfirmationAlertDialog({ title, description, placeholder, @@ -26,7 +60,7 @@ export function DeleteConfirmationAlertDialog({ placeholder?: string; match?: string; data?: { - id: string; + id: ID; }; /** * trigger when the delete button is clicked @@ -34,7 +68,7 @@ export function DeleteConfirmationAlertDialog({ * if the promise resolves to true, the dialog will be closed */ onDelete?: ( - data: { id: string }, + data: { id: ID }, user_confirmation_txt: string ) => Promise; }) { @@ -71,7 +105,7 @@ export function DeleteConfirmationAlertDialog({ spellCheck="false" type="text" name="comfirmation" - placeholder={placeholder} + placeholder={placeholder ?? match} value={confirmation} onChange={(e) => { setConfirmation(e.target.value); diff --git a/apps/forms/components/formfield/file-upload-field/uploader.ts b/apps/forms/components/formfield/file-upload-field/uploader.ts index 1b45fa3fc..69eb81c74 100644 --- a/apps/forms/components/formfield/file-upload-field/uploader.ts +++ b/apps/forms/components/formfield/file-upload-field/uploader.ts @@ -1,4 +1,4 @@ -import { createClientFormsClient } from "@/lib/supabase/client"; +import { createClientComponentFormsClient } from "@/lib/supabase/client"; import { GRIDA_FORMS_RESPONSE_BUCKET, GRIDA_FORMS_RESPONSE_BUCKET_UPLOAD_LIMIT, @@ -54,7 +54,7 @@ async function makeSignedUrlUploader({ }: { signed_urls: { path: string; token: string }[]; }) { - const supabase = createClientFormsClient(); + const supabase = createClientComponentFormsClient(); return async (file: File, i: number) => { const { path, token } = signed_urls[i]; @@ -70,7 +70,7 @@ async function makeSignedUrlUploader({ } function makeRequestUrlUploader({ request_url }: { request_url: string }) { - const supabase = createClientFormsClient(); + const supabase = createClientComponentFormsClient(); return async (file: File) => { const res = await fetch(request_url, { diff --git a/apps/forms/components/invalid.tsx b/apps/forms/components/invalid.tsx index cf203daeb..abc9fddef 100644 --- a/apps/forms/components/invalid.tsx +++ b/apps/forms/components/invalid.tsx @@ -9,7 +9,7 @@ export default function Invalid() { const { basepath } = state; return ( -
+

@@ -17,7 +17,10 @@ export default function Invalid() {

The page you're looking for doesn't exist or has been moved. - Don't worry, we're here to help. + If you think this is a mistake,{" "} + + please file an issue. +

+>; + +export function LanguageSelect({ + name, + required, + value, + defaultValue, + onValueChange, + options = supported_form_page_languages, + optionsmap, + className, + placeholder = "Select language", +}: { + name?: string; + required?: boolean; + value?: LanguageCode; + defaultValue?: LanguageCode; + onValueChange?: (value: LanguageCode) => void; + options?: LanguageCode[]; + optionsmap?: LanguageSelectOptionMap; + className?: string; + placeholder?: string; +}) { + return ( + + ); +} diff --git a/apps/forms/components/sidebar/index.tsx b/apps/forms/components/sidebar/index.tsx index 15292ce07..ba20adcb8 100644 --- a/apps/forms/components/sidebar/index.tsx +++ b/apps/forms/components/sidebar/index.tsx @@ -104,7 +104,10 @@ export function SidebarMenuLink({ return ( {/* override selected prop */} - {React.cloneElement(children as any, { selected })} + {React.cloneElement(children as any, { + selected, + className: "cursor-pointer", + })} ); } @@ -149,7 +152,7 @@ export const SidebarMenuItem = React.forwardRef(function SidebarMenuItem( "relative group", "w-full px-2 py-1 rounded text-sm font-medium text-foreground", "text-ellipsis whitespace-nowrap overflow-hidden", - "hover:bg-accent hover:text-accent-foreground", + "hover:bg-accent hover:text-accent-foreground cursor-default", "data-[muted='true']:text-muted-foreground", "data-[disabled='true']:cursor-not-allowed data-[disabled='true']:opacity-40 data-[disabled='true']:bg-background", "data-[selected='true']:bg-accent data-[selected='true']:text-accent-foreground", diff --git a/apps/forms/database.types.ts b/apps/forms/database.types.ts index 23142d2bc..18a0c549c 100644 --- a/apps/forms/database.types.ts +++ b/apps/forms/database.types.ts @@ -799,6 +799,7 @@ export type Database = { is_scheduling_enabled: boolean max_form_responses_by_customer: number | null max_form_responses_in_total: number | null + name: string project_id: number scheduling_close_at: string | null scheduling_open_at: string | null @@ -819,6 +820,7 @@ export type Database = { is_scheduling_enabled?: boolean max_form_responses_by_customer?: number | null max_form_responses_in_total?: number | null + name?: string project_id: number scheduling_close_at?: string | null scheduling_open_at?: string | null @@ -839,6 +841,7 @@ export type Database = { is_scheduling_enabled?: boolean max_form_responses_by_customer?: number | null max_form_responses_in_total?: number | null + name?: string project_id?: number scheduling_close_at?: string | null scheduling_open_at?: string | null @@ -957,57 +960,67 @@ export type Database = { } form_document: { Row: { + __name: string background: Json | null created_at: string ending_page_i18n_overrides: Json | null ending_page_template_id: string | null form_id: string + g11n_manifest_id: number | null id: string is_ending_page_enabled: boolean is_powered_by_branding_enabled: boolean is_redirect_after_response_uri_enabled: boolean - lang: Database["grida_forms"]["Enums"]["form_page_language"] + lang: Database["public"]["Enums"]["language_code"] method: Database["grida_forms"]["Enums"]["form_method"] - name: string project_id: number redirect_after_response_uri: string | null stylesheet: Json | null } Insert: { + __name?: string background?: Json | null created_at?: string ending_page_i18n_overrides?: Json | null ending_page_template_id?: string | null form_id: string + g11n_manifest_id?: number | null id: string is_ending_page_enabled?: boolean is_powered_by_branding_enabled?: boolean is_redirect_after_response_uri_enabled?: boolean - lang?: Database["grida_forms"]["Enums"]["form_page_language"] + lang?: Database["public"]["Enums"]["language_code"] method?: Database["grida_forms"]["Enums"]["form_method"] - name?: string project_id: number redirect_after_response_uri?: string | null stylesheet?: Json | null } Update: { + __name?: string background?: Json | null created_at?: string ending_page_i18n_overrides?: Json | null ending_page_template_id?: string | null form_id?: string + g11n_manifest_id?: number | null id?: string is_ending_page_enabled?: boolean is_powered_by_branding_enabled?: boolean is_redirect_after_response_uri_enabled?: boolean - lang?: Database["grida_forms"]["Enums"]["form_page_language"] + lang?: Database["public"]["Enums"]["language_code"] method?: Database["grida_forms"]["Enums"]["form_method"] - name?: string project_id?: number redirect_after_response_uri?: string | null stylesheet?: Json | null } Relationships: [ + { + foreignKeyName: "form_document_g11n_manifest_id_fkey" + columns: ["g11n_manifest_id"] + isOneToOne: false + referencedRelation: "manifest" + referencedColumns: ["id"] + }, { foreignKeyName: "form_document_id_fkey" columns: ["id"] @@ -1767,20 +1780,6 @@ export type Database = { | "video" | "json" form_method: "post" | "get" | "dialog" - form_page_language: - | "en" - | "ko" - | "es" - | "de" - | "ja" - | "fr" - | "pt" - | "it" - | "ru" - | "zh" - | "ar" - | "hi" - | "nl" form_response_unknown_field_handling_strategy_type: | "ignore" | "accept" @@ -1824,6 +1823,180 @@ export type Database = { [_ in never]: never } } + grida_g11n: { + Tables: { + key: { + Row: { + created_at: string + description: string | null + id: number + keypath: string[] + manifest_id: number + updated_at: string + } + Insert: { + created_at?: string + description?: string | null + id?: number + keypath: string[] + manifest_id: number + updated_at: string + } + Update: { + created_at?: string + description?: string | null + id?: number + keypath?: string[] + manifest_id?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "key_manifest_id_fkey" + columns: ["manifest_id"] + isOneToOne: false + referencedRelation: "manifest" + referencedColumns: ["id"] + }, + ] + } + locale: { + Row: { + code: string + created_at: string + id: number + manifest_id: number + updated_at: string + } + Insert: { + code: string + created_at?: string + id?: number + manifest_id: number + updated_at: string + } + Update: { + code?: string + created_at?: string + id?: number + manifest_id?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "locale_manifest_id_fkey" + columns: ["manifest_id"] + isOneToOne: false + referencedRelation: "manifest" + referencedColumns: ["id"] + }, + ] + } + manifest: { + Row: { + created_at: string + default_locale_id: number | null + id: number + project_id: number + updated_at: string + } + Insert: { + created_at?: string + default_locale_id?: number | null + id?: number + project_id: number + updated_at?: string + } + Update: { + created_at?: string + default_locale_id?: number | null + id?: number + project_id?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "manifest_default_locale_id_fkey" + columns: ["default_locale_id"] + isOneToOne: true + referencedRelation: "locale" + referencedColumns: ["id"] + }, + { + foreignKeyName: "manifest_project_id_fkey" + columns: ["project_id"] + isOneToOne: false + referencedRelation: "project" + referencedColumns: ["id"] + }, + ] + } + resource: { + Row: { + created_at: string + id: number + key_id: number + locale_id: number + manifest_id: number + updated_at: string + value: Json + } + Insert: { + created_at?: string + id?: number + key_id: number + locale_id: number + manifest_id: number + updated_at?: string + value: Json + } + Update: { + created_at?: string + id?: number + key_id?: number + locale_id?: number + manifest_id?: number + updated_at?: string + value?: Json + } + Relationships: [ + { + foreignKeyName: "value_key_id_fkey" + columns: ["key_id"] + isOneToOne: false + referencedRelation: "key" + referencedColumns: ["id"] + }, + { + foreignKeyName: "value_locale_id_fkey" + columns: ["locale_id"] + isOneToOne: false + referencedRelation: "locale" + referencedColumns: ["id"] + }, + { + foreignKeyName: "value_manifest_id_fkey" + columns: ["manifest_id"] + isOneToOne: false + referencedRelation: "manifest" + referencedColumns: ["id"] + }, + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } grida_sites: { Tables: { site_document: { @@ -2399,6 +2572,12 @@ export type Database = { } Returns: number[] } + rls_manifest: { + Args: { + p_manifest_id: number + } + Returns: boolean + } rls_organization: { Args: { p_organization_id: number @@ -2432,6 +2611,20 @@ export type Database = { } Enums: { doctype: "v0_form" | "v0_site" | "v0_schema" + language_code: + | "en" + | "ko" + | "es" + | "de" + | "ja" + | "fr" + | "pt" + | "it" + | "ru" + | "zh" + | "ar" + | "hi" + | "nl" } CompositeTypes: { [_ in never]: never diff --git a/apps/forms/i18n/resources.common.ts b/apps/forms/i18n/resources.common.ts new file mode 100644 index 000000000..125a407ed --- /dev/null +++ b/apps/forms/i18n/resources.common.ts @@ -0,0 +1,99 @@ +/// +/// use for .min usage. +/// + +const common = { + en: { + next: "Next", + back: "Previous", + submit: "Submit", + pay: "Pay", + home: "Home", + }, + es: { + next: "Siguiente", + back: "Anterior", + submit: "Enviar", + pay: "Pagar", + home: "Inicio", + }, + ko: { + next: "다음", + back: "이전", + submit: "제출", + pay: "결제", + home: "홈", + }, + ja: { + next: "次へ", + back: "戻る", + submit: "提出する", + pay: "支払う", + home: "ホーム", + }, + zh: { + next: "下一步", + back: "上一步", + submit: "提交", + pay: "支付", + home: "首页", + }, + fr: { + next: "Suivant", + back: "Précédent", + submit: "Soumettre", + pay: "Payer", + home: "Accueil", + }, + pt: { + next: "Próximo", + back: "Anterior", + submit: "Enviar", + pay: "Pagar", + home: "Início", + }, + it: { + next: "Avanti", + back: "Indietro", + submit: "Invia", + pay: "Paga", + home: "Home", + }, + de: { + next: "Weiter", + back: "Zurück", + submit: "Einreichen", + pay: "Bezahlen", + home: "Startseite", + }, + ru: { + next: "Далее", + back: "Назад", + submit: "Отправить", + pay: "Оплатить", + home: "Главная", + }, + ar: { + next: "التالي", + back: "السابق", + submit: "إرسال", + pay: "دفع", + home: "الرئيسية", + }, + hi: { + next: "अगला", + back: "पिछला", + submit: "जमा करें", + pay: "भुगतान करें", + home: "होम", + }, + nl: { + next: "Volgende", + back: "Vorige", + submit: "Indienen", + pay: "Betalen", + home: "Home", + }, +}; + +export default common; diff --git a/apps/forms/i18n/resources.ts b/apps/forms/i18n/resources.ts index 22bb46f43..eb7ecce53 100644 --- a/apps/forms/i18n/resources.ts +++ b/apps/forms/i18n/resources.ts @@ -1,6 +1,7 @@ import { TemplateVariables } from "@/lib/templating"; import type { ObjectPath } from "@/lib/templating/@types"; -import type { FormsPageLanguage } from "@/types"; +import type { LanguageCode } from "@/types"; +import common from "./resources.common"; type T = ObjectPath< TemplateVariables.FormResponseContext & { @@ -79,18 +80,14 @@ export interface Translation { } const resources: Record< - FormsPageLanguage, + LanguageCode, { translation: Translation; } > = { en: { translation: { - next: "Next", - back: "Previous", - submit: "Submit", - pay: "Pay", - home: "Home", + ...common.en, left_in_stock: `${use("available")} left`, sold_out: "Sold Out", support_metadata: `Support Metadata`, @@ -152,11 +149,7 @@ const resources: Record< }, es: { translation: { - next: "Siguiente", - back: "Anterior", - submit: "Enviar", - pay: "Pagar", - home: "Inicio", + ...common.es, left_in_stock: `${use("available")} restantes`, sold_out: "Agotado", support_metadata: `Metadatos de soporte`, @@ -218,11 +211,7 @@ const resources: Record< }, ko: { translation: { - next: "다음", - back: "이전", - submit: "제출", - pay: "결제", - home: "홈", + ...common.ko, left_in_stock: `${use("available")}개 남음`, sold_out: "매진됨", support_metadata: `서포트 메타데이터`, @@ -283,11 +272,7 @@ const resources: Record< }, ja: { translation: { - next: "次へ", - back: "戻る", - submit: "提出する", - pay: "支払う", - home: "ホーム", + ...common.ja, left_in_stock: `残り${use("available")}個`, sold_out: "完売", support_metadata: `サポートメタデータ`, @@ -348,11 +333,7 @@ const resources: Record< }, zh: { translation: { - next: "下一步", - back: "上一步", - submit: "提交", - pay: "支付", - home: "首页", + ...common.zh, left_in_stock: `剩余${use("available")}件`, sold_out: "售罄", support_metadata: `支持元数据`, @@ -410,11 +391,7 @@ const resources: Record< }, fr: { translation: { - next: "Suivant", - back: "Précédent", - submit: "Soumettre", - pay: "Payer", - home: "Accueil", + ...common.fr, left_in_stock: `${use("available")} restants`, sold_out: "Épuisé", support_metadata: `Métadonnées de support`, @@ -476,11 +453,7 @@ const resources: Record< }, pt: { translation: { - next: "Próximo", - back: "Anterior", - submit: "Enviar", - pay: "Pagar", - home: "Início", + ...common.pt, left_in_stock: `${use("available")} restantes`, sold_out: "Esgotado", support_metadata: `Metadados de suporte`, @@ -542,11 +515,7 @@ const resources: Record< }, it: { translation: { - next: "Avanti", - back: "Indietro", - submit: "Invia", - pay: "Paga", - home: "Home", + ...common.it, left_in_stock: `${use("available")} rimasti`, sold_out: "Esaurito", support_metadata: `Metadati di supporto`, @@ -608,11 +577,7 @@ const resources: Record< }, de: { translation: { - next: "Weiter", - back: "Zurück", - submit: "Einreichen", - pay: "Bezahlen", - home: "Startseite", + ...common.de, left_in_stock: `${use("available")} übrig`, sold_out: "Ausverkauft", support_metadata: `Support-Metadaten`, @@ -674,11 +639,7 @@ const resources: Record< }, ru: { translation: { - next: "Далее", - back: "Назад", - submit: "Отправить", - pay: "Оплатить", - home: "Главная", + ...common.ru, left_in_stock: `Осталось ${use("available")} шт.`, sold_out: "Распродано", support_metadata: `Метаданные поддержки`, @@ -738,11 +699,7 @@ const resources: Record< }, ar: { translation: { - next: "التالي", - back: "السابق", - submit: "إرسال", - pay: "دفع", - home: "الرئيسية", + ...common.ar, left_in_stock: `${use("available")} متبقية`, sold_out: "نفذت الكمية", support_metadata: `بيانات الدعم الوصفية`, @@ -802,11 +759,7 @@ const resources: Record< }, hi: { translation: { - next: "अगला", - back: "पिछला", - submit: "जमा करें", - pay: "भुगतान करें", - home: "होम", + ...common.hi, left_in_stock: `${use("available")} बचे हैं`, sold_out: "बिक गया", support_metadata: `सहायता मेटाडाटा`, @@ -867,11 +820,7 @@ const resources: Record< }, nl: { translation: { - next: "Volgende", - back: "Vorige", - submit: "Indienen", - pay: "Betalen", - home: "Home", + ...common.nl, left_in_stock: `Nog ${use("available")} beschikbaar`, sold_out: "Uitverkocht", support_metadata: `Ondersteuningsmetadata`, diff --git a/apps/forms/k/supported_field_types.ts b/apps/forms/k/supported_field_types.ts index b6784f984..bb92c4ac4 100644 --- a/apps/forms/k/supported_field_types.ts +++ b/apps/forms/k/supported_field_types.ts @@ -206,6 +206,7 @@ const html5_checkbox_alias_field_types: FormInputType[] = [ const html5_placeholder_not_supported_field_types: FormInputType[] = [ ...html5_file_alias_field_types, ...html5_checkbox_alias_field_types, + "hidden", "toggle", "toggle-group", "radio", diff --git a/apps/forms/k/supported_languages.ts b/apps/forms/k/supported_languages.ts index d5a261c46..37b667aa8 100644 --- a/apps/forms/k/supported_languages.ts +++ b/apps/forms/k/supported_languages.ts @@ -1,22 +1,25 @@ -import { FormsPageLanguage } from "@/types"; +import { LanguageCode } from "@/types"; import resources from "@/i18n"; -export const supported_form_page_languages: FormsPageLanguage[] = Object.keys( +export const supported_form_page_languages: LanguageCode[] = Object.keys( resources -) as FormsPageLanguage[]; +) as LanguageCode[]; -export const language_label_map: Record = { - en: "English", - es: "Spanish / Español", - de: "German / Deutsch", - ja: "Japanese / 日本語", - fr: "French / Français", - pt: "Portuguese / Português", - it: "Italian / Italiano", - ko: "Korean / 한국어", - ru: "Russian / Русский", - zh: "Chinese / 中文", - ar: "Arabic / العربية", - hi: "Hindi / हिन्दी", - nl: "Dutch / Nederlands", +export const language_label_map: Record< + LanguageCode, + { flag: string; label: string } +> = { + en: { flag: "🇺🇸", label: "English" }, + es: { flag: "🇪🇸", label: "Spanish / Español" }, + de: { flag: "🇩🇪", label: "German / Deutsch" }, + ja: { flag: "🇯🇵", label: "Japanese / 日本語" }, + fr: { flag: "🇫🇷", label: "French / Français" }, + pt: { flag: "🇵🇹", label: "Portuguese / Português" }, + it: { flag: "🇮🇹", label: "Italian / Italiano" }, + ko: { flag: "🇰🇷", label: "Korean / 한국어" }, + ru: { flag: "🇷🇺", label: "Russian / Русский" }, + zh: { flag: "🇨🇳", label: "Chinese / 中文" }, + ar: { flag: "🇸🇦", label: "Arabic / العربية" }, + hi: { flag: "🇮🇳", label: "Hindi / हिन्दी" }, + nl: { flag: "🇳🇱", label: "Dutch / Nederlands" }, }; diff --git a/apps/forms/k/video_block_defaults.ts b/apps/forms/k/video_block_defaults.ts index efe54825d..7f3b234b0 100644 --- a/apps/forms/k/video_block_defaults.ts +++ b/apps/forms/k/video_block_defaults.ts @@ -1,2 +1,2 @@ export const VIDEO_BLOCK_SRC_DEFAULT_VALUE = - "https://www.youtube.com/watch?v=BFhp7Y0iLSA&ab_channel=AbstractMotion"; + "https://www.youtube.com/watch?v=jNQXAC9IVRw&ab_channel=jawed"; diff --git a/apps/forms/lib/forms/renderer.ts b/apps/forms/lib/forms/renderer.ts index e989042e5..caf6f4cf4 100644 --- a/apps/forms/lib/forms/renderer.ts +++ b/apps/forms/lib/forms/renderer.ts @@ -5,7 +5,7 @@ import type { FormFieldDefinition, FormBlock, Option, - FormsPageLanguage, + LanguageCode, } from "@/types"; import { blockstree } from "./tree"; import { FormBlockTree } from "./types"; @@ -187,7 +187,7 @@ export class FormRenderTree { readonly id: string, readonly title: string | null | undefined, readonly description: string | null | undefined, - readonly lang: FormsPageLanguage | null | undefined, + readonly lang: LanguageCode | null | undefined, private readonly _m_fields: FormFieldDefinition[] = [], private readonly _m_blocks?: FormBlock[] | null, private readonly config?: RenderTreeConfig, diff --git a/apps/forms/lib/forms/url.ts b/apps/forms/lib/forms/url.ts index efffd907f..16184bd74 100644 --- a/apps/forms/lib/forms/url.ts +++ b/apps/forms/lib/forms/url.ts @@ -20,6 +20,7 @@ type EditorPageParamsMap = { ".": {}; form: {}; "form/edit": {}; + "form/g11n": {}; settings: {}; design: {}; data: {}; @@ -58,6 +59,8 @@ export function editorlink

( return `${origin}/${basepath}/${id}/form`; case "form/edit": return `${origin}/${basepath}/${id}/form/edit`; + case "form/g11n": + return `${origin}/${basepath}/${id}/form/g11n`; case "settings": return `${origin}/${basepath}/${id}/settings`; // case "settings/customize": diff --git a/apps/forms/lib/formstate/core/agent.tsx b/apps/forms/lib/formstate/core/agent.tsx index ce670b70c..c0f42e41d 100644 --- a/apps/forms/lib/formstate/core/agent.tsx +++ b/apps/forms/lib/formstate/core/agent.tsx @@ -9,8 +9,6 @@ export function FormAgentProvider({ }: React.PropsWithChildren<{ initial: FormAgentState }>) { const [state, dispatch] = React.useReducer(reducer, initial); - // console.log("FormAgentProvider", state.tree, initial.tree); - useEffect(() => { dispatch({ type: "refresh", state: initial }); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/forms/lib/simulator/index.ts b/apps/forms/lib/simulator/index.ts index f127d135a..43d57732c 100644 --- a/apps/forms/lib/simulator/index.ts +++ b/apps/forms/lib/simulator/index.ts @@ -2,7 +2,7 @@ import { SYSTEM_X_GF_SIMULATOR_FLAG_KEY } from "@/k/system"; import { nanoid } from "nanoid"; import { v4 } from "uuid"; import { FormRenderTree } from "../forms"; -import { createClientFormsClient } from "../supabase/client"; +import { createClientComponentFormsClient } from "../supabase/client"; import assert from "assert"; import { FormDocument } from "@/types"; import { FormSubmitErrorCode } from "@/types/private/api"; @@ -62,7 +62,7 @@ export class Simulator { } private async _fetch_form_schema() { - const { data } = await createClientFormsClient() + const { data } = await createClientComponentFormsClient() .from("form") .select( ` diff --git a/apps/forms/lib/supabase/client.ts b/apps/forms/lib/supabase/client.ts index 4924043ed..35258bf5e 100644 --- a/apps/forms/lib/supabase/client.ts +++ b/apps/forms/lib/supabase/client.ts @@ -1,7 +1,7 @@ import type { Database } from "@/database.types"; import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"; -export const createClientFormsClient = () => +export const createClientComponentFormsClient = () => createClientComponentClient({ options: { db: { @@ -10,7 +10,7 @@ export const createClientFormsClient = () => }, }); -export const createClientCommerceClient = () => +export const createClientComponentCommerceClient = () => createClientComponentClient({ options: { db: { @@ -20,7 +20,16 @@ export const createClientCommerceClient = () => isSingleton: false, }); -export const createClientWorkspaceClient = () => +export const createClientComponentG11nClient = () => + createClientComponentClient({ + options: { + db: { + schema: "grida_g11n", + }, + }, + }); + +export const createClientComponentWorkspaceClient = () => createClientComponentClient({ options: { db: { diff --git a/apps/forms/lib/supabase/server.ts b/apps/forms/lib/supabase/server.ts index 29e0138d5..5fb9a744e 100644 --- a/apps/forms/lib/supabase/server.ts +++ b/apps/forms/lib/supabase/server.ts @@ -53,6 +53,17 @@ export const grida_xsupabase_client = createClient< }, }); +export const grida_g11n_service_client = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_KEY!, + { + db: { + schema: "grida_g11n", + }, + } +); + +// TODO: rename to createServerComponentFormsClient export const createServerComponentClient = ( cookieStore: ReadonlyRequestCookies ) => @@ -81,6 +92,21 @@ export const createServerComponentWorkspaceClient = ( } ); +export const createServerComponentG11nClient = ( + cookieStore: ReadonlyRequestCookies +) => + _createServerComponentClient( + { + cookies: () => cookieStore, + }, + { + options: { + db: { schema: "grida_g11n" }, + }, + } + ); + +// TODO: rename to createRouteHandlerFormsClient export const createRouteHandlerClient = (cookieStore: ReadonlyRequestCookies) => _createRouteHandlerClient( { diff --git a/apps/forms/lib/supabase/types.ts b/apps/forms/lib/supabase/types.ts new file mode 100644 index 000000000..4c380f8e2 --- /dev/null +++ b/apps/forms/lib/supabase/types.ts @@ -0,0 +1,18 @@ +import type { Database } from "@/database.types"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +export type TGridaCommerceSupabaseClient = SupabaseClient< + Database, + "grida_commerce" +>; + +export type TGridaG11nSupabaseClient = SupabaseClient; + +export type TGridaFormsSupabaseClient = SupabaseClient; + +export type TGridaWorkspaceSupabaseClient = SupabaseClient; + +export type TGridaXSupabaseSupabaseClient = SupabaseClient< + Database, + "grida_x_supabase" +>; diff --git a/apps/forms/next.config.mjs b/apps/forms/next.config.mjs index b7a85019b..6f8320029 100644 --- a/apps/forms/next.config.mjs +++ b/apps/forms/next.config.mjs @@ -33,6 +33,11 @@ const nextConfig = withMDX()({ destination: "/", permanent: true, }, + { + source: "/issues/new", + destination: "https://github.com/gridaco/grida/issues/new/choose", + permanent: true, + }, // DO NOT ADD BELOW. this will match all paths with 3 segments. // { // source: "/:org/:proj/:id", diff --git a/apps/forms/package.json b/apps/forms/package.json index b8cce9bb5..4f664c24f 100644 --- a/apps/forms/package.json +++ b/apps/forms/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "build": "next build", "start": "next start", "lint": "next lint", @@ -52,6 +52,7 @@ "@radix-ui/react-toolbar": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", "@react-email/components": "^0.0.18", + "@stepperize/react": "^3.0.1", "@supabase/auth-helpers-nextjs": "^0.9.0", "@supabase/postgrest-js": "^1.15.5", "@supabase/ssr": "^0.1.0", diff --git a/apps/forms/scaffolds/analytics/stats/index.tsx b/apps/forms/scaffolds/analytics/stats/index.tsx index e8a7108f0..2ae9fe8fa 100644 --- a/apps/forms/scaffolds/analytics/stats/index.tsx +++ b/apps/forms/scaffolds/analytics/stats/index.tsx @@ -3,8 +3,8 @@ import type { Database } from "@/database.types"; import React, { useEffect, useMemo, useState } from "react"; import { - createClientFormsClient, - createClientWorkspaceClient, + createClientComponentFormsClient, + createClientComponentWorkspaceClient, } from "@/lib/supabase/client"; import TimeSeriesChart from "../charts/timeseries"; import { GraphSkeleton, NumberSkeleton } from "../charts/skeleton"; @@ -183,7 +183,7 @@ export function Sessions({ from: Date; to: Date; }) { - const supabase = useMemo(() => createClientFormsClient(), []); + const supabase = useMemo(() => createClientComponentFormsClient(), []); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); @@ -255,7 +255,7 @@ export function Customers({ from: Date; to: Date; }) { - const supabase = useMemo(() => createClientWorkspaceClient(), []); + const supabase = useMemo(() => createClientComponentWorkspaceClient(), []); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); @@ -327,7 +327,7 @@ export function Responses({ from: Date; to: Date; }) { - const supabase = useMemo(() => createClientFormsClient(), []); + const supabase = useMemo(() => createClientComponentFormsClient(), []); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); diff --git a/apps/forms/scaffolds/blocks-editor/blocks-editor.tsx b/apps/forms/scaffolds/blocks-editor/blocks-editor.tsx index ad35d1434..78f94daef 100644 --- a/apps/forms/scaffolds/blocks-editor/blocks-editor.tsx +++ b/apps/forms/scaffolds/blocks-editor/blocks-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useId, useRef } from "react"; +import React, { useCallback, useEffect, useId, useMemo, useRef } from "react"; import { type EditorFlatFormBlock, DRAFT_ID_START_WITH } from "../editor/state"; import { useEditorState } from "../editor"; import { @@ -16,7 +16,7 @@ import { SortableContext, verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import { createClientFormsClient } from "@/lib/supabase/client"; +import { createClientComponentFormsClient } from "@/lib/supabase/client"; import toast from "react-hot-toast"; import { InsertMenuTrigger } from "./insert-menu-trigger"; import { SectionStyle } from "../agent/theme"; @@ -24,6 +24,10 @@ import { usePrevious } from "@uidotdev/usehooks"; import equal from "deep-equal"; import { FormPageBackgroundSchema, FormStyleSheetV1Schema } from "@/types"; import { FormAgentProvider, initdummy } from "@/lib/formstate"; +import { Button } from "@/components/ui/button"; +import { PoweredByGridaFooter } from "@/scaffolds/e/form/powered-by-brand-footer"; +import clsx from "clsx"; +import common from "@/i18n/resources.common"; export default function BlocksEditorRoot() { return ( @@ -86,7 +90,7 @@ function DndContextProvider({ children }: React.PropsWithChildren<{}>) { function PendingBlocksResolver() { const [state, dispatch] = useEditorState(); - const supabase = createClientFormsClient(); + const supabase = createClientComponentFormsClient(); const insertBlock = useCallback( async (block: EditorFlatFormBlock) => { @@ -150,7 +154,7 @@ function PendingBlocksResolver() { function useSyncBlocks(blocks: EditorFlatFormBlock[]) { // TODO: add debounce - const supabase = createClientFormsClient(); + const supabase = createClientComponentFormsClient(); const prevBlocksRef = useRef(blocks); useEffect(() => { @@ -221,11 +225,39 @@ function OptimisticBlocksSyncProvider({ return <>{children}; } +function LangSyncProvider({ children }: React.PropsWithChildren<{}>) { + const [state] = useEditorState(); + const { document_id } = state; + const prev = usePrevious(state.document.g11n); + const supabase = useMemo(() => createClientComponentFormsClient(), []); + + useEffect(() => { + if (!prev) { + return; + } + + // sync lang to server + if (!equal(prev, state.document.g11n)) { + // update lang + supabase + .from("form_document") + .update({ + lang: state.document.g11n.lang, + }) + .eq("id", document_id!) + .then(({ error }) => { + if (error) console.error(error); + }); + } + }, [prev, document_id, state.document.g11n]); + return <>{children}; +} + function AgentThemeSyncProvider({ children }: React.PropsWithChildren<{}>) { const [state] = useEditorState(); - const { document_id, theme } = state; + const { document_id, document, theme } = state; const prev = usePrevious(state.theme); - const supabase = createClientFormsClient(); + const supabase = useMemo(() => createClientComponentFormsClient(), []); useEffect(() => { if (!prev) { @@ -238,7 +270,6 @@ function AgentThemeSyncProvider({ children }: React.PropsWithChildren<{}>) { supabase .from("form_document") .update({ - lang: theme.lang, is_powered_by_branding_enabled: theme.is_powered_by_branding_enabled, stylesheet: { appearance: theme.appearance, @@ -263,7 +294,6 @@ function AgentThemeSyncProvider({ children }: React.PropsWithChildren<{}>) { supabase, document_id, theme.is_powered_by_branding_enabled, - theme.lang, theme.appearance, theme.customCSS, theme.fontFamily, @@ -295,6 +325,7 @@ function BlocksEditor() { + b.id)} @@ -311,11 +342,43 @@ function BlocksEditor() { +

); } +function FooterPreview() { + const [state] = useEditorState(); + + const { + theme: { is_powered_by_branding_enabled }, + document: { g11n }, + } = state; + + return ( + <> +
+ +
+ {is_powered_by_branding_enabled && ( +
+ +
+ )} + + ); +} + function shallowEqual(obj1: any, obj2: any) { if (obj1 === obj2) return true; diff --git a/apps/forms/scaffolds/blocks-editor/blocks/base-block.tsx b/apps/forms/scaffolds/blocks-editor/blocks/base-block.tsx index 68fba9674..1d3da6bad 100644 --- a/apps/forms/scaffolds/blocks-editor/blocks/base-block.tsx +++ b/apps/forms/scaffolds/blocks-editor/blocks/base-block.tsx @@ -1,6 +1,6 @@ "use client"; -import { createClientFormsClient } from "@/lib/supabase/client"; +import { createClientComponentFormsClient } from "@/lib/supabase/client"; import { useEditorState } from "@/scaffolds/editor"; import { useCallback } from "react"; import toast from "react-hot-toast"; @@ -8,7 +8,7 @@ import clsx from "clsx"; export function useDeleteBlock() { const [state, dispatch] = useEditorState(); - const supabase = createClientFormsClient(); + const supabase = createClientComponentFormsClient(); const deleteBlock = useCallback( async (id: string) => { diff --git a/apps/forms/scaffolds/blocks-editor/blocks/field-block.tsx b/apps/forms/scaffolds/blocks-editor/blocks/field-block.tsx index d0eb9d129..7472a4a56 100644 --- a/apps/forms/scaffolds/blocks-editor/blocks/field-block.tsx +++ b/apps/forms/scaffolds/blocks-editor/blocks/field-block.tsx @@ -46,6 +46,9 @@ import { Button } from "@/components/ui/button"; import clsx from "clsx"; import { editorlink } from "@/lib/forms/url"; import { SYSTEM_GF_KEY_STARTS_WITH } from "@/k/system"; +import { useDialogState } from "@/components/hooks/use-dialog-state"; +import { useG11nResource } from "@/scaffolds/editor/use"; +import { g11nkey } from "@/scaffolds/editor/g11n"; export function FieldBlock({ id, @@ -56,9 +59,13 @@ export function FieldBlock({ const [state, dispatch] = useEditorState(); const [focused, setFocus] = useBlockFocus(id); - const fields = useFormFields(); + const { + form: { available_field_ids }, + } = state; - const { document_id, basepath } = state; + const advancedModeDialog = useDialogState(); + + const fields = useFormFields(); const form_field: FormFieldDefinition | undefined = fields.find( (f) => f.id === form_field_id @@ -66,13 +73,20 @@ export function FieldBlock({ const is_hidden_field = form_field?.type === "hidden"; - const { - form: { available_field_ids }, - } = state; - const [advanced, setAdvanced] = useState(false); - const can_advanced_mode = fields.length > 0; + const label = useG11nResource( + g11nkey("block", { id: id, property: "label" }) + ); + + const placeholder = useG11nResource( + g11nkey("block", { id: id, property: "placeholder" }) + ); + + const helptext = useG11nResource( + g11nkey("block", { id: id, property: "help_text" }) + ); + const onFieldChange = useCallback( (field_id: string) => { dispatch({ @@ -96,44 +110,42 @@ export function FieldBlock({ }); }, [dispatch, id]); - const onFieldEditClick = useCallback(() => { - dispatch({ - type: "editor/field/edit", - field_id: form_field_id!, - }); - }, [dispatch, form_field_id]); - return ( - - -
- - - { - if (!open) { - setAdvanced(false); - } - }} - > - - Advanced Mode - - In advanced mode, you can re-use already referenced field. - This is useful when there are multiple blocks that should be - visible optionally. (Use with caution, only one value will be - accepted if there are multiple rendered blocks with the same - field) - -
+ <> + + + +
+ + + {fields.length === 0 ? ( + <> + + + ) : ( + <> -
- - - - - - -
- {fields.length === 0 ? ( - <> - - - ) : ( - <> - - - )} -
-
-
- - - - - - - - -
-
-
- {is_hidden_field ? ( -
-

- Hidden fields are not displayed in the form. -
- Configure how this field is populated with{" "} - - URL Parameters - {" "} - {form_field.required ? "(required)" : ""}{" "} - {!( - form_field.name.startsWith(SYSTEM_GF_KEY_STARTS_WITH) || - form_field.required - ) && ( - <> - or{" "} - + {can_advanced_mode && ( + +

+ + Advanced +
+ + )} + + )} -

+ +
+
+ + + + + + + +
- ) : ( - + +
+ {is_hidden_field ? ( + + ) : ( + + )} +
+ + + ); +} + +function HiddenFieldInfo({ data }: { data: FormFieldDefinition }) { + const [state, dispatch] = useEditorState(); + const { document_id, basepath } = state; + + const onFieldEditClick = useCallback(() => { + dispatch({ + type: "editor/field/edit", + field_id: data.id, + }); + }, [dispatch, data.id]); + + return ( +
+

+ Hidden fields are not displayed in the form. +
+ Configure how this field is populated with{" "} + + URL Parameters + {" "} + {data.required ? "(required)" : ""}{" "} + {!( + data.name.startsWith(SYSTEM_GF_KEY_STARTS_WITH) || data.required + ) && ( + <> + or{" "} + + )} -

- +

+
); } @@ -336,3 +313,77 @@ export function FormFieldBlockMenuItems({ ); } + +function AdvancedModeDialog({ + block_id, + form_field_id, + ...props +}: React.ComponentProps & { + block_id: string; + form_field_id: string; +}) { + const [state, dispatch] = useEditorState(); + + const fields = useFormFields(); + + const onFieldChange = useCallback( + (field_id: string) => { + dispatch({ + type: "blocks/field/change", + field_id, + block_id: block_id, + }); + }, + [dispatch, block_id] + ); + + return ( + + + Advanced Mode + + In advanced mode, you can re-use already referenced field. This is + useful when there are multiple blocks that should be visible + optionally. (Use with caution, only one value will be accepted if + there are multiple rendered blocks with the same field) + +
+ +
+ + + + + +
+
+ ); +} diff --git a/apps/forms/scaffolds/blocks-editor/blocks/header-block.tsx b/apps/forms/scaffolds/blocks-editor/blocks/header-block.tsx index 927c4cb11..7e916bfa5 100644 --- a/apps/forms/scaffolds/blocks-editor/blocks/header-block.tsx +++ b/apps/forms/scaffolds/blocks-editor/blocks/header-block.tsx @@ -1,6 +1,5 @@ "use client"; -import { useCallback } from "react"; import { DotsHorizontalIcon, HeadingIcon, @@ -12,8 +11,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { EditorFlatFormBlock } from "@/scaffolds/editor/state"; -import { useEditorState } from "@/scaffolds/editor"; import { BlockHeader, FlatBlockBase, @@ -22,36 +19,19 @@ import { } from "./base-block"; import TextareaAutosize from "react-textarea-autosize"; import { Button } from "@/components/ui/button"; +import { useG11nResource } from "@/scaffolds/editor/use"; +import { g11nkey } from "@/scaffolds/editor/g11n"; -export function HeaderBlock({ - id, - title_html, - description_html, -}: EditorFlatFormBlock) { - const [state, dispatch] = useEditorState(); +export function HeaderBlock({ id }: { id: string }) { const deleteBlock = useDeleteBlock(); const [focused, setFocus] = useBlockFocus(id); - const onEditTitle = useCallback( - (title: string) => { - dispatch({ - type: "blocks/title", - block_id: id, - title_html: title, - }); - }, - [dispatch, id] + const title = useG11nResource( + g11nkey("block", { id: id, property: "title_html" }) ); - const onEditDescription = useCallback( - (description: string) => { - dispatch({ - type: "blocks/description", - block_id: id, - description_html: description, - }); - }, - [dispatch, id] + const description = useG11nResource( + g11nkey("block", { id: id, property: "description_html" }) ); return ( @@ -87,15 +67,15 @@ export function HeaderBlock({ type="text" className="bg-background w-full p-4 text-2xl font-bold outline-none" placeholder="Heading" - value={title_html ?? ""} - onChange={(e) => onEditTitle(e.target.value)} + value={title.value} + onChange={(e) => title.change(e.target.value)} /> onEditDescription(e.target.value)} + value={description.value} + onChange={(e) => description.change(e.target.value)} />
diff --git a/apps/forms/scaffolds/blocks-editor/blocks/pdf-block.tsx b/apps/forms/scaffolds/blocks-editor/blocks/pdf-block.tsx index 948847ece..244a4df9b 100644 --- a/apps/forms/scaffolds/blocks-editor/blocks/pdf-block.tsx +++ b/apps/forms/scaffolds/blocks-editor/blocks/pdf-block.tsx @@ -21,6 +21,7 @@ import { } from "./base-block"; import { useEditorState } from "@/scaffolds/editor"; import { PDFViewer } from "@/components/pdf-viewer"; +import { Input } from "@/components/ui/input"; export function PdfBlock({ id, @@ -64,17 +65,17 @@ export function PdfBlock({
- { + // TODO: dispatch({ type: "blocks/video/src", block_id: id, src: e.target.value, }); }} - className="bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-neutral-700 dark:border-neutral-600 dark:placeholder-neutral-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Video URL" />
diff --git a/apps/forms/scaffolds/blocks-editor/blocks/video-block.tsx b/apps/forms/scaffolds/blocks-editor/blocks/video-block.tsx index a474eade6..73305eb1e 100644 --- a/apps/forms/scaffolds/blocks-editor/blocks/video-block.tsx +++ b/apps/forms/scaffolds/blocks-editor/blocks/video-block.tsx @@ -23,6 +23,9 @@ import { } from "./base-block"; import { useEditorState } from "@/scaffolds/editor"; import dynamic from "next/dynamic"; +import { Input } from "@/components/ui/input"; +import { useG11nResource } from "@/scaffolds/editor/use"; +import { g11nkey } from "@/scaffolds/editor/g11n"; const ReactPlayer = dynamic(() => import("react-player/lazy"), { ssr: false }); @@ -30,13 +33,15 @@ export function VideoBlock({ id, type, form_field_id, - src, + // src, data, }: EditorFlatFormBlock) { const [state, dispatch] = useEditorState(); const [focused, setFocus] = useBlockFocus(id); const deleteBlock = useDeleteBlock(); + const src = useG11nResource(g11nkey("block", { id: id, property: "src" })); + return ( @@ -77,22 +82,18 @@ export function VideoBlock({
- { - dispatch({ - type: "blocks/video/src", - block_id: id, - src: e.target.value, - }); + src.change(e.target.value || undefined); }} - className="bg-neutral-50 border border-neutral-300 text-neutral-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-neutral-700 dark:border-neutral-600 dark:placeholder-neutral-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Video URL" />
- +
diff --git a/apps/forms/scaffolds/dialogs/langs-add-dialog.tsx b/apps/forms/scaffolds/dialogs/langs-add-dialog.tsx new file mode 100644 index 000000000..46f3a3731 --- /dev/null +++ b/apps/forms/scaffolds/dialogs/langs-add-dialog.tsx @@ -0,0 +1,76 @@ +"use client"; + +import React, { useCallback } from "react"; +import { useEditorState } from "../editor"; +import { LanguageCode } from "@/types"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; +import { + LanguageSelect, + LanguageSelectOptionMap, +} from "@/components/language-select"; +import { Button } from "@/components/ui/button"; + +export function AddNewLanguageDialog({ + ...props +}: React.ComponentProps) { + const [state, dispatch] = useEditorState(); + + const { langs } = state.document.g11n; + + const [newlang, setNewLang] = React.useState(); + + const onAddLang = useCallback( + (lang: LanguageCode) => { + dispatch({ + type: "editor/document/langs/add", + lang, + }); + }, + [dispatch] + ); + + return ( + + + Add New Language + Select a language to add. +
{ + e.preventDefault(); + // + onAddLang(newlang!); + props?.onOpenChange?.(false); + }} + > +
+ { + acc[l] = { disabled: true }; + return acc; + }, {})} + value={newlang} + onValueChange={setNewLang} + /> +
+
+ + + + + + +
+
+ ); +} diff --git a/apps/forms/scaffolds/editable-document-title.tsx b/apps/forms/scaffolds/editable-document-title.tsx index 71f0ef9bf..36f58ac6e 100644 --- a/apps/forms/scaffolds/editable-document-title.tsx +++ b/apps/forms/scaffolds/editable-document-title.tsx @@ -1,7 +1,7 @@ "use client"; import { Input } from "@/components/ui/input"; -import { createClientWorkspaceClient } from "@/lib/supabase/client"; +import { createClientComponentWorkspaceClient } from "@/lib/supabase/client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; import toast from "react-hot-toast"; import { useEditorState } from "./editor"; @@ -17,7 +17,7 @@ export function EditableDocumentTitle({ const [value, setValue] = useState(defaultValue || ""); - const supabase = useMemo(() => createClientWorkspaceClient(), []); + const supabase = useMemo(() => createClientComponentWorkspaceClient(), []); // eslint-disable-next-line react-hooks/exhaustive-deps const updateTitle = useCallback( diff --git a/apps/forms/scaffolds/editor/action.ts b/apps/forms/scaffolds/editor/action.ts index 76d164105..22d213274 100644 --- a/apps/forms/scaffolds/editor/action.ts +++ b/apps/forms/scaffolds/editor/action.ts @@ -9,7 +9,7 @@ import type { FormResponse, FormResponseWithFields, FormStyleSheetV1Schema, - FormsPageLanguage, + LanguageCode, GridaXSupabase, } from "@/types"; import type { @@ -22,8 +22,9 @@ import type { Tokens } from "@/ast"; import { SYM_LOCALTZ } from "./symbols"; import { ZodObject } from "zod"; -export type BlocksEditorAction = +export type EditorAction = | GlobalSavingAction + | GlobalWorkbenchPathAction | EditorSidebarModeAction | CreateNewPendingBlockAction | ResolvePendingBlockAction @@ -64,7 +65,7 @@ export type BlocksEditorAction = | DataTableLoadingAction | DataGridCellChangeAction | FeedXSupabaseMainTableRowsAction - | EditorThemeLangAction + | NSEditorDocumentLangAction | EditorThemePoweredByBrandingAction | EditorThemePaletteAction | EditorThemeAppearanceAction @@ -90,6 +91,14 @@ export type GlobalSavingAction = { saving: boolean; }; +/** + * /[org]/[proj]/[docid]/[...workbenchpath] + */ +export type GlobalWorkbenchPathAction = { + type: "workbench/path"; + path: string; +}; + export interface EditorSidebarModeAction { type: "editor/sidebar/mode"; mode: "project" | "build" | "data" | "connect"; @@ -142,30 +151,45 @@ export interface BlockVHiddenAction { v_hidden: Tokens.ShorthandBooleanBinaryExpression; } +/** + * @deprecated - remove me - use translation module + */ export interface HtmlBlockBodyAction { type: "blocks/html/body"; block_id: string; html: string; } +/** + * @deprecated - remove me - use translation module + */ export interface ImageBlockSrcAction { type: "blocks/image/src"; block_id: string; src: string; } +/** + * @deprecated - remove me - use translation module + */ export interface VideoBlockSrcAction { type: "blocks/video/src"; block_id: string; src: string; } +/** + * @deprecated - remove me - use translation module + */ export interface BlockTitleAction { type: "blocks/title"; block_id: string; title_html: string; } +/** + * @deprecated - remove me - use translation module + */ export interface BlockDescriptionAction { type: "blocks/description"; block_id: string; @@ -334,11 +358,6 @@ export interface FeedXSupabaseMainTableRowsAction { data: GridaXSupabase.XDataRow[]; } -export interface EditorThemeLangAction { - type: "editor/theme/lang"; - lang: FormsPageLanguage; -} - export interface EditorThemePoweredByBrandingAction { type: "editor/theme/powered_by_branding"; enabled: boolean; @@ -384,6 +403,41 @@ export interface FormEndingPreferencesAction type: "editor/form/ending/preferences"; } +// #region lang +export type NSEditorDocumentLangAction = + | EditorDocumentLangSetCurrentAction + | EditorDocumentLangSetDefaultAction + | EditorDocumentLangAddAction + | EditorDocumentLangDeleteAction + | EditorDocumentLangMessageAction; + +export interface EditorDocumentLangSetCurrentAction { + type: "editor/document/langs/set-current"; + lang: LanguageCode; +} + +export interface EditorDocumentLangSetDefaultAction { + type: "editor/document/langs/set-default"; + lang: LanguageCode; +} + +export interface EditorDocumentLangAddAction { + type: "editor/document/langs/add"; + lang: LanguageCode; +} +export interface EditorDocumentLangDeleteAction { + type: "editor/document/langs/delete"; + lang: LanguageCode; +} + +export interface EditorDocumentLangMessageAction { + type: "editor/document/langs/messages/change"; + lang: LanguageCode; + key: string; + message: string | undefined; +} +// #endregion lang + export interface DocumentSelectPageAction { type: "editor/document/select-page"; page_id: string; diff --git a/apps/forms/scaffolds/editor/dispatch.ts b/apps/forms/scaffolds/editor/dispatch.ts index 0d8ca6b72..b8af50f56 100644 --- a/apps/forms/scaffolds/editor/dispatch.ts +++ b/apps/forms/scaffolds/editor/dispatch.ts @@ -1,9 +1,9 @@ -import { BlocksEditorAction } from "./action"; +import { EditorAction } from "./action"; import { createContext, useCallback, useContext } from "react"; -export type Dispatcher = (action: BlocksEditorAction) => void; +export type Dispatcher = (action: EditorAction) => void; -export type FlatDispatcher = (action: BlocksEditorAction) => void; +export type FlatDispatcher = (action: EditorAction) => void; const __noop = () => {}; @@ -12,7 +12,7 @@ export const DispatchContext = createContext(__noop); export const useDispatch = (): FlatDispatcher => { const dispatch = useContext(DispatchContext); return useCallback( - (action: BlocksEditorAction) => { + (action: EditorAction) => { dispatch(action); }, [dispatch] diff --git a/apps/forms/scaffolds/editor/feed.tsx b/apps/forms/scaffolds/editor/feed.tsx index 13492ca7e..f4a189de6 100644 --- a/apps/forms/scaffolds/editor/feed.tsx +++ b/apps/forms/scaffolds/editor/feed.tsx @@ -9,8 +9,8 @@ import { } from "./use"; import toast from "react-hot-toast"; import { - createClientFormsClient, - createClientWorkspaceClient, + createClientComponentFormsClient, + createClientComponentWorkspaceClient, } from "@/lib/supabase/client"; import { RealtimePostgresChangesPayload } from "@supabase/supabase-js"; import useSWR from "swr"; @@ -51,7 +51,7 @@ const useSubscription = ({ onDelete?: (data: RealtimeTableChangeData | {}) => void; enabled: boolean; }) => { - const supabase = useMemo(() => createClientFormsClient(), []); + const supabase = useMemo(() => createClientComponentFormsClient(), []); useEffect(() => { if (!form_id) return; @@ -94,7 +94,7 @@ const useSubscription = ({ }; function useFetchSchemaTableRows(table_id: string) { - const supabase = useMemo(() => createClientFormsClient(), []); + const supabase = useMemo(() => createClientComponentFormsClient(), []); return useCallback( async (limit: number = 100) => { @@ -122,7 +122,7 @@ function useFetchSchemaTableRows(table_id: string) { } function useFetchSchemaTableRow() { - const supabase = useMemo(() => createClientFormsClient(), []); + const supabase = useMemo(() => createClientComponentFormsClient(), []); return useCallback( async (id: string) => { @@ -148,7 +148,7 @@ function useFetchSchemaTableRow() { } function useFetchResponseSessions(form_id: string) { - const supabase = useMemo(() => createClientFormsClient(), []); + const supabase = useMemo(() => createClientComponentFormsClient(), []); return useCallback( async (limit: number = 100) => { @@ -185,7 +185,7 @@ function useChangeDatagridLoading() { } function useSyncCell() { - const supabase = useMemo(() => createClientFormsClient(), []); + const supabase = useMemo(() => createClientComponentFormsClient(), []); return useCallback( async (id: string, payload: { value: any; option_id?: string | null }) => { @@ -625,7 +625,7 @@ export function CustomerFeedProvider({ datagrid_table_refresh_key, } = state; - const client = useMemo(() => createClientWorkspaceClient(), []); + const client = useMemo(() => createClientComponentWorkspaceClient(), []); const setLoading = useChangeDatagridLoading(); diff --git a/apps/forms/scaffolds/editor/g11n.ts b/apps/forms/scaffolds/editor/g11n.ts new file mode 100644 index 000000000..d46504a08 --- /dev/null +++ b/apps/forms/scaffolds/editor/g11n.ts @@ -0,0 +1,21 @@ +import type { IFormBlock, IFormField } from "@/types"; + +type G11nFormBlockResourceQuery = [ + type: "block", + { + id: string; + property: + | keyof Pick< + IFormBlock, + "title_html" | "description_html" | "body_html" | "src" + > + | keyof Pick; + }, +]; + +type ResourceKeyQuery = G11nFormBlockResourceQuery; + +export function g11nkey(...q: ResourceKeyQuery) { + const [type, { id, property }] = q; + return [type, id, property].join("."); +} diff --git a/apps/forms/scaffolds/editor/init/g11n.init.ts b/apps/forms/scaffolds/editor/init/g11n.init.ts new file mode 100644 index 000000000..4d24471a4 --- /dev/null +++ b/apps/forms/scaffolds/editor/init/g11n.init.ts @@ -0,0 +1,130 @@ +import type { FormBlock, FormFieldDefinition, LanguageCode } from "@/types"; +import { g11nkey } from "../g11n"; +import { FieldSupports } from "@/k/supported_field_types"; + +type ResourceKV = Record; + +abstract class G11nKVInit { + readonly keys: string[] = []; + readonly resources: ResourceKV = {}; + + constructor() {} +} + +export class FormDocumentG11nKVInit extends G11nKVInit { + readonly keys: string[] = []; + readonly resources: ResourceKV = {}; + + constructor( + readonly blocks: FormBlock[], + readonly fields: FormFieldDefinition[] + ) { + super(); + const { keys, resources } = this.initialize(); + + this.keys = keys; + this.resources = resources; + } + + private initialize(): { + keys: string[]; + resources: ResourceKV; + } { + const keys: string[] = []; + const resources: ResourceKV = {}; + + this.blocks.forEach((b) => { + if (b.type === "field") { + if (!b.form_field_id) { + return; + } + + const field = this.fields.find((f) => f.id === b.form_field_id); + if (!field) { + return; + } + + const labelkey = g11nkey("block", { id: b.id, property: "label" }); + const placeholderkey = g11nkey("block", { + id: b.id, + property: "placeholder", + }); + const helptextkey = g11nkey("block", { + id: b.id, + property: "help_text", + }); + + if (FieldSupports.placeholder(field.type)) { + keys.push(labelkey, placeholderkey, helptextkey); + resources[labelkey] = field.label; + resources[placeholderkey] = field.placeholder; + resources[helptextkey] = field.help_text; + } else { + // conditionally available + keys.push(labelkey, /*placeholderkey*/ helptextkey); + + resources[labelkey] = field.label; + resources[helptextkey] = field.help_text; + } + } else { + switch (b.type) { + case "video": + case "image": + case "pdf": { + const srckey = g11nkey("block", { id: b.id, property: "src" }); + + // register key + keys.push(srckey); + + // register resource + resources[srckey] = b.data.src; + break; + } + case "header": { + const titlekey = g11nkey("block", { + id: b.id, + property: "title_html", + }); + + const descriptionkey = g11nkey("block", { + id: b.id, + property: "description_html", + }); + + // register key + keys.push(titlekey); + keys.push(descriptionkey); + + // register resource + resources[titlekey] = b.title_html; + resources[descriptionkey] = b.description_html; + break; + } + case "html": { + const bodykey = g11nkey("block", { + id: b.id, + property: "body_html", + }); + + // register key + keys.push(bodykey); + + // register resource + resources[bodykey] = b.body_html; + break; + } + + case "section": + case "group": + case "divider": + break; + } + } + }); + + return { + keys, + resources, + }; + } +} diff --git a/apps/forms/scaffolds/editor/init.ts b/apps/forms/scaffolds/editor/init/index.ts similarity index 94% rename from apps/forms/scaffolds/editor/init.ts rename to apps/forms/scaffolds/editor/init/index.ts index ebc90238e..977eaca36 100644 --- a/apps/forms/scaffolds/editor/init.ts +++ b/apps/forms/scaffolds/editor/init/index.ts @@ -15,11 +15,19 @@ import type { TableXSBMainTableConnection, GDocSchemaTable, TableMenuItem, -} from "./state"; + IG11nState, +} from "../state"; import { blockstreeflat } from "@/lib/forms/tree"; -import { SYM_LOCALTZ, EditorSymbols } from "./symbols"; -import { FormFieldDefinition, GridaXSupabase } from "@/types"; +import { SYM_LOCALTZ, EditorSymbols } from "../symbols"; +import type { + FormBlock, + FormFieldDefinition, + GridaXSupabase, + IFormField, + LanguageCode, +} from "@/types"; import { SupabasePostgRESTOpenApi } from "@/lib/supabase-postgrest"; +import { FormDocumentG11nKVInit } from "./g11n.init"; export function initialEditorState(init: EditorInit): EditorState { switch (init.doctype) { @@ -163,6 +171,10 @@ function initialDatabaseEditorState( supabase_project: init.supabase_project, connections: {}, document: { + g11n: { + ...initialize_g11n_state_without_keys_from_lang(init.document.lang), + keys: [], + }, pages: [], nodes: [], templatedata: {}, @@ -239,6 +251,10 @@ function initialSiteEditorState(init: SiteDocumentEditorInit): EditorState { return { ...base, document: { + g11n: { + ...initialize_g11n_state_without_keys_from_lang(init.document.lang), + keys: [], + }, pages: sitedocumentpagesinit({ basepath: base.basepath, document_id: init.document_id, @@ -415,6 +431,8 @@ function initialFormEditorState(init: FormDocumentEditorInit): EditorState { const tableids = Object.getOwnPropertySymbols(tables); const values = tableids.map((id) => (tables as any)[id]); + const g11ninit = new FormDocumentG11nKVInit(init.blocks, init.fields); + return { ...base, supabase_project: init.connections?.supabase?.supabase_project ?? null, @@ -443,6 +461,11 @@ function initialFormEditorState(init: FormDocumentEditorInit): EditorState { blocks: blockstreeflat(init.blocks), document: { + g11n: { + ...initialize_g11n_state_without_keys_from_lang(init.document.lang), + resources: { [init.document.lang]: g11ninit.resources }, + keys: g11ninit.keys, + }, pages: formdocumentpagesinit({ basepath: base.basepath, document_id: init.document_id, @@ -515,6 +538,20 @@ function sitedocumentpagesinit({ ]; } +function initialize_g11n_state_without_keys_from_lang( + lang: LanguageCode +): Omit { + return { + lang: lang, + lang_default: lang, + langs: [lang], + manifest_id: null, + resources: { + [lang]: {}, + }, + }; +} + function formdocumentpagesinit({ basepath, document_id, diff --git a/apps/forms/scaffolds/editor/reducer.ts b/apps/forms/scaffolds/editor/reducer.ts index bcf78ef0b..ca6d77790 100644 --- a/apps/forms/scaffolds/editor/reducer.ts +++ b/apps/forms/scaffolds/editor/reducer.ts @@ -14,11 +14,12 @@ import type { } from "./state"; import type { GlobalSavingAction, + GlobalWorkbenchPathAction, EditorSidebarModeAction, BlockDescriptionAction, BlockTitleAction, BlockVHiddenAction, - BlocksEditorAction, + EditorAction, ChangeBlockFieldAction, CreateFielFromBlockdAction, CreateNewPendingBlockAction, @@ -50,7 +51,6 @@ import type { FeedXSupabaseMainTableRowsAction, DataTableRefreshAction, DataTableLoadingAction, - EditorThemeLangAction, EditorThemePaletteAction, EditorThemeFontFamilyAction, EditorThemeBackgroundAction, @@ -98,11 +98,9 @@ import { table_to_sidebar_table_menu, } from "./init"; import assert from "assert"; +import langReducer from "./reducers/lang.reducer"; -export function reducer( - state: EditorState, - action: BlocksEditorAction -): EditorState { +export function reducer(state: EditorState, action: EditorAction): EditorState { switch (action.type) { case "saving": { const { saving } = action; @@ -110,6 +108,17 @@ export function reducer( draft.saving = saving; }); } + case "workbench/path": { + // TODO: experiemntal + const { path } = action; + return produce(state, (draft) => { + if (path === "form/edit") { + draft.document.selected_page_id = "form"; + } else { + draft.document.selected_page_id = ""; + } + }); + } case "editor/sidebar/mode": { const { mode } = action; return produce(state, (draft) => { @@ -308,11 +317,12 @@ export function reducer( } case "blocks/delete": { const { block_id } = action; - console.log("delete block", block_id); return produce(state, (draft) => { // remove the field id from available_field_ids draft.blocks = draft.blocks.filter((block) => block.id !== block_id); + draft.focus_block_id = null; + // find the field_id of the deleted block const field_id = state.blocks.find( (b) => b.id === block_id @@ -983,12 +993,6 @@ export function reducer( }); // } - case "editor/theme/lang": { - const { lang } = action; - return produce(state, (draft) => { - draft.theme.lang = lang; - }); - } case "editor/theme/powered_by_branding": { const { enabled } = action; return produce(state, (draft) => { @@ -1049,7 +1053,15 @@ export function reducer( draft.theme.customCSS = custom; }); } - // + // #region lang + case "editor/document/langs/set-current": + case "editor/document/langs/set-default": + case "editor/document/langs/add": + case "editor/document/langs/delete": + case "editor/document/langs/messages/change": { + return langReducer(state, action); + } + // #endregion lang case "editor/document/select-page": { const { page_id } = action; diff --git a/apps/forms/scaffolds/editor/reducers/lang.reducer.ts b/apps/forms/scaffolds/editor/reducers/lang.reducer.ts new file mode 100644 index 000000000..17b1fb1dd --- /dev/null +++ b/apps/forms/scaffolds/editor/reducers/lang.reducer.ts @@ -0,0 +1,90 @@ +import { produce, type Draft } from "immer"; +import type { EditorState } from "../state"; +import type { + EditorDocumentLangSetCurrentAction, + EditorDocumentLangSetDefaultAction, + EditorDocumentLangAddAction, + EditorDocumentLangDeleteAction, + NSEditorDocumentLangAction, + EditorDocumentLangMessageAction, +} from "../action"; +import assert from "assert"; +import toast from "react-hot-toast"; + +export default function langReducer( + state: EditorState, + action: NSEditorDocumentLangAction +): EditorState { + switch (action.type) { + case "editor/document/langs/set-current": { + const { lang } = action; + return produce(state, (draft) => { + assert(draft.document.g11n.langs.includes(lang), "Language not found"); + draft.document.g11n.lang = lang; + }); + } + case "editor/document/langs/set-default": { + const { lang } = action; + return produce(state, (draft) => { + if (draft.document.g11n.langs.length === 1) { + const prevlang = draft.document.g11n.lang; + draft.document.g11n.langs = [lang]; + draft.document.g11n.lang_default = lang; + draft.document.g11n.lang = lang; + // swap the resources lang key + draft.document.g11n.resources = { + [lang]: draft.document.g11n.resources[prevlang], + }; + } else { + assert( + draft.document.g11n.langs.includes(lang), + "Language not found" + ); + draft.document.g11n.lang_default = lang; + draft.document.g11n.lang = lang; + draft.document.g11n.langs = draft.document.g11n.langs + .slice() + .sort((a, b) => { + if (a === lang) return -1; + if (b === lang) return 1; + return 0; + }); + } + }); + } + case "editor/document/langs/add": { + const { lang } = action; + return produce(state, (draft) => { + const langs = new Set(draft.document.g11n.langs); + langs.add(lang); + draft.document.g11n.langs = Array.from(langs); + draft.document.g11n.lang = lang; + draft.document.g11n.resources[lang] = {}; + }); + } + case "editor/document/langs/delete": { + const { lang } = action; + return produce(state, (draft) => { + if (draft.document.g11n.langs.length === 1) { + toast.error("At least one language is required"); + return; + } + const langs = new Set(draft.document.g11n.langs); + langs.delete(lang); + draft.document.g11n.langs = Array.from(langs); + draft.document.g11n.lang = draft.document.g11n.langs[0]; + delete draft.document.g11n.resources[lang]; + }); + } + case "editor/document/langs/messages/change": { + const { lang, key, message } = action; + + return produce(state, (draft) => { + assert(draft.document.g11n.resources[lang], "Language not found"); + draft.document.g11n.resources[lang]![key] = message; + }); + } + } + + return state; +} diff --git a/apps/forms/scaffolds/editor/state.ts b/apps/forms/scaffolds/editor/state.ts index c4abfb2e2..0f06ed4fd 100644 --- a/apps/forms/scaffolds/editor/state.ts +++ b/apps/forms/scaffolds/editor/state.ts @@ -16,10 +16,11 @@ import type { FormResponseSession, FormResponseUnknownFieldHandlingStrategyType, FormStyleSheetV1Schema, - FormsPageLanguage, + LanguageCode, GDocumentType, GridaXSupabase, OrderBy, + PGINT8ID, } from "@/types"; import { SYM_LOCALTZ, EditorSymbols } from "./symbols"; import { ZodObject } from "zod"; @@ -52,6 +53,9 @@ export interface BaseDocumentEditorInit { }; document_id: string; document_title: string; + document: { + lang: LanguageCode; + }; doctype: GDocumentType; theme: EditorState["theme"]; } @@ -317,6 +321,74 @@ interface IInsertionMenuState { insertmenu: TGlobalEditorDialogState; } +export interface IG11nState { + g11n: { + /** + * grida_g11n.manifest.id + */ + manifest_id: PGINT8ID | null; + + /** + * view document in... + */ + lang: LanguageCode; + + /** + * default language + */ + lang_default: LanguageCode; + + /** + * available languages provided by user + */ + langs: LanguageCode[]; + + keys: Array; + + resources: Partial< + Record> + >; + }; +} + +interface IDocumentState extends IG11nState { + pages: MenuItem[]; + selected_page_id?: string; + nodes: any[]; + templatesample?: string; + templatedata: { + [key: string]: { + text?: Tokens.StringValueExpression; + template_id: string; + attributes?: Omit< + React.HtmlHTMLAttributes, + "style" | "className" + >; + properties?: { [key: string]: Tokens.StringValueExpression }; + style?: React.CSSProperties; + }; + }; + selected_node_id?: string; + selected_node_type?: string; + selected_node_schema?: ZodObject | null; + selected_node_default_properties?: Record; + selected_node_default_style?: React.CSSProperties; + selected_node_default_text?: Tokens.StringValueExpression; + selected_node_context?: Record; +} + +interface IEditorDocumentThemeState { + theme: { + is_powered_by_branding_enabled: boolean; + appearance: Appearance; + palette?: FormStyleSheetV1Schema["palette"]; + fontFamily: FontFamily; + customCSS?: FormStyleSheetV1Schema["custom"]; + section?: FormStyleSheetV1Schema["section"]; + background?: FormPageBackgroundSchema; + }; +} + export interface BaseDocumentEditorState extends IEditorGlobalSavingState, IEditorDateContextState, @@ -324,6 +396,7 @@ export interface BaseDocumentEditorState IInsertionMenuState, IFieldEditorState, ICustomerEditorState, + IEditorDocumentThemeState, IRowEditorState { basepath: string; organization: { @@ -337,41 +410,7 @@ export interface BaseDocumentEditorState document_id: string; document_title: string; doctype: GDocumentType; - document: { - pages: MenuItem[]; - selected_page_id?: string; - nodes: any[]; - templatesample?: string; - templatedata: { - [key: string]: { - text?: Tokens.StringValueExpression; - template_id: string; - attributes?: Omit< - React.HtmlHTMLAttributes, - "style" | "className" - >; - properties?: { [key: string]: Tokens.StringValueExpression }; - style?: React.CSSProperties; - }; - }; - selected_node_id?: string; - selected_node_type?: string; - selected_node_schema?: ZodObject | null; - selected_node_default_properties?: Record; - selected_node_default_style?: React.CSSProperties; - selected_node_default_text?: Tokens.StringValueExpression; - selected_node_context?: Record; - }; - theme: { - is_powered_by_branding_enabled: boolean; - lang: FormsPageLanguage; - appearance: Appearance; - palette?: FormStyleSheetV1Schema["palette"]; - fontFamily: FontFamily; - customCSS?: FormStyleSheetV1Schema["custom"]; - section?: FormStyleSheetV1Schema["section"]; - background?: FormPageBackgroundSchema; - }; + document: IDocumentState; } interface IFieldEditorState { diff --git a/apps/forms/scaffolds/editor/use.ts b/apps/forms/scaffolds/editor/use.ts index 03ea26ee9..b0b6c07a5 100644 --- a/apps/forms/scaffolds/editor/use.ts +++ b/apps/forms/scaffolds/editor/use.ts @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useContext } from "react"; +import { useMemo, useContext, useCallback } from "react"; import type { EditorState, GDocTable, GDocTableID } from "./state"; import { useDispatch, type FlatDispatcher } from "./dispatch"; @@ -95,3 +95,78 @@ export function useDatabaseTableId(): string | null { return table_id; } + +/** + * @example + * ```ts + * const t = useDocumentTranslations() + * ``` + */ +export function useDocumentTranslations() { + const [state, dispatch] = useEditorState(); + const { lang, lang_default, resources: messages } = state.document.g11n; + // + + return useCallback( + (key: string) => { + return messages[lang]?.[key]; + }, + [lang, messages] + ); +} + +export function useG11nResource(key: string) { + // const onEditTitle = useCallback( + // (title: string) => { + // dispatch({ + // type: "blocks/title", + // block_id: id, + // title_html: title, + // }); + // }, + // [dispatch, id] + // ); + + // const onEditDescription = useCallback( + // (description: string) => { + // dispatch({ + // type: "blocks/description", + // block_id: id, + // description_html: description, + // }); + // }, + // [dispatch, id] + // ); + + const [state, dispatch] = useEditorState(); + const { lang, lang_default, resources } = state.document.g11n; + + const fallback = useMemo(() => { + return resources[lang_default]?.[key]; + }, [lang_default, resources, key]); + + const value = useMemo(() => { + return resources[lang]?.[key]; + }, [lang, resources, key]); + + const change = useCallback( + (message?: string) => { + dispatch({ + type: "editor/document/langs/messages/change", + key: key, + message: message, + lang: lang, + }); + }, + [key, lang, dispatch] + ); + + return { + fallback, + value, + change, + lang, + lang_default, + isTranslationMode: lang !== lang_default, + }; +} diff --git a/apps/forms/scaffolds/grid-editor/components/layout.tsx b/apps/forms/scaffolds/grid-editor/components/layout.tsx index 0162ae316..cbc0381b4 100644 --- a/apps/forms/scaffolds/grid-editor/components/layout.tsx +++ b/apps/forms/scaffolds/grid-editor/components/layout.tsx @@ -8,7 +8,7 @@ export function Root({ children }: React.PropsWithChildren<{}>) { export function Header({ children }: React.PropsWithChildren<{}>) { return ( -
+
{children}
); diff --git a/apps/forms/scaffolds/grid-editor/index.tsx b/apps/forms/scaffolds/grid-editor/index.tsx index 0e6b431dc..50867ccd5 100644 --- a/apps/forms/scaffolds/grid-editor/index.tsx +++ b/apps/forms/scaffolds/grid-editor/index.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo } from "react"; import { ResponseGrid } from "../grid"; -import { createClientFormsClient } from "@/lib/supabase/client"; +import { createClientComponentFormsClient } from "@/lib/supabase/client"; import { AlertDialog, AlertDialogContent, @@ -73,7 +73,7 @@ export function GridEditor({ selection: "on" | "off"; deletion: "on" | "off"; }) { - const supabase = useMemo(() => createClientFormsClient(), []); + const supabase = useMemo(() => createClientComponentFormsClient(), []); const [state, dispatch] = useEditorState(); const { datagrid_isloading, datagrid_selected_rows } = state; @@ -481,7 +481,7 @@ function TableMod() { function useDeleteSelectedSchemaTableRows() { const [state, dispatch] = useEditorState(); - const supabase = createClientFormsClient(); + const supabase = createClientComponentFormsClient(); const { datagrid_selected_rows } = state; return useCallback(() => { const deleting = supabase diff --git a/apps/forms/scaffolds/grid/grid.css b/apps/forms/scaffolds/grid/grid.css index 3f460810d..6e9672c3c 100644 --- a/apps/forms/scaffolds/grid/grid.css +++ b/apps/forms/scaffolds/grid/grid.css @@ -32,7 +32,7 @@ } .rdg-header-row > .rdg-cell { - border-top: 1px solid hsl(var(--border)); + border-top: none; border-bottom: 1px solid hsl(var(--border)); } diff --git a/apps/forms/scaffolds/i18n-editor/index.tsx b/apps/forms/scaffolds/i18n-editor/index.tsx new file mode 100644 index 000000000..724d7c5e0 --- /dev/null +++ b/apps/forms/scaffolds/i18n-editor/index.tsx @@ -0,0 +1,170 @@ +"use client"; + +import React from "react"; +import { useEditorState } from "../editor"; +import { DotsHorizontalIcon, DownloadIcon } from "@radix-ui/react-icons"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { SparkleIcon } from "lucide-react"; +import { cn } from "@/utils"; +import * as GridLayout from "@/scaffolds/grid-editor/components/layout"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { language_label_map } from "@/k/supported_languages"; +import { useG11nResource } from "../editor/use"; +import { Badge } from "@/components/ui/badge"; + +export function I18nEditor() { + const [state] = useEditorState(); + const { lang, lang_default, keys } = state.document.g11n; + + return ( + + +
+ + +
+
+ +
+ + {keys.map((key) => { + return ; + })} + + {keys.map((key) => { + return ; + })} +
+
+ + + +
+ ); +} + +function DuoRow({ keyname }: { keyname: string }) { + const [state] = useEditorState(); + const { lang, lang_default, langs, keys } = state.document.g11n; + + const resource = useG11nResource(keyname); + + const badge: "done" | "todo" = resource.value ? "done" : "todo"; + + return ( +
+
+ + {resource.fallback || ( + (Empty) + )} +
+ + {keyname} + +
+
+
+ { + resource.change(e.target.value || undefined); + }} + className="flex-1 w-full flex items-center h-full" + /> +
+
+
+ + + + + + + { + alert("Not implemented yet - contact support"); + }} + > + Copy Translation Key + + + +
+
+
+
+ + {badge} + +
+
+
+ ); +} + +function GroupHeaderRow() { + return ( +
+ Namespace +
+ ); +} + +function AnchorLangCell({ + children, + className, + ...props +}: React.LabelHTMLAttributes) { + return ( + + ); +} + +function TargetLangCell({ + className, + ...props +}: React.InputHTMLAttributes) { + return ( + + ); +} diff --git a/apps/forms/scaffolds/media/editor-file-uploader.ts b/apps/forms/scaffolds/media/editor-file-uploader.ts index 4ac9edc23..1d85e2373 100644 --- a/apps/forms/scaffolds/media/editor-file-uploader.ts +++ b/apps/forms/scaffolds/media/editor-file-uploader.ts @@ -1,11 +1,11 @@ -import { createClientFormsClient } from "@/lib/supabase/client"; +import { createClientComponentFormsClient } from "@/lib/supabase/client"; import { useEditorState } from "../editor"; import { nanoid } from "nanoid"; import { useCallback } from "react"; export function useUploadFile() { const [state] = useEditorState(); - const supabase = createClientFormsClient(); + const supabase = createClientComponentFormsClient(); return useCallback( async (file: Blob | File) => { diff --git a/apps/forms/scaffolds/mediapicker/form-media-uploader.ts b/apps/forms/scaffolds/mediapicker/form-media-uploader.ts index b53e42649..beb4f3112 100644 --- a/apps/forms/scaffolds/mediapicker/form-media-uploader.ts +++ b/apps/forms/scaffolds/mediapicker/form-media-uploader.ts @@ -1,10 +1,10 @@ import { useCallback } from "react"; -import { createClientFormsClient } from "@/lib/supabase/client"; +import { createClientComponentFormsClient } from "@/lib/supabase/client"; import { useEditorState } from "../editor"; import { nanoid } from "nanoid"; function useStorageUploader(bucket: string, makefilekey: () => string) { - const supabase = createClientFormsClient(); + const supabase = createClientComponentFormsClient(); return useCallback( async (file: Blob | File) => { diff --git a/apps/forms/scaffolds/options/use-inventory.ts b/apps/forms/scaffolds/options/use-inventory.ts index 0af9674b4..1f4e1790f 100644 --- a/apps/forms/scaffolds/options/use-inventory.ts +++ b/apps/forms/scaffolds/options/use-inventory.ts @@ -3,13 +3,13 @@ import { InventoryStock } from "@/types/inventory"; import { INITIAL_INVENTORY_STOCK } from "@/k/inventory_defaults"; import { GridaCommerceClient } from "@/services/commerce"; import { useEditorState } from "../editor"; -import { createClientCommerceClient } from "@/lib/supabase/client"; +import { createClientComponentCommerceClient } from "@/lib/supabase/client"; import type { Option } from "@/types"; function useCommerceClient() { const [state] = useEditorState(); - const supabase = useMemo(() => createClientCommerceClient(), []); + const supabase = useMemo(() => createClientComponentCommerceClient(), []); const commerce = useMemo( () => diff --git a/apps/forms/scaffolds/playground/preview/index.tsx b/apps/forms/scaffolds/playground/preview/index.tsx index e5d74c643..1c59eb21f 100644 --- a/apps/forms/scaffolds/playground/preview/index.tsx +++ b/apps/forms/scaffolds/playground/preview/index.tsx @@ -8,7 +8,7 @@ import resources from "@/i18n"; import { nanoid } from "nanoid"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import useVariablesCSS from "../use-variables-css"; -import { FormsPageLanguage } from "@/types"; +import { LanguageCode } from "@/types"; import { useTheme } from "next-themes"; import type { FormEventMessage, @@ -170,7 +170,7 @@ export function PlaygroundPreviewSlave() { }, []); const [renderer, invalid] = useRenderer(schema); - const lang: FormsPageLanguage = (renderer?.lang ?? "en") as FormsPageLanguage; + const lang: LanguageCode = (renderer?.lang ?? "en") as LanguageCode; return ( <> diff --git a/apps/forms/scaffolds/settings/customize/custom-ending-page-preferences.tsx b/apps/forms/scaffolds/settings/customize/custom-ending-page-preferences.tsx index 576c9f599..ee16ae8c5 100644 --- a/apps/forms/scaffolds/settings/customize/custom-ending-page-preferences.tsx +++ b/apps/forms/scaffolds/settings/customize/custom-ending-page-preferences.tsx @@ -24,12 +24,12 @@ import { MixIcon } from "@radix-ui/react-icons"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { I18nProvider } from "@/i18n/csr"; import { useTranslation } from "react-i18next"; -import { createClientFormsClient } from "@/lib/supabase/client"; +import { createClientComponentFormsClient } from "@/lib/supabase/client"; import toast from "react-hot-toast"; import type { EndingPageI18nOverrides, EndingPageTemplateID, - FormsPageLanguage, + LanguageCode, } from "@/types"; import { render, @@ -51,12 +51,12 @@ export function EndingPagePreferences() { const { form, - theme: { lang }, + document: { g11n }, form: { ending }, } = state; const [customizeOpen, setCustomizeOpen] = useState(false); - const supabase = createClientFormsClient(); + const supabase = createClientComponentFormsClient(); const { handleSubmit, @@ -158,12 +158,12 @@ export function EndingPagePreferences() { Enabling ending page will disable redirection - + {template && (
@@ -175,7 +175,7 @@ export function EndingPagePreferences() { key={template} form_id={form.form_id} title={form.form_title} - lang={lang} + lang={g11n.lang} init={{ template_id: template ?? "default", i18n_overrides: overrides, @@ -220,7 +220,7 @@ function Preview({ }: { template: EndingPageTemplateID; title: string; - lang: FormsPageLanguage; + lang: LanguageCode; overrides?: Record; }) { const { t } = useTranslation(); @@ -288,7 +288,7 @@ function CustomizeTemplate({ template_id?: EndingPageTemplateID; i18n_overrides?: Record; }; - lang: FormsPageLanguage; + lang: LanguageCode; title: string; form_id: string; onSave?: (template_id: string, data: Record) => void; diff --git a/apps/forms/scaffolds/sidebar/sidebar-mode-blocks.tsx b/apps/forms/scaffolds/sidebar/sidebar-mode-blocks.tsx deleted file mode 100644 index a16d574de..000000000 --- a/apps/forms/scaffolds/sidebar/sidebar-mode-blocks.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import React from "react"; -import { useEditorState, useFormFields } from "../editor"; -import { - SidebarMenuItem, - SidebarMenuItemActions, - SidebarMenuList, - SidebarSection, - SidebarMenuItemAction, - SidebarSectionHeaderItem, - SidebarSectionHeaderLabel, -} from "@/components/sidebar"; -import { EditorFlatFormBlock, MenuItem } from "../editor/state"; -import { FormBlockType, FormFieldDefinition, FormInputType } from "@/types"; -import { BlockTypeIcon } from "@/components/form-blcok-type-icon"; -import { FormFieldTypeIcon } from "@/components/form-field-type-icon"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { DotsHorizontalIcon } from "@radix-ui/react-icons"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { FormFieldBlockMenuItems } from "../blocks-editor/blocks/field-block"; -import { renderMenuItems } from "./render"; - -export function ModeDesign() { - const [state, dispatch] = useEditorState(); - - const { - document: { pages }, - } = state; - - const show_hierarchy = - state.document.selected_page_id && - ["form", "collection"].includes(state.document.selected_page_id); - - return ( - <> - {renderMenuItems(pages, { - onSelect: (page) => { - dispatch({ - type: "editor/document/select-page", - page_id: page.id, - }); - }, - })} - - {show_hierarchy && ( - <> -
- - - )} - - ); -} - -function FormBlockHierarchyList() { - const [state, dispatch] = useEditorState(); - const fields = useFormFields(); - // const [expands, setExpands] = useState>({}); - const { focus_block_id } = state; - - return ( - <> - {state.blocks.map((b) => { - const selected = focus_block_id === b.id; - const { label, icon } = blocklabel(b, { - fields, - }); - return ( - { - // setExpands((expands) => ({ - // ...expands, - // [b.id]: expanded, - // })); - // }} - level={b.parent_id ? 1 : 0} - selected={selected} - onSelect={() => { - dispatch({ - type: "blocks/focus", - block_id: b.id, - }); - }} - icon={} - > - {label} - - - - - - - - - - - - - - ); - })} - - ); -} - -function SiteLayerHierarchyList() { - return <>; -} - -function HierarchyView() { - const [state] = useEditorState(); - const { doctype } = state; - - return ( - - - - - - Layers - - - - - - {doctype === "v0_form" && } - {doctype === "v0_site" && } - - - - - ); -} - -function FormHierarchyItemIcon({ - icon: { namespace, type }, - className, -}: { - className?: string; - icon: - | { namespace: "block"; type: FormBlockType } - | { - namespace: "field"; - type: FormInputType; - }; -}) { - switch (namespace) { - case "block": - return ( - - ); - case "field": - return ( - - ); - } -} - -function blocklabel( - block: EditorFlatFormBlock, - context: { - fields: FormFieldDefinition[]; - } -): { - label: string; - icon: - | { namespace: "block"; type: FormBlockType } - | { - namespace: "field"; - type: FormInputType; - }; -} { - switch (block.type) { - case "field": - // find field - const field = context.fields.find((f) => f.id === block.form_field_id); - return { - label: field?.name ?? "...", - icon: { - namespace: "field", - type: field?.type ?? "text", - }, - }; - default: - return { - label: block.type, - icon: { - namespace: "block", - type: block.type, - }, - }; - } -} diff --git a/apps/forms/scaffolds/sidebar/sidebar-mode-design.tsx b/apps/forms/scaffolds/sidebar/sidebar-mode-design.tsx new file mode 100644 index 000000000..ff752d101 --- /dev/null +++ b/apps/forms/scaffolds/sidebar/sidebar-mode-design.tsx @@ -0,0 +1,412 @@ +"use client"; + +import React, { useCallback } from "react"; +import { useEditorState, useFormFields } from "../editor"; +import { + SidebarMenuItem, + SidebarMenuItemActions, + SidebarMenuList, + SidebarSection, + SidebarMenuItemAction, + SidebarSectionHeaderItem, + SidebarSectionHeaderLabel, + SidebarMenuItemLabel, + SidebarMenuLink, +} from "@/components/sidebar"; +import { EditorFlatFormBlock, MenuItem } from "../editor/state"; +import { + FormBlockType, + FormFieldDefinition, + FormInputType, + LanguageCode, +} from "@/types"; +import { BlockTypeIcon } from "@/components/form-blcok-type-icon"; +import { FormFieldTypeIcon } from "@/components/form-field-type-icon"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + DotsHorizontalIcon, + GlobeIcon, + TrashIcon, +} from "@radix-ui/react-icons"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { FormFieldBlockMenuItems } from "../blocks-editor/blocks/field-block"; +import { renderMenuItems } from "./render"; +import { useDialogState } from "@/components/hooks/use-dialog-state"; +import { AddNewLanguageDialog } from "../dialogs/langs-add-dialog"; +import { Badge } from "@/components/ui/badge"; +import { + DeleteConfirmationAlertDialog, + useDeleteConfirmationAlertDialogState, +} from "@/components/delete-confirmation-dialog"; +import { LanguagesIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { editorlink } from "@/lib/forms/url"; +import { + language_label_map, + supported_form_page_languages, +} from "@/k/supported_languages"; + +export function ModeDesign() { + const [state, dispatch] = useEditorState(); + + const { + document: { pages }, + } = state; + + const show_tools = + state.document.selected_page_id && + ["form", "collection"].includes(state.document.selected_page_id); + + return ( + <> + {renderMenuItems(pages, { + onSelect: (page) => { + dispatch({ + type: "editor/document/select-page", + page_id: page.id, + }); + }, + })} + + {/* WIP */} + {process.env.NODE_ENV === "development" && } + + {show_tools && ( + <> +
+ + + )} + + ); +} + +function LocalizationView() { + const router = useRouter(); + const [state, dispatch] = useEditorState(); + const addnewlangDialog = useDialogState("addnewlang", { refreshkey: true }); + const deleteConfirmationDialog = + useDeleteConfirmationAlertDialogState("deleteconfirmation", { + refreshkey: true, + }); + + const { lang, lang_default, langs } = state.document.g11n; + + const ismultilang = langs.length > 1; + + const switchLang = useCallback( + (lang: LanguageCode) => { + dispatch({ + type: "editor/document/langs/set-current", + lang: lang, + }); + }, + [dispatch] + ); + + const switchDefaultLang = useCallback( + (lang: LanguageCode) => { + dispatch({ + type: "editor/document/langs/set-default", + lang: lang, + }); + }, + [dispatch] + ); + + const deleteLang = useCallback( + (lang: LanguageCode) => { + dispatch({ + type: "editor/document/langs/delete", + lang, + }); + }, + [dispatch] + ); + + if (!ismultilang) { + return <>; + } + + return ( + <> + + + {...deleteConfirmationDialog} + key={deleteConfirmationDialog.refreshkey} + onDelete={async ({ id }) => { + deleteLang(id); + return true; + }} + /> + + {/* + Localization + */} + + + + + + Translations + + + + + + + + + + + Add Language + + + + + + + + {langs.map((l) => { + const isdefault = l === lang_default; + const isselected = l === lang; + return ( + switchLang(l)} + > + + {language_label_map[l].flag} {l}{" "} + {isdefault && ( + + default + + )} + + + + + + + + + { + router.push( + editorlink("form/g11n", { + basepath: state.basepath, + document_id: state.document_id, + }) + ); + }} + > + + Open in Translate + + + switchDefaultLang(l)} + > + + Set as Default + + + + deleteConfirmationDialog.openDialog({ + id: l, + title: "DELETE " + l, + description: `Are you sure you want to delete the language "${l}"? This action cannot be undone.`, + match: "DELETE " + l, + }) + } + > + + Delete + + + + + + + ); + })} + + + + ); +} + +function FormBlockHierarchyList() { + const [state, dispatch] = useEditorState(); + const fields = useFormFields(); + // const [expands, setExpands] = useState>({}); + const { focus_block_id } = state; + + return ( + <> + {state.blocks.map((b) => { + const selected = focus_block_id === b.id; + const { label, icon } = blocklabel(b, { + fields, + }); + return ( + { + // setExpands((expands) => ({ + // ...expands, + // [b.id]: expanded, + // })); + // }} + level={b.parent_id ? 1 : 0} + selected={selected} + onSelect={() => { + dispatch({ + type: "blocks/focus", + block_id: b.id, + }); + }} + icon={} + > + {label} + + + + + + + + + + + + + + ); + })} + + ); +} + +function SiteLayerHierarchyList() { + return <>; +} + +function HierarchyView() { + const [state] = useEditorState(); + const { doctype } = state; + + return ( + + + + + + Layers + + + + + + {doctype === "v0_form" && } + {doctype === "v0_site" && } + + + + + ); +} + +function FormHierarchyItemIcon({ + icon: { namespace, type }, + className, +}: { + className?: string; + icon: + | { namespace: "block"; type: FormBlockType } + | { + namespace: "field"; + type: FormInputType; + }; +}) { + switch (namespace) { + case "block": + return ( + + ); + case "field": + return ( + + ); + } +} + +function blocklabel( + block: EditorFlatFormBlock, + context: { + fields: FormFieldDefinition[]; + } +): { + label: string; + icon: + | { namespace: "block"; type: FormBlockType } + | { + namespace: "field"; + type: FormInputType; + }; +} { + switch (block.type) { + case "field": + // find field + const field = context.fields.find((f) => f.id === block.form_field_id); + return { + label: field?.name ?? "...", + icon: { + namespace: "field", + type: field?.type ?? "text", + }, + }; + default: + return { + label: block.type, + icon: { + namespace: "block", + type: block.type, + }, + }; + } +} diff --git a/apps/forms/scaffolds/sidebar/sidebar.tsx b/apps/forms/scaffolds/sidebar/sidebar.tsx index ee54d3caa..8aca2abcb 100644 --- a/apps/forms/scaffolds/sidebar/sidebar.tsx +++ b/apps/forms/scaffolds/sidebar/sidebar.tsx @@ -16,7 +16,7 @@ import { SidebarSectionHeaderItem, SidebarSectionHeaderLabel, } from "@/components/sidebar"; -import { ModeDesign } from "./sidebar-mode-blocks"; +import { ModeDesign } from "./sidebar-mode-design"; import { editorlink } from "@/lib/forms/url"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useWorkspace } from "../workspace"; diff --git a/apps/forms/scaffolds/sidecontrol/controls/hidden.tsx b/apps/forms/scaffolds/sidecontrol/controls/hidden.tsx index 2ab2ecbea..e3cb22474 100644 --- a/apps/forms/scaffolds/sidecontrol/controls/hidden.tsx +++ b/apps/forms/scaffolds/sidecontrol/controls/hidden.tsx @@ -1,4 +1,5 @@ import { Switch } from "@/components/ui/switch"; +import { PropertyLineControlRoot } from "../ui"; export function HiddenControl({ value, @@ -7,5 +8,9 @@ export function HiddenControl({ value?: boolean; onValueChange?: (value: boolean) => void; }) { - return ; + return ( + + + + ); } diff --git a/apps/forms/scaffolds/sidecontrol/controls/input.tsx b/apps/forms/scaffolds/sidecontrol/controls/input.tsx new file mode 100644 index 000000000..b1382e99e --- /dev/null +++ b/apps/forms/scaffolds/sidecontrol/controls/input.tsx @@ -0,0 +1,33 @@ +import { Input } from "@/components/ui/input"; +import { inputVariants } from "./utils/input-variants"; +import React from "react"; +import { cn } from "@/utils"; + +export function InputControl({ + id, + type = "text", + name, + value, + autoComplete = "off", + onValueChange, + placeholder = "Enter", + className, + ...props +}: Omit, "onChange"> & { + onValueChange?: (value?: string) => void; +}) { + return ( + { + onValueChange?.(e.target.value || undefined); + }} + {...props} + /> + ); +} diff --git a/apps/forms/scaffolds/sidecontrol/sidecontrol-doctype-form.tsx b/apps/forms/scaffolds/sidecontrol/sidecontrol-doctype-form.tsx index 8955ad375..966456fec 100644 --- a/apps/forms/scaffolds/sidecontrol/sidecontrol-doctype-form.tsx +++ b/apps/forms/scaffolds/sidecontrol/sidecontrol-doctype-form.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { SidebarMenuSectionContent, SidebarSection, @@ -16,12 +16,18 @@ import { import { useEditorState, useFormFields } from "@/scaffolds/editor"; import { MixIcon } from "@radix-ui/react-icons"; import { Tokens } from "@/ast"; -import { KeyIcon } from "lucide-react"; -import toast from "react-hot-toast"; import { FormExpression } from "@/lib/forms/expression"; import { PropertyLine, PropertyLineLabel } from "./ui"; import { EditBinaryExpression } from "../panels/extensions/v-edit"; import { PopoverClose } from "@radix-ui/react-popover"; +import { InputControl } from "./controls/input"; +import { FieldSupports } from "@/k/supported_field_types"; +import toast from "react-hot-toast"; +import { Badge } from "@/components/ui/badge"; +import { useG11nResource } from "../editor/use"; +import { language_label_map } from "@/k/supported_languages"; +import { g11nkey } from "../editor/g11n"; +import { FormFieldDefinition } from "@/types"; export function SideControlDoctypeForm() { const [state, dispatch] = useEditorState(); @@ -34,26 +40,83 @@ export function SideControlDoctypeForm() { } /** - * NOTE - the type string represents a id, not a scalar for this component, atm. - * TODO: support scalar types + * use within the context where focus_block_id is set + * @returns */ -type ConditionExpression = Tokens.ShorthandBooleanBinaryExpression; - -function SelectedFormBlockProperties() { - // +function useFocusedFormBlock() { const [state, dispatch] = useEditorState(); - const fields = useFormFields(); + + if (!state.focus_block_id) { + throw new Error("No block focused"); + } const block = useMemo( - () => state.blocks.find((b) => b.id === state.focus_block_id), + () => state.blocks.find((b) => b.id === state.focus_block_id)!, [state.blocks, state.focus_block_id] ); + const set_v_hidden = useCallback( + (exp: Tokens.ShorthandBooleanBinaryExpression) => { + dispatch({ + type: "blocks/hidden", + block_id: block.id, + v_hidden: exp, + }); + }, + [block] + ); + + return [block, { set_v_hidden }] as const; +} + +function useFormField(id?: string | null): FormFieldDefinition | undefined { + const fields = useFormFields(); + return useMemo( + () => (!id ? undefined : fields.find((f) => f.id === id)), + [fields, id] + ); +} + +function SelectedFormBlockProperties() { + const [block] = useFocusedFormBlock(); + + return ( +
+ + + Block + + + + Hidden + + + + + {block.type === "field" && } + {block.type === "header" && } + {block.type === "video" && } + {/* NOT SUPPORTED */} + {/* {block.type === "image" && } */} + {/* {block.type === "html" && } */} +
+ ); +} + +/** + * NOTE - the type string represents a id, not a scalar for this component, atm. + * TODO: support scalar types + */ +type ConditionExpression = Tokens.ShorthandBooleanBinaryExpression; + +function PropertyV_Hidden() { + const [block, { set_v_hidden }] = useFocusedFormBlock(); + + const fields = useFormFields(); + const [condition_v_hidden, set_condition_v_hidden] = useState(); - // console.log("block?.v_hidden", block?.v_hidden); - const _v_hidden_set = !!block?.v_hidden; const onSave = (e: any) => { @@ -71,73 +134,53 @@ function SelectedFormBlockProperties() { r, ]; - // - dispatch({ - type: "blocks/hidden", - block_id: block!.id, - v_hidden: exp, - }); + set_v_hidden(exp); }; return ( -
- - - Block - - - - Hidden - - -
- -
-
- -
- - ({ - type: "form_field_value", - identifier: f.id, - label: f.name, - }))} - rightOptions={(left) => - fields - .find((f) => f.id === left) - ?.options?.map((o) => ({ - type: "option_value_reference", - identifier: o.id, - label: o.label || o.value, - })) - } - defaultValue={condition_v_hidden} - onValueChange={(value) => { - set_condition_v_hidden(value); - }} - /> - - - -
-
-
-
-
-
- + + +
+ +
+
+ +
+ + ({ + type: "form_field_value", + identifier: f.id, + label: f.name, + }))} + rightOptions={(left) => + fields + .find((f) => f.id === left) + ?.options?.map((o) => ({ + type: "option_value_reference", + identifier: o.id, + label: o.label || o.value, + })) + } + defaultValue={condition_v_hidden} + onValueChange={(value) => { + set_condition_v_hidden(value); + }} + /> + + + +
+
{/*
{_v_hidden_set ? ( @@ -147,6 +190,220 @@ function SelectedFormBlockProperties() { "No condition set" )}
*/} -
+ + ); +} + +function BlockTypeField() { + const [state, dispatch] = useEditorState(); + const [block] = useFocusedFormBlock(); + const field = useFormField(block.form_field_id); + const is_hidden_field = field?.type === "hidden"; + const { lang, lang_default } = state.document.g11n; + const istranslationmode = lang !== lang_default; + + const label = useG11nResource( + g11nkey("block", { id: block.id, property: "label" }) + ); + + const placeholder = useG11nResource( + g11nkey("block", { id: block.id, property: "placeholder" }) + ); + + const helptext = useG11nResource( + g11nkey("block", { id: block.id, property: "help_text" }) + ); + + if (!field || is_hidden_field) return <>; + + return ( + + + + {istranslationmode ? ( + <> + Field{" "} + + {language_label_map[lang].flag} {lang} + + + ) : ( + <>Field + )} + + + + + Label + + + {FieldSupports.placeholder(field.type) && ( + + Placeholder + + + )} + + Help Text + + + + + ); +} + +function BlockTypeHeader() { + const [state, dispatch] = useEditorState(); + const [block] = useFocusedFormBlock(); + const { lang, lang_default } = state.document.g11n; + const istranslationmode = lang !== lang_default; + + const title = useG11nResource( + g11nkey("block", { id: block.id, property: "title_html" }) + ); + + const description = useG11nResource( + g11nkey("block", { id: block.id, property: "description_html" }) + ); + + return ( + + + + {istranslationmode ? ( + <> + Header{" "} + + {language_label_map[lang].flag} {lang} + + + ) : ( + <>Header + )} + + + + + Title + + + + Description + + + + + ); +} + +function BlockTypeVideo() { + const [state, dispatch] = useEditorState(); + const [block] = useFocusedFormBlock(); + const { lang, lang_default } = state.document.g11n; + const istranslationmode = lang !== lang_default; + + const src = useG11nResource( + g11nkey("block", { id: block.id, property: "src" }) + ); + + return ( + + + + {istranslationmode ? ( + <> + Video{" "} + + {language_label_map[lang].flag} {lang} + + + ) : ( + <>Video + )} + + + + + URL + + + + + ); +} + +function BlockTypeImage() { + const [state, dispatch] = useEditorState(); + const [block] = useFocusedFormBlock(); + const { lang, lang_default } = state.document.g11n; + const istranslationmode = lang !== lang_default; + + const src = useG11nResource( + g11nkey("block", { id: block.id, property: "src" }) + ); + + return ( + + + + {istranslationmode ? ( + <> + Image{" "} + + {language_label_map[lang].flag} {lang} + + + ) : ( + <>Image + )} + + + + + URL + + + + ); } diff --git a/apps/forms/scaffolds/sidecontrol/sidecontrol-global.tsx b/apps/forms/scaffolds/sidecontrol/sidecontrol-global.tsx index 8e9c84a9d..9f99119ef 100644 --- a/apps/forms/scaffolds/sidecontrol/sidecontrol-global.tsx +++ b/apps/forms/scaffolds/sidecontrol/sidecontrol-global.tsx @@ -26,7 +26,7 @@ import { Appearance, FontFamily, FormStyleSheetV1Schema, - FormsPageLanguage, + LanguageCode, } from "@/types"; import * as _variants from "@/theme/palettes"; import { PaletteColorChip } from "@/components/design/palette-color-chip"; @@ -38,6 +38,7 @@ import { MoonIcon, OpenInNewWindowIcon, Pencil2Icon, + PlusIcon, SunIcon, } from "@radix-ui/react-icons"; import { @@ -62,14 +63,17 @@ import { PreferenceBody, PreferenceBox, PreferenceBoxHeader, - PreferenceDescription, } from "@/components/preferences"; -import { - language_label_map, - supported_form_page_languages, -} from "@/k/supported_languages"; +import { supported_form_page_languages } from "@/k/supported_languages"; import { Switch } from "@/components/ui/switch"; import { PoweredByGridaWaterMark } from "@/components/powered-by-branding"; +import { PropertyLine, PropertyLineControlRoot, PropertyLineLabel } from "./ui"; +import { inputVariants } from "./controls/utils/input-variants"; +import { useDialogState } from "@/components/hooks/use-dialog-state"; +import { defineStepper } from "@stepperize/react"; +import { LanguageSelect } from "@/components/language-select"; +import { Badge } from "@/components/ui/badge"; +import { AddNewLanguageDialog } from "../dialogs/langs-add-dialog"; const { default: all, ...variants } = _variants; @@ -108,6 +112,14 @@ export function SideControlGlobal() { + + + Language + + + + + Custom CSS @@ -128,6 +140,223 @@ export function SideControlGlobal() { ); } +function Language() { + const [state, dispatch] = useEditorState(); + const localizationSetupDialog = useDialogState("localization-setup", { + refreshkey: true, + }); + const addnewlangDialog = useDialogState("addnewlang", { refreshkey: true }); + const { + document: { g11n }, + } = state; + + const onLangChange = useCallback( + (lang: LanguageCode) => { + dispatch({ + type: "editor/document/langs/set-current", + lang, + }); + }, + [dispatch] + ); + + const onDefaultLangChange = useCallback( + (lang: LanguageCode) => { + dispatch({ + type: "editor/document/langs/set-default", + lang, + }); + }, + [dispatch] + ); + + const ismultilangs = g11n.langs.length > 1; + + if (ismultilangs) { + return ( + <> + + +
+ {g11n.langs.map((l) => { + const isdefault = l === g11n.lang_default; + const iscurrent = l === g11n.lang; + return ( + onLangChange(l)} + variant={iscurrent ? "default" : "outline"} + key={l} + className="cursor-pointer" + > + {l}{" "} + {isdefault && ( + + (default) + + )} + + ); + })} + + + New + +
+
+ + ); + } + + return ( + <> + + + Default + + + {/* WIP - this is a entry point to enabling localization, disabling this will prevent users from setting up localization, we're good to go. */} + {process.env.NODE_ENV !== "production" && ( + + Localize + + { + if (checked) { + localizationSetupDialog.openDialog(); + } + }} + /> + + + )} + + ); +} + +const { useStepper } = defineStepper( + { id: "fallbacklang" }, + { id: "firstlang" } +); + +function EnableMultiLanguageDialog({ + ...props +}: React.ComponentProps) { + const [state, dispatch] = useEditorState(); + const stepper = useStepper(); + + const [firstLang, setFirstLang] = useState(); + + const changeDefaultLang = useCallback( + (lang: LanguageCode) => { + dispatch({ + type: "editor/document/langs/set-default", + lang, + }); + }, + [dispatch] + ); + + const addLang = useCallback( + (lang: LanguageCode) => { + dispatch({ + type: "editor/document/langs/add", + lang, + }); + }, + [dispatch] + ); + + return ( + + + Setup Localization + + Ensure your content feels local everywhere, making every user feel + right at home. + +
+
{ + e.preventDefault(); + if (stepper.isLast) { + // + addLang(firstLang!); + props?.onOpenChange?.(false); + } else { + stepper.next(); + } + }} + > + {stepper.when("fallbacklang", () => ( +
+ + +

+ + Your current contents will be used as the default language. + +
+ Choose a default language that will be used when a user's + preferred language isn't available. This ensures your + content is always accessible and understandable. +

+
+ ))} + {stepper.when("firstlang", () => ( +
+ + l !== state.document.g11n.lang_default + )} + /> +

+ Select the first language for localization. You can add or + delete languages later, so don't worry if you're + unsure. +

+
+ ))} +
+
+ + + + + + +
+
+ ); +} + function FontFamilyControl() { const [state, dispatch] = useEditorState(); @@ -512,19 +741,9 @@ function CustomCSS() { function Settings() { const [state, dispatch] = useEditorState(); const { - theme: { lang, is_powered_by_branding_enabled }, + theme: { is_powered_by_branding_enabled }, } = state; - const onLangChange = useCallback( - (lang: FormsPageLanguage) => { - dispatch({ - type: "editor/theme/lang", - lang, - }); - }, - [dispatch] - ); - const onPoweredByBrandingEnabledChange = useCallback( (enabled: boolean) => { dispatch({ @@ -550,51 +769,8 @@ function Settings() {
- Language Branding - - - Page Language} - description={ - <>Choose the language that your customers will be seeing. - } - /> - -
-
-
- - - The form page will be displayed in{" "} - - {language_label_map[lang]} - - -
-
-
-
-
-
) { return ( @@ -8,10 +9,30 @@ export function PropertyLine({ children }: React.PropsWithChildren<{}>) { ); } -export function PropertyLineLabel({ children }: React.PropsWithChildren<{}>) { +export function PropertyLineLabel({ + children, + className, + ...props +}: React.ComponentProps) { return ( -